1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ExchangedClaims {
34 #[serde(flatten)]
36 pub claims: NhiClaims,
37 pub av_subject_tokens: Vec<RevocationIdentity>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub av_actor_tokens: Vec<RevocationIdentity>,
43 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
54pub const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
56
57pub const JWT_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:jwt";
59
60#[derive(Debug, Deserialize)]
62pub struct TokenExchangeRequest {
63 pub grant_type: String,
65 pub subject_token: String,
67 pub subject_token_type: String,
69 pub audience: String,
71 #[serde(default)]
73 pub scope: Option<String>,
74 #[serde(default)]
76 pub actor_token: Option<String>,
77 #[serde(default)]
79 pub actor_token_type: Option<String>,
80}
81
82#[derive(Debug, Serialize)]
84pub struct TokenExchangeResponse {
85 pub access_token: String,
87 pub issued_token_type: String,
89 pub token_type: String,
91 pub expires_in: u64,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub scope: Option<String>,
96}
97
98#[derive(Debug, thiserror::Error)]
100#[non_exhaustive]
101pub enum ExchangeError {
102 #[error("unsupported grant_type: {0}")]
104 UnsupportedGrant(String),
105 #[error("unsupported subject_token_type: {0}")]
107 UnsupportedTokenType(String),
108 #[error("subject token validation failed: {0}")]
110 SubjectInvalid(#[from] crate::validator::IdentityError),
111 #[error("requested scope {0:?} exceeds subject scopes")]
113 ScopeExceedsSubject(String),
114 #[error("no scopes remain after intersection")]
116 EmptyIntersection,
117 #[error("subject token has expired")]
119 SubjectExpired,
120 #[error("subject delegation chain is missing or inconsistent")]
122 MissingSubjectChain,
123 #[error("requested scopes exceed supported limits or contain invalid characters")]
125 InvalidScopes,
126 #[error("token signing failed: {0}")]
128 SigningFailed(String),
129 #[error("delegation depth {depth} exceeds maximum {max}")]
131 DelegationExceeded {
132 depth: usize,
134 max: usize,
136 },
137 #[error("target audience {0:?} is not an allowed backend")]
139 TargetNotAllowed(String),
140 #[error("actor token must be an undelegated agent token")]
143 ActorNotDirect,
144}
145
146pub struct ExchangeActor<'a> {
148 pub claims: &'a NhiClaims,
150 pub chain: &'a [RevocationIdentity],
152}
153
154pub 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
172fn 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
184pub struct ExchangeParams<'a> {
186 pub subject: &'a NhiClaims,
188 pub subject_chain: &'a [RevocationIdentity],
190 pub subject_delegation_depth: usize,
192 pub target_audience: &'a str,
194 pub requested_scopes: Option<&'a str>,
196 pub azp: &'a str,
198 pub now_s: u64,
200 pub ttl_s: u64,
202 pub issuer: &'a str,
204 pub max_depth: usize,
207 pub allowed_audiences: &'a [String],
210}
211
212pub fn build_exchanged_claims(params: &ExchangeParams<'_>) -> Result<ExchangedClaims, ExchangeError> {
217 build_exchanged_claims_with_actor(params, None)
218}
219
220pub 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 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 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 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
366pub struct ExchangedValidation<'a> {
368 pub key: &'a DecodingKey,
370 pub kid: &'a str,
372 pub issuer: &'a str,
374 pub allowed_audiences: &'a [String],
376 pub max_depth: usize,
378 pub now_s: u64,
380 pub token_type: &'a str,
382 pub for_revocation: bool,
384}
385
386pub 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 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 || 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
505pub 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 #[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 #[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 #[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 #[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 #[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(¶ms).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(¶ms).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(¶ms);
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(¶ms);
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(¶ms);
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(¶ms);
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(¶ms).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(¶ms),
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(¶ms).unwrap();
826 assert_eq!(result.exp, 1105);
827 params.now_s = 1105;
828 assert!(matches!(
829 build_exchanged_claims(¶ms),
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(¶ms),
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(¶ms),
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, ¶ms)
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), ¶ms).is_err());
890 }
891 params.token_type = "av-tool+jwt";
892 assert!(verify_exchanged_token(&signed_exchange(&token, "av-tool+jwt", &encoding), ¶ms).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), ¶ms).is_err(),
910 "accepted malformed exchanged token variant {mutation}"
911 );
912 }
913 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, ¶ms).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, ¶ms).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(), ¶ms).is_err());
941 params.now_s = token.exp;
942 assert!(matches!(
943 verify_exchanged_token(&signed_exchange(&token, "JWT", &encoding), ¶ms),
944 Err(IdentityError::Expired { .. })
945 ));
946 params.for_revocation = true;
947 assert!(verify_exchanged_token(&signed_exchange(&token, "JWT", &encoding), ¶ms).is_ok());
948 params.for_revocation = false;
949 params.now_s = 1000;
950 assert!(verify_exchanged_token(&signed_exchange(&token, "JWT", &encoding), ¶ms).is_err());
951 params.for_revocation = true;
952 assert!(verify_exchanged_token(&signed_exchange(&token, "JWT", &encoding), ¶ms).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(¶ms).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(¶ms).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 ¶ms,
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(¶ms, 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 ¶ms,
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 ¶ms,
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 ¶ms,
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 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}