Skip to main content

av_identity/
exchange.rs

1//! RFC 8693 token exchange: exchange an inbound NHI token for a
2//! backend-scoped token with narrowed scopes.
3//!
4//! The exchange endpoint accepts a form-encoded request containing:
5//! - `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`
6//! - `subject_token` — the inbound NHI JWT
7//! - `subject_token_type=urn:ietf:params:oauth:token-type:jwt`
8//! - `audience` — the target backend name
9//! - `scope` — requested scopes (space-separated, optional)
10//! - `actor_token` / `actor_token_type` — optional RFC 8693 §2.1 actor:
11//!   a validated NHI token naming the agent that will act
12//!
13//! The issued token preserves the original `sub` (human principal) and
14//! records the acting agents in `act`, newest first. Without an actor
15//! token, `act.sub` and `azp` are the subject token's agent. With an actor
16//! token, `act.sub` and `azp` are the actor's agent and the subject's
17//! agent moves one level down (`act.act.sub`). Scopes are always an
18//! intersection, never a union: requested ∩ subject, and additionally
19//! ∩ actor when an actor token is present.
20
21use crate::claims::{ActorClaim, Audience, NhiClaims};
22use crate::revocation::{RevocationIdentity, RevocationStore, RevokedBy};
23use crate::validator::{
24    validate_claim_fields, IdentityError, DEFAULT_LEEWAY_SECS, MAX_IDENTITY_STRING_CHARS, MAX_SCOPES,
25};
26use jsonwebtoken::{Algorithm, DecodingKey, Validation};
27use serde::{Deserialize, Serialize};
28use std::collections::HashSet;
29
30/// Signed ancestry travels with exchanged tokens without trusting such claims
31/// in upstream NHI tokens. Each entry came from a verified delegation link.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ExchangedClaims {
34    /// Standard identity and authorization claims.
35    #[serde(flatten)]
36    pub claims: NhiClaims,
37    /// Original identities, leaf first, for JTI and instance revocation.
38    pub av_subject_tokens: Vec<RevocationIdentity>,
39    /// The RFC 8693 actor token's identity, when one was presented, so
40    /// revoking the actor also revokes every token it obtained.
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub av_actor_tokens: Vec<RevocationIdentity>,
43    /// Total delegation depth, including parent links removed during exchange.
44    pub av_delegation_depth: usize,
45}
46
47impl std::ops::Deref for ExchangedClaims {
48    type Target = NhiClaims;
49    fn deref(&self) -> &Self::Target {
50        &self.claims
51    }
52}
53
54/// RFC 8693 grant type.
55pub const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
56
57/// RFC 8693 JWT token type.
58pub const JWT_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:jwt";
59
60/// Token exchange request (form-encoded body).
61#[derive(Debug, Deserialize)]
62pub struct TokenExchangeRequest {
63    /// RFC 8693 grant type URI.
64    pub grant_type: String,
65    /// The inbound NHI JWT to exchange.
66    pub subject_token: String,
67    /// Token type of the subject (must be `urn:ietf:params:oauth:token-type:jwt`).
68    pub subject_token_type: String,
69    /// Target backend audience for the exchanged token.
70    pub audience: String,
71    /// Requested scopes (space-separated, optional).
72    #[serde(default)]
73    pub scope: Option<String>,
74    /// RFC 8693 actor token: a validated NHI token for the acting agent.
75    #[serde(default)]
76    pub actor_token: Option<String>,
77    /// Token type of the actor token (required when `actor_token` is set).
78    #[serde(default)]
79    pub actor_token_type: Option<String>,
80}
81
82/// Token exchange response.
83#[derive(Debug, Serialize)]
84pub struct TokenExchangeResponse {
85    /// The exchanged token (JWT or JSON-encoded claims).
86    pub access_token: String,
87    /// Token type URI of the issued token.
88    pub issued_token_type: String,
89    /// Always `"Bearer"`.
90    pub token_type: String,
91    /// Seconds until the exchanged token expires.
92    pub expires_in: u64,
93    /// Effective scopes of the exchanged token.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub scope: Option<String>,
96}
97
98/// Errors from the exchange process.
99#[derive(Debug, thiserror::Error)]
100#[non_exhaustive]
101pub enum ExchangeError {
102    /// The `grant_type` is not the RFC 8693 token exchange URI.
103    #[error("unsupported grant_type: {0}")]
104    UnsupportedGrant(String),
105    /// The `subject_token_type` is not `urn:ietf:params:oauth:token-type:jwt`.
106    #[error("unsupported subject_token_type: {0}")]
107    UnsupportedTokenType(String),
108    /// The inbound subject token failed identity validation.
109    #[error("subject token validation failed: {0}")]
110    SubjectInvalid(#[from] crate::validator::IdentityError),
111    /// The requested scope is not a subset of the subject's scopes.
112    #[error("requested scope {0:?} exceeds subject scopes")]
113    ScopeExceedsSubject(String),
114    /// Scope intersection produced an empty set.
115    #[error("no scopes remain after intersection")]
116    EmptyIntersection,
117    /// The subject has no remaining lifetime to delegate.
118    #[error("subject token has expired")]
119    SubjectExpired,
120    /// The caller failed to supply a verified source chain.
121    #[error("subject delegation chain is missing or inconsistent")]
122    MissingSubjectChain,
123    /// A scope request exceeds the supported count or field limits.
124    #[error("requested scopes exceed supported limits or contain invalid characters")]
125    InvalidScopes,
126    /// Token signing failed.
127    #[error("token signing failed: {0}")]
128    SigningFailed(String),
129    /// The resulting delegation chain exceeds the maximum allowed depth.
130    #[error("delegation depth {depth} exceeds maximum {max}")]
131    DelegationExceeded {
132        /// The computed depth of the resulting chain.
133        depth: usize,
134        /// The configured maximum.
135        max: usize,
136    },
137    /// The requested audience is not a configured backend name.
138    #[error("target audience {0:?} is not an allowed backend")]
139    TargetNotAllowed(String),
140    /// The actor token is itself delegated; it must directly identify the
141    /// acting agent.
142    #[error("actor token must be an undelegated agent token")]
143    ActorNotDirect,
144}
145
146/// A validated RFC 8693 actor token.
147pub struct ExchangeActor<'a> {
148    /// The actor token's claims.
149    pub claims: &'a NhiClaims,
150    /// Verified identities of the actor token (a single root token).
151    pub chain: &'a [RevocationIdentity],
152}
153
154/// Compute the intersection of two scope sets, respecting wildcard rules.
155/// Returns only scopes that are covered by BOTH inputs.
156pub fn scope_intersection(a: &[String], b: &[String]) -> Vec<String> {
157    let mut result = Vec::new();
158    let mut seen = HashSet::new();
159    for scope in a {
160        if b.iter().any(|bs| scope_covers(bs, scope)) && seen.insert(scope.as_str()) {
161            result.push(scope.clone());
162        }
163    }
164    for scope in b {
165        if a.iter().any(|as_| scope_covers(as_, scope)) && seen.insert(scope.as_str()) {
166            result.push(scope.clone());
167        }
168    }
169    result
170}
171
172/// Returns true if `parent` covers `child` (same wildcard rules as the
173/// delegation chain validator).
174fn scope_covers(parent: &str, child: &str) -> bool {
175    parent == "*"
176        || parent == child
177        || parent.strip_suffix(":*").is_some_and(|prefix| {
178            child
179                .strip_prefix(prefix)
180                .is_some_and(|rest| rest.starts_with(':'))
181        })
182}
183
184/// Parameters for building exchanged claims.
185pub struct ExchangeParams<'a> {
186    /// Claims from the validated inbound subject token.
187    pub subject: &'a NhiClaims,
188    /// Verified identities returned by `IdentityValidator`, leaf first.
189    pub subject_chain: &'a [RevocationIdentity],
190    /// Combined parent-token and actor depth returned by the validator.
191    pub subject_delegation_depth: usize,
192    /// Target backend audience for the exchanged token.
193    pub target_audience: &'a str,
194    /// Space-separated requested scopes (None inherits the subject's scopes).
195    pub requested_scopes: Option<&'a str>,
196    /// Authorized party identifier (the client requesting the exchange).
197    pub azp: &'a str,
198    /// Current time in seconds since epoch.
199    pub now_s: u64,
200    /// TTL in seconds for the exchanged token.
201    pub ttl_s: u64,
202    /// Issuer URI for the gateway itself (not the upstream IdP).
203    pub issuer: &'a str,
204    /// Maximum allowed delegation depth. The resulting `act` chain must
205    /// not exceed this depth.
206    pub max_depth: usize,
207    /// Backend names that are valid exchange targets. When non-empty, the
208    /// target audience must appear in this list.
209    pub allowed_audiences: &'a [String],
210}
211
212/// Build exchanged claims from a validated subject token. The resulting
213/// token preserves `sub` (the human), adds a new link to the `act`
214/// chain, scopes to the intersection of requested and subject scopes,
215/// and narrows audience to the target backend.
216pub fn build_exchanged_claims(params: &ExchangeParams<'_>) -> Result<ExchangedClaims, ExchangeError> {
217    build_exchanged_claims_with_actor(params, None)
218}
219
220/// [`build_exchanged_claims`] with an optional RFC 8693 actor token. The
221/// actor must be an undelegated token; its scopes further narrow the
222/// result, its agent becomes `act.sub` and `azp`, the subject's agent
223/// becomes `act.act.sub`, and the delegation depth grows by two.
224pub fn build_exchanged_claims_with_actor(
225    params: &ExchangeParams<'_>,
226    actor: Option<&ExchangeActor<'_>>,
227) -> Result<ExchangedClaims, ExchangeError> {
228    let subject = params.subject;
229    if let Some(actor) = actor {
230        if actor.claims.parent_token.is_some()
231            || actor.claims.act.is_some()
232            || actor.chain.len() != 1
233            || actor.chain.first() != Some(&RevocationIdentity::from(actor.claims))
234        {
235            return Err(ExchangeError::ActorNotDirect);
236        }
237    }
238    if params.subject_chain.first() != Some(&RevocationIdentity::from(subject))
239        || params.subject_chain.len().saturating_sub(1) > params.subject_delegation_depth
240        || subject.act.as_ref().map_or(0, |a| 1 + a.depth()) > params.subject_delegation_depth
241    {
242        return Err(ExchangeError::MissingSubjectChain);
243    }
244
245    if !params.allowed_audiences.is_empty()
246        && !params
247            .allowed_audiences
248            .iter()
249            .any(|a| a == params.target_audience)
250    {
251        return Err(ExchangeError::TargetNotAllowed(params.target_audience.to_owned()));
252    }
253
254    // Bound input before allocating or intersecting. UTF-8 uses at most four
255    // bytes per code point; the extra byte permits a separating space.
256    if params
257        .requested_scopes
258        .is_some_and(|s| s.len() > MAX_SCOPES * (4 * MAX_IDENTITY_STRING_CHARS + 1))
259    {
260        return Err(ExchangeError::InvalidScopes);
261    }
262    let requested: Vec<String> = match params.requested_scopes {
263        Some(scopes) => scopes
264            .split(' ')
265            .take(MAX_SCOPES + 1)
266            .map(str::to_owned)
267            .collect(),
268        None => subject.scopes.clone(),
269    };
270    if requested.len() > MAX_SCOPES
271        || requested.iter().any(|s| {
272            s.is_empty()
273                || s.chars().count() > MAX_IDENTITY_STRING_CHARS
274                || !s.bytes().all(|b| matches!(b, 0x21 | 0x23..=0x5b | 0x5d..=0x7e))
275                || av_core::text::contains_bidi_or_zero_width(s)
276        })
277    {
278        return Err(ExchangeError::InvalidScopes);
279    }
280
281    // RFC 6749 scope-token grammar also applies to subject entries. The
282    // inbound NHI profile permits non-ASCII scope names for compatibility,
283    // but an OAuth exchange must never serialize those as an invalid scope.
284    if subject
285        .scopes
286        .iter()
287        .any(|s| s.is_empty() || !s.bytes().all(|b| matches!(b, 0x21 | 0x23..=0x5b | 0x5d..=0x7e)))
288    {
289        return Err(ExchangeError::InvalidScopes);
290    }
291    let mut effective = scope_intersection(&requested, &subject.scopes);
292    if let Some(actor) = actor {
293        effective = scope_intersection(&effective, &actor.claims.scopes);
294    }
295    if effective.is_empty() {
296        return Err(ExchangeError::EmptyIntersection);
297    }
298
299    let subject_agent = ActorClaim {
300        sub: params.azp.to_owned(),
301        act: subject.act.as_ref().map(|a| Box::new(a.clone())),
302    };
303    let (act, azp, links) = match actor {
304        None => (subject_agent, params.azp.to_owned(), 1_usize),
305        Some(actor) => (
306            ActorClaim {
307                sub: actor.claims.instance_uid.clone(),
308                act: Some(Box::new(subject_agent)),
309            },
310            actor.claims.instance_uid.clone(),
311            2,
312        ),
313    };
314    let depth = params.subject_delegation_depth.saturating_add(links);
315    if depth > params.max_depth {
316        return Err(ExchangeError::DelegationExceeded {
317            depth,
318            max: params.max_depth,
319        });
320    }
321
322    let exp = params
323        .now_s
324        .saturating_add(params.ttl_s.min(crate::MAX_TTL_SECS))
325        .min(subject.exp)
326        .min(actor.map_or(u64::MAX, |actor| actor.claims.exp));
327    if exp <= params.now_s {
328        return Err(ExchangeError::SubjectExpired);
329    }
330
331    let claims = NhiClaims {
332        sub: subject.sub.clone(),
333        iss: params.issuer.to_owned(),
334        aud: Audience::Single(params.target_audience.to_owned()),
335        iat: params.now_s,
336        nbf: Some(params.now_s.max(subject.nbf.unwrap_or(subject.iat))),
337        exp,
338        jti: av_core::ids::new_event_uid(),
339        azp: Some(azp),
340        act: Some(act),
341        instance_uid: subject.instance_uid.clone(),
342        charter: subject.charter.clone(),
343        version: subject.version.clone(),
344        scopes: effective,
345        parent_token: None,
346    };
347    validate_claim_fields(&claims, params.now_s, DEFAULT_LEEWAY_SECS, false)?;
348    let exchanged = ExchangedClaims {
349        claims,
350        av_subject_tokens: params.subject_chain.to_vec(),
351        av_actor_tokens: actor.map(|actor| actor.chain.to_vec()).unwrap_or_default(),
352        av_delegation_depth: depth,
353    };
354    // Leave room for base64url expansion, the JOSE header and Ed25519
355    // signature inside the verifier's 16 KiB pre-authentication limit.
356    if serde_json::to_vec(&exchanged)
357        .map_err(|error| ExchangeError::SigningFailed(error.to_string()))?
358        .len()
359        > 11 * 1024
360    {
361        return Err(ExchangeError::InvalidScopes);
362    }
363    Ok(exchanged)
364}
365
366/// Trust and time settings for a token issued by this harness.
367pub struct ExchangedValidation<'a> {
368    /// Harness Ed25519 public key.
369    pub key: &'a DecodingKey,
370    /// Exact expected signing-key identifier.
371    pub kid: &'a str,
372    /// Exact expected harness issuer.
373    pub issuer: &'a str,
374    /// Configured backend names accepted as single-string audiences.
375    pub allowed_audiences: &'a [String],
376    /// Configured maximum combined delegation depth.
377    pub max_depth: usize,
378    /// Validation clock, in epoch seconds.
379    pub now_s: u64,
380    /// Exact expected type: `JWT` or `av-tool+jwt`.
381    pub token_type: &'a str,
382    /// Permit bounded future activation for holder revocation only.
383    pub for_revocation: bool,
384}
385
386/// Verify a harness-issued token without reading revocation storage.
387pub fn verify_exchanged_token(
388    token: &str,
389    params: &ExchangedValidation<'_>,
390) -> Result<ExchangedClaims, IdentityError> {
391    if token.len() > 16 * 1024 {
392        return Err(IdentityError::Malformed("exchanged token exceeds 16 KiB".into()));
393    }
394    let header = jsonwebtoken::decode_header(token).map_err(|e| IdentityError::Malformed(e.to_string()))?;
395    crate::validator::validate_jose_header(&header)?;
396    if !matches!(params.token_type, "JWT" | "av-tool+jwt")
397        || header.typ.as_deref() != Some(params.token_type)
398        || header.alg != Algorithm::EdDSA
399        || header.kid.as_deref() != Some(params.kid)
400    {
401        return Err(IdentityError::Verification(
402            "unexpected exchanged-token header".into(),
403        ));
404    }
405    let mut validation = Validation::new(Algorithm::EdDSA);
406    validation.set_audience(params.allowed_audiences);
407    validation.set_issuer(&[params.issuer]);
408    validation.set_required_spec_claims(&["exp", "aud", "sub", "iss", "iat", "jti", "nbf"]);
409    // Apply the supplied clock after signature verification, rather than
410    // mixing it with jsonwebtoken's process clock.
411    validation.validate_exp = false;
412    validation.validate_nbf = false;
413    let token = jsonwebtoken::decode::<ExchangedClaims>(token, params.key, &validation)
414        .map_err(|e| IdentityError::Verification(e.to_string()))?
415        .claims;
416    let Audience::Single(audience) = &token.aud else {
417        return Err(IdentityError::Verification(
418            "exchanged audience must be a single backend".into(),
419        ));
420    };
421    if !params.allowed_audiences.contains(audience) || token.parent_token.is_some() {
422        return Err(IdentityError::Verification(
423            "invalid exchanged-token audience or parent".into(),
424        ));
425    }
426    validate_claim_fields(
427        &token.claims,
428        params.now_s,
429        DEFAULT_LEEWAY_SECS,
430        params.for_revocation,
431    )?;
432    if token.scopes.iter().any(|scope| {
433        scope.is_empty()
434            || !scope
435                .bytes()
436                .all(|b| matches!(b, 0x21 | 0x23..=0x5b | 0x5d..=0x7e))
437    }) {
438        return Err(IdentityError::Verification("invalid OAuth scope token".into()));
439    }
440    if (!params.for_revocation && token.exp <= params.now_s)
441        || token.exp < params.now_s.saturating_sub(DEFAULT_LEEWAY_SECS)
442    {
443        return Err(IdentityError::Expired {
444            exp: token.exp,
445            now: params.now_s,
446        });
447    }
448    if !params.for_revocation
449        && token
450            .nbf
451            .is_some_and(|nbf| nbf > params.now_s.saturating_add(DEFAULT_LEEWAY_SECS))
452    {
453        return Err(IdentityError::Verification("token is not yet active".into()));
454    }
455    let act_links = token.act.as_ref().map_or(0, |a| 1 + a.depth());
456    if token.av_subject_tokens.is_empty()
457        || token.av_subject_tokens.len() > params.max_depth
458        || token.av_actor_tokens.len() > 1
459        || token.av_delegation_depth == 0
460        || token.av_delegation_depth > params.max_depth
461        || token.av_subject_tokens.len().saturating_add(token.av_actor_tokens.len()) > token.av_delegation_depth
462        || act_links > token.av_delegation_depth
463        || token.act.is_none()
464        // An actor-token exchange records the actor as `act.sub` above the
465        // subject's agent, so it always carries at least two act links.
466        || token.av_actor_tokens.first().is_some_and(|actor| {
467            act_links < 2 || token.act.as_ref().is_none_or(|act| act.sub != actor.instance_uid)
468        })
469    {
470        return Err(IdentityError::Verification(
471            "invalid exchanged-token ancestry or depth".into(),
472        ));
473    }
474    for ancestor in token.av_subject_tokens.iter().chain(&token.av_actor_tokens) {
475        if ancestor.iss.is_empty()
476            || ancestor.iss.chars().count() > MAX_IDENTITY_STRING_CHARS
477            || av_core::text::contains_bidi_or_zero_width(&ancestor.iss)
478            || ancestor.jti.trim().is_empty()
479            || ancestor.instance_uid.trim().is_empty()
480            || ancestor.jti.chars().any(char::is_control)
481            || ancestor.instance_uid.chars().any(char::is_control)
482            || ancestor.jti.chars().count() > MAX_IDENTITY_STRING_CHARS
483            || ancestor.instance_uid.chars().count() > 128
484            || av_core::text::contains_bidi_or_zero_width(&ancestor.jti)
485            || av_core::text::contains_bidi_or_zero_width(&ancestor.instance_uid)
486            || ancestor.iat > token.iat.saturating_add(DEFAULT_LEEWAY_SECS)
487        {
488            return Err(IdentityError::Verification(
489                "invalid exchanged-token ancestor".into(),
490            ));
491        }
492    }
493    if token
494        .av_subject_tokens
495        .first()
496        .is_none_or(|source| source.instance_uid != token.instance_uid)
497    {
498        return Err(IdentityError::Verification(
499            "subject instance does not match ancestry".into(),
500        ));
501    }
502    Ok(token)
503}
504
505/// Check a verified exchanged token in its separate namespace, then check
506/// every original identity against JTI and instance revocation rules.
507pub fn check_exchanged_revocation(
508    token: &ExchangedClaims,
509    exchanged: &dyn RevocationStore,
510    upstream: &dyn RevocationStore,
511) -> Result<(), IdentityError> {
512    if exchanged
513        .check_token(&token.iss, &token.jti, &token.instance_uid, token.iat)
514        .map_err(IdentityError::RevocationUnavailable)?
515        .is_some()
516    {
517        return Err(IdentityError::Revoked(token.jti.clone()));
518    }
519    for ancestor in token.av_subject_tokens.iter().chain(&token.av_actor_tokens) {
520        match upstream
521            .check_token(&ancestor.iss, &ancestor.jti, &ancestor.instance_uid, ancestor.iat)
522            .map_err(IdentityError::RevocationUnavailable)?
523        {
524            None => {}
525            Some(RevokedBy::Jti) => return Err(IdentityError::Revoked(ancestor.jti.clone())),
526            Some(RevokedBy::Instance { cutoff }) => {
527                return Err(IdentityError::InstanceRevoked {
528                    instance_uid: ancestor.instance_uid.clone(),
529                    cutoff,
530                })
531            }
532        }
533    }
534    Ok(())
535}
536
537#[cfg(test)]
538mod tests {
539    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
540
541    use super::*;
542    use proptest::prelude::*;
543
544    fn base_claims(scopes: &[&str]) -> NhiClaims {
545        NhiClaims {
546            sub: "user:alice@corp.com".into(),
547            iss: "idp.corp.com".into(),
548            aud: "agentvisor-ai".into(),
549            iat: 1000,
550            nbf: None,
551            exp: 1900,
552            jti: "jti-1".into(),
553            azp: None,
554            act: None,
555            instance_uid: "inst-1".into(),
556            charter: "support".into(),
557            version: "1.0".into(),
558            scopes: scopes.iter().map(|s| (*s).to_owned()).collect(),
559            parent_token: None,
560        }
561    }
562
563    #[test]
564    fn intersection_exact_match() {
565        let a = vec!["tool:read".to_owned(), "tool:write".to_owned()];
566        let b = vec!["tool:read".to_owned(), "payout".to_owned()];
567        let result = scope_intersection(&a, &b);
568        assert_eq!(result, vec!["tool:read".to_owned()]);
569    }
570
571    #[test]
572    fn intersection_wildcard() {
573        let a = vec!["tool:*".to_owned()];
574        let b = vec!["tool:read".to_owned(), "tool:write".to_owned()];
575        let result = scope_intersection(&a, &b);
576        assert!(result.contains(&"tool:read".to_owned()));
577        assert!(result.contains(&"tool:write".to_owned()));
578    }
579
580    #[test]
581    fn intersection_star() {
582        let a = vec!["*".to_owned()];
583        let b = vec!["tool:read".to_owned(), "payout".to_owned()];
584        let result = scope_intersection(&a, &b);
585        assert!(result.contains(&"tool:read".to_owned()));
586        assert!(result.contains(&"payout".to_owned()));
587    }
588
589    #[test]
590    fn intersection_empty() {
591        let a = vec!["tool:read".to_owned()];
592        let b = vec!["payout".to_owned()];
593        let result = scope_intersection(&a, &b);
594        assert!(result.is_empty());
595    }
596
597    proptest! {
598        /// Intersection is commutative: the same scopes come out
599        /// regardless of argument order.
600        #[test]
601        fn intersection_is_commutative(
602            a in prop::collection::vec("[a-z:*]{1,12}", 0..12),
603            b in prop::collection::vec("[a-z:*]{1,12}", 0..12),
604        ) {
605            let mut ab = scope_intersection(&a, &b);
606            ab.sort();
607            let mut ba = scope_intersection(&b, &a);
608            ba.sort();
609            prop_assert_eq!(ab, ba);
610        }
611
612        /// Intersection never grows: every output scope is covered by
613        /// both inputs (privilege narrowing).
614        #[test]
615        fn intersection_never_grows(
616            a in prop::collection::vec("[a-z:*]{1,12}", 0..12),
617            b in prop::collection::vec("[a-z:*]{1,12}", 0..12),
618        ) {
619            for scope in scope_intersection(&a, &b) {
620                let covered_a = a.iter().any(|as_| scope_covers(as_, &scope));
621                let covered_b = b.iter().any(|bs| scope_covers(bs, &scope));
622                prop_assert!(covered_a && covered_b, "scope {scope:?} not covered by both");
623            }
624        }
625
626        /// Intersection is a subset of each input (no escalation).
627        #[test]
628        fn intersection_is_subset_of_each_input(
629            a in prop::collection::vec("[a-z:*]{1,12}", 0..12),
630            b in prop::collection::vec("[a-z:*]{1,12}", 0..12),
631        ) {
632            for scope in scope_intersection(&a, &b) {
633                prop_assert!(a.contains(&scope) || b.contains(&scope));
634            }
635        }
636
637        /// Self-intersection is the identity (idempotence).
638        #[test]
639        fn intersection_with_self_is_idempotent(
640            a in prop::collection::vec("[a-z:*]{1,12}", 0..12),
641        ) {
642            let mut result = scope_intersection(&a, &a);
643            result.sort();
644            result.dedup();
645            let mut expected = a.clone();
646            expected.sort();
647            expected.dedup();
648            prop_assert_eq!(result, expected);
649        }
650
651        /// Total: never panics on arbitrary UTF-8 scope strings.
652        #[test]
653        fn intersection_never_panics(
654            a in prop::collection::vec(".*", 0..8),
655            b in prop::collection::vec(".*", 0..8),
656        ) {
657            let _ = scope_intersection(&a, &b);
658        }
659    }
660
661    fn default_params<'a>(
662        subject: &'a NhiClaims,
663        target: &'a str,
664        scopes: Option<&'a str>,
665        chain: &'a [RevocationIdentity],
666    ) -> ExchangeParams<'a> {
667        ExchangeParams {
668            subject,
669            subject_chain: chain,
670            subject_delegation_depth: subject.act.as_ref().map_or(0, |a| 1 + a.depth()),
671            target_audience: target,
672            requested_scopes: scopes,
673            azp: "client-1",
674            now_s: 1100,
675            ttl_s: 60,
676            issuer: "agentvisor-ai",
677            max_depth: 4,
678            allowed_audiences: &[],
679        }
680    }
681
682    #[test]
683    fn exchanged_claims_preserve_sub_and_narrow_scopes() {
684        let subject = base_claims(&["tool:read", "tool:write", "payout"]);
685        let chain = [RevocationIdentity::from(&subject)];
686        let params = default_params(&subject, "backend-a", Some("tool:read payout"), &chain);
687        let result = build_exchanged_claims(&params).unwrap();
688        assert_eq!(result.sub, "user:alice@corp.com");
689        assert_eq!(result.azp, Some("client-1".to_owned()));
690        assert!(result.act.is_some());
691        assert_eq!(result.act.as_ref().unwrap().sub, "client-1");
692        assert_eq!(result.aud, Audience::Single("backend-a".to_owned()));
693        assert_eq!(result.scopes, vec!["tool:read", "payout"]);
694        assert_eq!(result.exp - result.iat, 60);
695        assert_eq!(result.iss, "agentvisor-ai");
696    }
697
698    #[test]
699    fn exchanged_claims_add_act_link() {
700        let mut subject = base_claims(&["tool:read"]);
701        subject.act = Some(ActorClaim {
702            sub: "agent:billing".into(),
703            act: None,
704        });
705        let chain = [RevocationIdentity::from(&subject)];
706        let mut params = default_params(&subject, "backend-b", None, &chain);
707        params.azp = "client-2";
708        let result = build_exchanged_claims(&params).unwrap();
709        let act = result.act.as_ref().unwrap();
710        assert_eq!(act.sub, "client-2");
711        let inner = act.act.as_ref().unwrap();
712        assert_eq!(inner.sub, "agent:billing");
713        assert!(inner.act.is_none());
714    }
715
716    #[test]
717    fn exchange_refuses_scope_escalation() {
718        let subject = base_claims(&["tool:read"]);
719        let chain = [RevocationIdentity::from(&subject)];
720        let params = default_params(&subject, "backend-a", Some("tool:write"), &chain);
721        let result = build_exchanged_claims(&params);
722        assert!(result.is_err());
723    }
724
725    #[test]
726    fn exchange_refuses_excessive_depth() {
727        let mut subject = base_claims(&["tool:read"]);
728        subject.act = Some(ActorClaim {
729            sub: "a1".into(),
730            act: Some(Box::new(ActorClaim {
731                sub: "a2".into(),
732                act: None,
733            })),
734        });
735        let chain = [RevocationIdentity::from(&subject)];
736        let mut params = default_params(&subject, "backend-a", None, &chain);
737        params.max_depth = 1;
738        let result = build_exchanged_claims(&params);
739        assert!(result.is_err());
740        assert!(matches!(
741            result.unwrap_err(),
742            ExchangeError::DelegationExceeded { depth: 3, max: 1 }
743        ));
744    }
745
746    #[test]
747    fn exchange_refuses_unknown_audience() {
748        let subject = base_claims(&["tool:read"]);
749        let allowed = vec!["backend-a".to_owned(), "backend-b".to_owned()];
750        let chain = [RevocationIdentity::from(&subject)];
751        let mut params = default_params(&subject, "backend-c", None, &chain);
752        params.allowed_audiences = &allowed;
753        let result = build_exchanged_claims(&params);
754        assert!(result.is_err());
755        assert!(matches!(result.unwrap_err(), ExchangeError::TargetNotAllowed(_)));
756    }
757
758    #[test]
759    fn exchange_allows_known_audience() {
760        let subject = base_claims(&["tool:read"]);
761        let allowed = vec!["backend-a".to_owned()];
762        let chain = [RevocationIdentity::from(&subject)];
763        let mut params = default_params(&subject, "backend-a", None, &chain);
764        params.allowed_audiences = &allowed;
765        let result = build_exchanged_claims(&params);
766        assert!(result.is_ok());
767    }
768
769    #[test]
770    fn scope_requests_are_bounded_and_empty_authority_is_refused() {
771        let subject = base_claims(&[]);
772        let chain = [RevocationIdentity::from(&subject)];
773        for requested in [None, Some("tool:admin")] {
774            assert!(matches!(
775                build_exchanged_claims(&default_params(&subject, "backend-a", requested, &chain)),
776                Err(ExchangeError::EmptyIntersection)
777            ));
778        }
779        let subject = base_claims(&["tool:*"]);
780        let chain = [RevocationIdentity::from(&subject)];
781        let excessive = (0..65).map(|i| format!("tool:{i}")).collect::<Vec<_>>().join(" ");
782        let huge = "x".repeat(70_000);
783        let long_field = format!("tool:{}", "x".repeat(256));
784        for requested in [&excessive, &huge, &long_field] {
785            assert!(matches!(
786                build_exchanged_claims(&default_params(&subject, "backend-a", Some(requested), &chain)),
787                Err(ExchangeError::InvalidScopes)
788            ));
789        }
790        let result = build_exchanged_claims(&default_params(
791            &subject,
792            "backend-a",
793            Some("tool:a tool:a tool:b"),
794            &chain,
795        ))
796        .unwrap();
797        assert_eq!(result.scopes, vec!["tool:a", "tool:b"]);
798    }
799
800    #[test]
801    fn exchanged_output_stays_inside_verifier_size_limit_and_preserves_activation() {
802        let mut subject = base_claims(&["tool:*"]);
803        subject.nbf = Some(1105);
804        let chain = [RevocationIdentity::from(&subject)];
805        let params = default_params(&subject, "backend-a", Some("tool:read"), &chain);
806        assert_eq!(build_exchanged_claims(&params).unwrap().nbf, Some(1105));
807        let oversized = (0..64)
808            .map(|i| format!("tool:{i}:{}", "x".repeat(192)))
809            .collect::<Vec<_>>()
810            .join(" ");
811        let params = default_params(&subject, "backend-a", Some(&oversized), &chain);
812        assert!(matches!(
813            build_exchanged_claims(&params),
814            Err(ExchangeError::InvalidScopes)
815        ));
816    }
817
818    #[test]
819    fn exchange_never_outlives_its_subject_or_extends_depth_budget() {
820        let mut subject = base_claims(&["tool:read"]);
821        subject.exp = 1105;
822        let chain = [RevocationIdentity::from(&subject)];
823        let mut params = default_params(&subject, "backend-a", None, &chain);
824        params.ttl_s = u64::MAX;
825        let result = build_exchanged_claims(&params).unwrap();
826        assert_eq!(result.exp, 1105);
827        params.now_s = 1105;
828        assert!(matches!(
829            build_exchanged_claims(&params),
830            Err(ExchangeError::SubjectExpired)
831        ));
832        params.now_s = 1100;
833        params.subject_delegation_depth = params.max_depth;
834        assert!(matches!(
835            build_exchanged_claims(&params),
836            Err(ExchangeError::DelegationExceeded { depth: 5, max: 4 })
837        ));
838        params.subject_delegation_depth = 0;
839        params.subject_chain = &[];
840        assert!(matches!(
841            build_exchanged_claims(&params),
842            Err(ExchangeError::MissingSubjectChain)
843        ));
844    }
845
846    fn exchange_keys() -> (jsonwebtoken::EncodingKey, DecodingKey) {
847        use ed25519_dalek::pkcs8::{spki::der::pem::LineEnding, EncodePrivateKey, EncodePublicKey};
848        let signing = ed25519_dalek::SigningKey::from_bytes(&[42; 32]);
849        let private = signing.to_pkcs8_pem(LineEnding::LF).unwrap();
850        let public = signing.verifying_key().to_public_key_pem(LineEnding::LF).unwrap();
851        (
852            jsonwebtoken::EncodingKey::from_ed_pem(private.as_bytes()).unwrap(),
853            DecodingKey::from_ed_pem(public.as_bytes()).unwrap(),
854        )
855    }
856
857    fn signed_exchange(token: &ExchangedClaims, typ: &str, key: &jsonwebtoken::EncodingKey) -> String {
858        let mut header = jsonwebtoken::Header::new(Algorithm::EdDSA);
859        header.kid = Some("exchange-key".into());
860        header.typ = Some(typ.into());
861        jsonwebtoken::encode(&header, token, key).unwrap()
862    }
863
864    #[test]
865    fn exchanged_verification_enforces_type_issuer_audience_signature_time_and_ancestry() {
866        let subject = base_claims(&["tool:read"]);
867        let chain = [RevocationIdentity::from(&subject)];
868        let token = build_exchanged_claims(&default_params(&subject, "backend-a", None, &chain)).unwrap();
869        let (encoding, decoding) = exchange_keys();
870        let audiences = ["backend-a".to_owned()];
871        let mut params = ExchangedValidation {
872            key: &decoding,
873            kid: "exchange-key",
874            issuer: "agentvisor-ai",
875            allowed_audiences: &audiences,
876            max_depth: 4,
877            now_s: 1100,
878            token_type: "JWT",
879            for_revocation: false,
880        };
881        let signed = signed_exchange(&token, "JWT", &encoding);
882        assert_eq!(
883            verify_exchanged_token(&signed, &params)
884                .unwrap()
885                .av_subject_tokens,
886            chain
887        );
888        for typ in ["av-intent+jwt", "av-tool+jwt", "jwt"] {
889            assert!(verify_exchanged_token(&signed_exchange(&token, typ, &encoding), &params).is_err());
890        }
891        params.token_type = "av-tool+jwt";
892        assert!(verify_exchanged_token(&signed_exchange(&token, "av-tool+jwt", &encoding), &params).is_ok());
893        params.token_type = "JWT";
894        for mutation in 0..10 {
895            let mut invalid = token.clone();
896            match mutation {
897                0 => invalid.claims.iss = "other-issuer".into(),
898                1 => invalid.claims.aud = "other-backend".into(),
899                2 => invalid.claims.aud = Audience::Multi(audiences.to_vec()),
900                3 => invalid.av_subject_tokens.clear(),
901                4 => invalid.av_subject_tokens[0].jti.clear(),
902                5 => invalid.claims.jti.clear(),
903                6 => invalid.av_delegation_depth = 5,
904                7 => invalid.av_subject_tokens[0].instance_uid = "different".into(),
905                8 => invalid.av_subject_tokens[0].iss.clear(),
906                _ => invalid.claims.parent_token = Some("nested-jwt".into()),
907            }
908            assert!(
909                verify_exchanged_token(&signed_exchange(&invalid, "JWT", &encoding), &params).is_err(),
910                "accepted malformed exchanged token variant {mutation}"
911            );
912        }
913        // Tokens issued before issuer-bound ancestry was introduced must be
914        // reissued. Guessing the missing issuer would permit cross-issuer rules.
915        let mut legacy = serde_json::to_value(&token).unwrap();
916        legacy["av_subject_tokens"][0]
917            .as_object_mut()
918            .unwrap()
919            .remove("iss");
920        let mut header = jsonwebtoken::Header::new(Algorithm::EdDSA);
921        header.kid = Some("exchange-key".into());
922        let legacy = jsonwebtoken::encode(&header, &legacy, &encoding).unwrap();
923        assert!(verify_exchanged_token(&legacy, &params).is_err());
924
925        for mutation in 0..3 {
926            let mut header = jsonwebtoken::Header::new(Algorithm::EdDSA);
927            header.kid = Some("exchange-key".into());
928            match mutation {
929                0 => header.crit = Some(vec!["issuer-restriction".into()]),
930                1 => header.crit = Some(vec![]),
931                _ => header.extras.insert("b64", false),
932            }
933            let invalid = jsonwebtoken::encode(&header, &token, &encoding).unwrap();
934            assert!(verify_exchanged_token(&invalid, &params).is_err());
935        }
936
937        let mut forged = signed.into_bytes();
938        let end = forged.len() - 10;
939        forged[end] = if forged[end] == b'A' { b'B' } else { b'A' };
940        assert!(verify_exchanged_token(&String::from_utf8(forged).unwrap(), &params).is_err());
941        params.now_s = token.exp;
942        assert!(matches!(
943            verify_exchanged_token(&signed_exchange(&token, "JWT", &encoding), &params),
944            Err(IdentityError::Expired { .. })
945        ));
946        params.for_revocation = true;
947        assert!(verify_exchanged_token(&signed_exchange(&token, "JWT", &encoding), &params).is_ok());
948        params.for_revocation = false;
949        params.now_s = 1000;
950        assert!(verify_exchanged_token(&signed_exchange(&token, "JWT", &encoding), &params).is_err());
951        params.for_revocation = true;
952        assert!(verify_exchanged_token(&signed_exchange(&token, "JWT", &encoding), &params).is_ok());
953    }
954
955    #[test]
956    fn exchanged_revocation_uses_original_issuance_and_separate_namespaces() {
957        use crate::InMemoryRevocationStore;
958        let subject = base_claims(&["tool:read"]);
959        let chain = [
960            RevocationIdentity::from(&subject),
961            RevocationIdentity {
962                iss: subject.iss.clone(),
963                jti: "parent".into(),
964                instance_uid: "parent-instance".into(),
965                iat: 900,
966            },
967        ];
968        let mut params = default_params(&subject, "backend-a", None, &chain);
969        params.subject_delegation_depth = 1;
970        let token = build_exchanged_claims(&params).unwrap();
971        let upstream = InMemoryRevocationStore::new();
972        let exchanged = InMemoryRevocationStore::new();
973        upstream.revoke(&token.jti, 2000).unwrap();
974        assert!(check_exchanged_revocation(&token, &exchanged, &upstream).is_ok());
975        upstream.revoke_instance("parent-instance", 900, 2000).unwrap();
976        assert_eq!(
977            check_exchanged_revocation(&token, &exchanged, &upstream).unwrap_err(),
978            IdentityError::InstanceRevoked {
979                instance_uid: "parent-instance".into(),
980                cutoff: 900
981            }
982        );
983        let upstream = InMemoryRevocationStore::new();
984        upstream
985            .revoke_instance(&subject.instance_uid, 1000, 2000)
986            .unwrap();
987        assert!(matches!(
988            check_exchanged_revocation(&token, &exchanged, &upstream),
989            Err(IdentityError::InstanceRevoked { .. })
990        ));
991        let upstream = InMemoryRevocationStore::new();
992        upstream.revoke("parent", 2000).unwrap();
993        assert_eq!(
994            check_exchanged_revocation(&token, &exchanged, &upstream).unwrap_err(),
995            IdentityError::Revoked("parent".into())
996        );
997        let upstream = InMemoryRevocationStore::new();
998        exchanged.revoke(&token.jti, 2000).unwrap();
999        assert_eq!(
1000            check_exchanged_revocation(&token, &exchanged, &upstream).unwrap_err(),
1001            IdentityError::Revoked(token.jti.clone())
1002        );
1003        assert!(!upstream.is_revoked(&subject.jti).unwrap());
1004    }
1005
1006    #[test]
1007    fn exchanged_revocation_store_errors_never_report_active() {
1008        struct Offline;
1009        impl RevocationStore for Offline {
1010            fn revoke(&self, _: &str, _: u64) -> Result<(), String> {
1011                Err("offline".into())
1012            }
1013            fn is_revoked(&self, _: &str) -> Result<bool, String> {
1014                Err("offline".into())
1015            }
1016        }
1017        let subject = base_claims(&["tool:read"]);
1018        let chain = [RevocationIdentity::from(&subject)];
1019        let token = build_exchanged_claims(&default_params(&subject, "backend-a", None, &chain)).unwrap();
1020        let live = crate::InMemoryRevocationStore::new();
1021        assert!(matches!(
1022            check_exchanged_revocation(&token, &Offline, &live),
1023            Err(IdentityError::RevocationUnavailable(_))
1024        ));
1025        assert!(matches!(
1026            check_exchanged_revocation(&token, &live, &Offline),
1027            Err(IdentityError::RevocationUnavailable(_))
1028        ));
1029    }
1030
1031    #[test]
1032    fn oauth_scope_grammar_rejects_ambiguous_or_non_ascii_scope_tokens() {
1033        let subject = base_claims(&["tool:*"]);
1034        let chain = [RevocationIdentity::from(&subject)];
1035        for invalid in [
1036            "",
1037            "tool:read\ttool:write",
1038            "tool:read\n",
1039            "tool:read  tool:write",
1040            " tool:read",
1041            "tool:read ",
1042            "tool:\"read",
1043            "tool:read\\write",
1044            "tool:café",
1045        ] {
1046            assert!(
1047                matches!(
1048                    build_exchanged_claims(&default_params(&subject, "backend-a", Some(invalid), &chain)),
1049                    Err(ExchangeError::InvalidScopes)
1050                ),
1051                "accepted {invalid:?}"
1052            );
1053        }
1054        for invalid in ["tool:read tool:write", "tool:café", "tool:read\\write"] {
1055            let subject = base_claims(&[invalid]);
1056            let chain = [RevocationIdentity::from(&subject)];
1057            assert!(matches!(
1058                build_exchanged_claims(&default_params(&subject, "backend-a", None, &chain)),
1059                Err(ExchangeError::InvalidScopes)
1060            ));
1061        }
1062        let params = default_params(&subject, "backend-a", Some("tool:read tool:write"), &chain);
1063        assert_eq!(
1064            build_exchanged_claims(&params).unwrap().scopes,
1065            ["tool:read", "tool:write"]
1066        );
1067    }
1068
1069    fn actor_claims(scopes: &[&str]) -> NhiClaims {
1070        let mut actor = base_claims(scopes);
1071        actor.jti = "jti-actor".into();
1072        actor.instance_uid = "inst-child".into();
1073        actor.exp = 1500;
1074        actor
1075    }
1076
1077    #[test]
1078    fn actor_token_narrows_scopes_three_ways_and_nests_act() {
1079        let subject = base_claims(&["tool:read", "tool:write", "payout"]);
1080        let chain = [RevocationIdentity::from(&subject)];
1081        let actor = actor_claims(&["tool:read", "payout"]);
1082        let actor_chain = [RevocationIdentity::from(&actor)];
1083        let params = default_params(&subject, "backend-a", Some("tool:read tool:write"), &chain);
1084        let result = build_exchanged_claims_with_actor(
1085            &params,
1086            Some(&ExchangeActor {
1087                claims: &actor,
1088                chain: &actor_chain,
1089            }),
1090        )
1091        .unwrap();
1092        assert_eq!(result.sub, "user:alice@corp.com", "sub stays the human");
1093        assert_eq!(result.scopes, vec!["tool:read"], "requested ∩ subject ∩ actor");
1094        assert_eq!(result.azp.as_deref(), Some("inst-child"));
1095        let act = result.act.as_ref().unwrap();
1096        assert_eq!(act.sub, "inst-child", "the actor is the current actor");
1097        assert_eq!(
1098            act.act.as_ref().unwrap().sub,
1099            "client-1",
1100            "the subject's agent is one level down"
1101        );
1102        assert_eq!(result.av_delegation_depth, 2);
1103        assert_eq!(result.av_actor_tokens, actor_chain.to_vec());
1104        assert!(result.exp <= actor.exp, "never outlives the actor");
1105    }
1106
1107    #[test]
1108    fn actor_scopes_disjoint_from_request_leave_nothing() {
1109        let subject = base_claims(&["tool:read", "tool:write"]);
1110        let chain = [RevocationIdentity::from(&subject)];
1111        let actor = actor_claims(&["payout"]);
1112        let actor_chain = [RevocationIdentity::from(&actor)];
1113        let params = default_params(&subject, "backend-a", None, &chain);
1114        let actor = ExchangeActor {
1115            claims: &actor,
1116            chain: &actor_chain,
1117        };
1118        assert!(matches!(
1119            build_exchanged_claims_with_actor(&params, Some(&actor)),
1120            Err(ExchangeError::EmptyIntersection)
1121        ));
1122    }
1123
1124    #[test]
1125    fn delegated_actor_tokens_and_depth_overflow_are_refused() {
1126        let subject = base_claims(&["tool:read"]);
1127        let chain = [RevocationIdentity::from(&subject)];
1128        let mut delegated = actor_claims(&["tool:read"]);
1129        delegated.act = Some(ActorClaim {
1130            sub: "someone".into(),
1131            act: None,
1132        });
1133        let delegated_chain = [RevocationIdentity::from(&delegated)];
1134        let params = default_params(&subject, "backend-a", None, &chain);
1135        assert!(matches!(
1136            build_exchanged_claims_with_actor(
1137                &params,
1138                Some(&ExchangeActor {
1139                    claims: &delegated,
1140                    chain: &delegated_chain,
1141                })
1142            ),
1143            Err(ExchangeError::ActorNotDirect)
1144        ));
1145        let actor = actor_claims(&["tool:read"]);
1146        let actor_chain = [RevocationIdentity::from(&actor)];
1147        let mut params = default_params(&subject, "backend-a", None, &chain);
1148        params.max_depth = 1;
1149        assert!(matches!(
1150            build_exchanged_claims_with_actor(
1151                &params,
1152                Some(&ExchangeActor {
1153                    claims: &actor,
1154                    chain: &actor_chain,
1155                })
1156            ),
1157            Err(ExchangeError::DelegationExceeded { depth: 2, max: 1 })
1158        ));
1159    }
1160
1161    #[test]
1162    fn actor_exchange_verifies_and_revoking_the_actor_revokes_the_token() {
1163        let (encoding, decoding) = exchange_keys();
1164        let now = av_core::time::now_ms() / 1000;
1165        let mut subject = base_claims(&["tool:read"]);
1166        subject.iat = now - 10;
1167        subject.exp = now + 600;
1168        let chain = [RevocationIdentity::from(&subject)];
1169        let mut actor = actor_claims(&["tool:read"]);
1170        actor.iat = now - 5;
1171        actor.exp = now + 600;
1172        let actor_chain = [RevocationIdentity::from(&actor)];
1173        let mut params = default_params(&subject, "backend-a", None, &chain);
1174        params.now_s = now;
1175        let allowed = ["backend-a".to_owned()];
1176        params.allowed_audiences = &allowed;
1177        let exchanged = build_exchanged_claims_with_actor(
1178            &params,
1179            Some(&ExchangeActor {
1180                claims: &actor,
1181                chain: &actor_chain,
1182            }),
1183        )
1184        .unwrap();
1185        let jwt = signed_exchange(&exchanged, "JWT", &encoding);
1186        let verified = verify_exchanged_token(
1187            &jwt,
1188            &ExchangedValidation {
1189                key: &decoding,
1190                kid: "exchange-key",
1191                issuer: "agentvisor-ai",
1192                allowed_audiences: &allowed,
1193                max_depth: 4,
1194                now_s: now,
1195                token_type: "JWT",
1196                for_revocation: false,
1197            },
1198        )
1199        .unwrap();
1200        assert_eq!(verified.av_actor_tokens.len(), 1);
1201
1202        let exchanged_store = crate::InMemoryRevocationStore::new();
1203        let upstream = crate::InMemoryRevocationStore::new();
1204        check_exchanged_revocation(&verified, &exchanged_store, &upstream).unwrap();
1205        upstream.revoke("jti-actor", now + 600).unwrap();
1206        assert!(matches!(
1207            check_exchanged_revocation(&verified, &exchanged_store, &upstream),
1208            Err(IdentityError::Revoked(jti)) if jti == "jti-actor"
1209        ));
1210
1211        // An actor record without the matching act link is forged ancestry.
1212        let mut forged = exchanged.clone();
1213        forged.claims.act = Some(ActorClaim {
1214            sub: "inst-child".into(),
1215            act: None,
1216        });
1217        let forged = signed_exchange(&forged, "JWT", &encoding);
1218        assert!(verify_exchanged_token(
1219            &forged,
1220            &ExchangedValidation {
1221                key: &decoding,
1222                kid: "exchange-key",
1223                issuer: "agentvisor-ai",
1224                allowed_audiences: &allowed,
1225                max_depth: 4,
1226                now_s: now,
1227                token_type: "JWT",
1228                for_revocation: false,
1229            },
1230        )
1231        .is_err());
1232    }
1233}