1use std::sync::Arc;
57
58use cheers_core::{
59 validate_grant, Actor, AuthStrength, ClientAssertion, CodecError, Error, GrantError,
60 McpClaims, Owns, PrincipalId, PrincipalKind, Scope, ScopeRegistry, StoreError, UsedJtiStore,
61};
62use pasetors::keys::AsymmetricPublicKey;
63use pasetors::token::UntrustedToken;
64use pasetors::version4::{PublicToken, V4};
65
66use crate::bundles::{expand_scopes, BundleExpansionError, BundleStore};
67use crate::codec::PasetoV4SecretMinter;
68use crate::grants::GrantStore;
69use crate::ownership::{OwnershipRow, OwnershipStore};
70use crate::service_principal::{ServicePrincipalStore, SigningKeyStatus};
71use crate::session::generate_jti;
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[non_exhaustive]
81pub struct McpPolicy {
82 pub access_ttl_seconds: i64,
84 pub api_token_default_ttl_seconds: i64,
92 pub api_token_max_ttl_seconds: i64,
97}
98
99impl McpPolicy {
100 pub const DEFAULT_ACCESS_TTL_SECONDS: i64 = 10 * 60;
102 pub const DEFAULT_API_TOKEN_TTL_SECONDS: i64 = 90 * 24 * 60 * 60;
104 pub const MAX_API_TOKEN_TTL_SECONDS: i64 = 365 * 24 * 60 * 60;
106
107 pub fn new(access_ttl_seconds: i64) -> Self {
108 Self {
109 access_ttl_seconds,
110 ..Self::default()
111 }
112 }
113
114 pub fn with_access_ttl(mut self, seconds: i64) -> Self {
115 self.access_ttl_seconds = seconds;
116 self
117 }
118
119 pub fn with_api_token_ttls(mut self, default: i64, max: i64) -> Self {
123 self.api_token_default_ttl_seconds = default;
124 self.api_token_max_ttl_seconds = max;
125 self
126 }
127}
128
129impl Default for McpPolicy {
130 fn default() -> Self {
131 Self {
132 access_ttl_seconds: Self::DEFAULT_ACCESS_TTL_SECONDS,
133 api_token_default_ttl_seconds: Self::DEFAULT_API_TOKEN_TTL_SECONDS,
134 api_token_max_ttl_seconds: Self::MAX_API_TOKEN_TTL_SECONDS,
135 }
136 }
137}
138
139#[derive(Debug, Clone)]
141#[non_exhaustive]
142pub struct MintedMcpToken {
143 pub token: String,
145 pub claims: McpClaims,
147}
148
149#[derive(Debug, thiserror::Error)]
155#[non_exhaustive]
156pub enum McpMintError {
157 #[error("no entitlement: principal '{principal}' has no grant for aud '{aud}'")]
160 AudNotEntitled { principal: String, aud: String },
161 #[error("requested scope '{scope}' is not granted by both principals for aud '{aud}'")]
166 InvalidScope { scope: Scope, aud: String },
167 #[error("mint path expects {expected} principal; got {got}")]
171 WrongPrincipalKind {
172 expected: PrincipalKind,
173 got: PrincipalKind,
174 },
175 #[error(
181 "principal '{principal}' does not hold {} for aud '{aud}'",
182 .scopes.iter().map(|s| s.as_wire()).collect::<Vec<_>>().join(", ")
183 )]
184 UnentitledScopes {
185 principal: String,
186 scopes: Vec<Scope>,
187 aud: String,
188 },
189 #[error("requested ttl {requested}s is outside the allowed range 1..={max}s")]
193 TtlOutOfRange { requested: i64, max: i64 },
194 #[error(transparent)]
199 GrantMisconfigured(#[from] GrantError),
200 #[error(transparent)]
203 BundleExpansion(#[from] BundleExpansionError),
204 #[error(transparent)]
206 Store(#[from] StoreError),
207 #[error(transparent)]
209 Codec(#[from] CodecError),
210 #[error("service assertions are not configured on this authority")]
215 ServiceAssertionsUnconfigured,
216 #[error("malformed client assertion: {0}")]
221 AssertionMalformed(String),
222 #[error("client assertion kid '{0}' is unknown")]
224 AssertionUnknownKid(String),
225 #[error("client assertion kid '{0}' is a retired key")]
229 AssertionRetiredKey(String),
230 #[error("client assertion names '{iss}'/'{sub}' but kid '{kid}' belongs to '{owner}'")]
233 AssertionPrincipalMismatch {
234 kid: String,
235 owner: PrincipalId,
236 iss: PrincipalId,
237 sub: PrincipalId,
238 },
239 #[error("client assertion aud '{got}' is not the token endpoint '{expected}'")]
241 AssertionBadAudience { got: String, expected: String },
242 #[error("client assertion expired at {exp} (now {now})")]
244 AssertionExpired { exp: i64, now: i64 },
245 #[error("client assertion lifetime {lifetime}s exceeds the {max}s maximum")]
247 AssertionLifetimeTooLong { lifetime: i64, max: i64 },
248 #[error("client assertion jti '{0}' was already used")]
250 AssertionReplayed(String),
251 #[error("used-jti store failure: {0}")]
254 JtiStore(String),
255 #[error("principal '{principal}' holds none of the requested scopes for aud '{aud}'")]
258 NoGrantedScopes { principal: String, aud: String },
259}
260
261impl From<McpMintError> for Error {
262 fn from(e: McpMintError) -> Self {
263 match e {
264 McpMintError::Store(s) => Error::Store(s),
265 McpMintError::Codec(c) => Error::Codec(c),
266 McpMintError::BundleExpansion(BundleExpansionError::Store(s)) => Error::Store(s),
267 other => Error::InvalidInput(other.to_string()),
273 }
274 }
275}
276
277pub struct McpAuthority<B, G, O> {
288 minter: PasetoV4SecretMinter,
289 bundles: B,
290 grants: G,
291 ownership: O,
292 scopes: Arc<ScopeRegistry>,
295 iss: String,
296 kid: String,
300 policy: McpPolicy,
301 service: Option<ServiceAssertions>,
305}
306
307struct ServiceAssertions {
309 keys: Arc<dyn ServicePrincipalStore>,
310 used_jtis: Arc<dyn UsedJtiStore>,
311}
312
313impl<B, G, O> McpAuthority<B, G, O>
314where
315 B: BundleStore,
316 G: GrantStore,
317 O: OwnershipStore,
318{
319 pub fn new(
325 minter: PasetoV4SecretMinter,
326 bundles: B,
327 grants: G,
328 ownership: O,
329 scopes: Arc<ScopeRegistry>,
330 iss: impl Into<String>,
331 kid: impl Into<String>,
332 ) -> Self {
333 Self {
334 minter,
335 bundles,
336 grants,
337 ownership,
338 scopes,
339 iss: iss.into(),
340 kid: kid.into(),
341 policy: McpPolicy::default(),
342 service: None,
343 }
344 }
345
346 pub fn with_policy(mut self, policy: McpPolicy) -> Self {
348 self.policy = policy;
349 self
350 }
351
352 pub fn policy(&self) -> &McpPolicy {
353 &self.policy
354 }
355
356 pub fn with_service_assertions(
360 mut self,
361 keys: Arc<dyn ServicePrincipalStore>,
362 used_jtis: Arc<dyn UsedJtiStore>,
363 ) -> Self {
364 self.service = Some(ServiceAssertions { keys, used_jtis });
365 self
366 }
367
368 pub fn token_endpoint(&self) -> String {
370 format!("{}/token", self.iss.trim_end_matches('/'))
371 }
372
373 pub fn scopes(&self) -> &ScopeRegistry {
374 &self.scopes
375 }
376
377 pub fn issuer(&self) -> &str {
378 &self.iss
379 }
380
381 pub async fn mint_user_fresh(
403 &self,
404 user: PrincipalId,
405 actor: Option<Actor>,
406 camp_id: Option<String>,
407 aud: impl Into<String>,
408 now: i64,
409 ) -> Result<MintedMcpToken, McpMintError> {
410 if user.kind != PrincipalKind::User {
411 return Err(McpMintError::WrongPrincipalKind {
412 expected: PrincipalKind::User,
413 got: user.kind,
414 });
415 }
416 let aud = aud.into();
417
418 let entries = self.grants.list_for(&user, &aud).await?;
419 if entries.is_empty() {
420 return Err(McpMintError::AudNotEntitled {
421 principal: user.to_string(),
422 aud,
423 });
424 }
425
426 let scopes = expand_scopes(&self.bundles, &entries).await?;
427 for s in &scopes {
428 validate_grant(&self.scopes, user.kind, s, &aud)?;
429 }
430
431 let owns = match &camp_id {
432 Some(id) => {
433 let camp_principal = PrincipalId::camp(id);
434 let rows = self.ownership.list_for_principal(&camp_principal).await?;
435 rows_to_owns(&rows)
436 }
437 None => Owns::default(),
438 };
439
440 let mut claims = McpClaims::new(
441 self.iss.clone(),
442 aud,
443 user,
444 now,
445 now + self.policy.access_ttl_seconds,
446 generate_jti(),
447 scopes,
448 )
449 .with_auth_strength(AuthStrength::UserFresh);
450 if let Some(a) = actor {
451 claims = claims.with_act(a);
452 }
453 if let Some(c) = camp_id {
454 claims = claims.with_camp_id(c);
455 }
456 if !owns.is_empty() {
457 claims = claims.with_owns(owns);
458 }
459
460 let token = self.minter.mint_mcp(&claims, &self.kid)?;
461 Ok(MintedMcpToken { token, claims })
462 }
463
464 pub async fn mint_api_token(
516 &self,
517 user: PrincipalId,
518 aud: impl Into<String>,
519 requested: &[Scope],
520 ttl_seconds: Option<i64>,
521 now: i64,
522 ) -> Result<MintedMcpToken, McpMintError> {
523 if user.kind != PrincipalKind::User {
524 return Err(McpMintError::WrongPrincipalKind {
525 expected: PrincipalKind::User,
526 got: user.kind,
527 });
528 }
529 let aud = aud.into();
530
531 let ttl = self.api_token_ttl(ttl_seconds)?;
532
533 let entries = self.grants.list_for(&user, &aud).await?;
534 if entries.is_empty() {
535 return Err(McpMintError::AudNotEntitled {
536 principal: user.to_string(),
537 aud,
538 });
539 }
540
541 let effective = expand_scopes(&self.bundles, &entries).await?;
542 for s in &effective {
543 validate_grant(&self.scopes, user.kind, s, &aud)?;
544 }
545
546 let scopes = attenuate(requested, effective, &user, &aud)?;
547
548 self.sign_api_token(user, aud, scopes, ttl, now)
549 }
550
551 pub fn rotate_api_token(
593 &self,
594 user: PrincipalId,
595 aud: impl Into<String>,
596 held: &[Scope],
597 requested: &[Scope],
598 ttl_seconds: Option<i64>,
599 now: i64,
600 ) -> Result<MintedMcpToken, McpMintError> {
601 if user.kind != PrincipalKind::User {
602 return Err(McpMintError::WrongPrincipalKind {
603 expected: PrincipalKind::User,
604 got: user.kind,
605 });
606 }
607 let aud = aud.into();
608 let ttl = self.api_token_ttl(ttl_seconds)?;
609
610 let mut ceiling: Vec<Scope> = Vec::with_capacity(held.len());
611 for s in held {
612 validate_grant(&self.scopes, user.kind, s, &aud)?;
613 if !ceiling.contains(s) {
614 ceiling.push(s.clone());
615 }
616 }
617
618 let scopes = attenuate(requested, ceiling, &user, &aud)?;
619
620 self.sign_api_token(user, aud, scopes, ttl, now)
621 }
622
623 fn api_token_ttl(&self, ttl_seconds: Option<i64>) -> Result<i64, McpMintError> {
628 match ttl_seconds {
629 None => Ok(self.policy.api_token_default_ttl_seconds),
630 Some(n) if n >= 1 && n <= self.policy.api_token_max_ttl_seconds => Ok(n),
631 Some(n) => Err(McpMintError::TtlOutOfRange {
632 requested: n,
633 max: self.policy.api_token_max_ttl_seconds,
634 }),
635 }
636 }
637
638 fn sign_api_token(
643 &self,
644 user: PrincipalId,
645 aud: String,
646 scopes: Vec<Scope>,
647 ttl: i64,
648 now: i64,
649 ) -> Result<MintedMcpToken, McpMintError> {
650 let claims = McpClaims::new(
651 self.iss.clone(),
652 aud,
653 user,
654 now,
655 now + ttl,
656 generate_jti(),
657 scopes,
658 )
659 .with_auth_strength(AuthStrength::ApiToken);
660
661 let token = self.minter.mint_mcp(&claims, &self.kid)?;
662 Ok(MintedMcpToken { token, claims })
663 }
664
665 pub async fn mint_bootstrap(
691 &self,
692 camp: PrincipalId,
693 aud: impl Into<String>,
694 now: i64,
695 ) -> Result<MintedMcpToken, McpMintError> {
696 if camp.kind != PrincipalKind::Camp {
697 return Err(McpMintError::WrongPrincipalKind {
698 expected: PrincipalKind::Camp,
699 got: camp.kind,
700 });
701 }
702 let aud = aud.into();
703
704 let entries = self.grants.list_for(&camp, &aud).await?;
705 if entries.is_empty() {
706 return Err(McpMintError::AudNotEntitled {
707 principal: camp.to_string(),
708 aud,
709 });
710 }
711
712 let scopes = expand_scopes(&self.bundles, &entries).await?;
713 for s in &scopes {
714 validate_grant(&self.scopes, camp.kind, s, &aud)?;
715 }
716
717 let rows = self.ownership.list_for_principal(&camp).await?;
718 let owns = rows_to_owns(&rows);
719
720 let camp_id = camp.id.clone();
721 let mut claims = McpClaims::new(
722 self.iss.clone(),
723 aud,
724 camp,
725 now,
726 now + self.policy.access_ttl_seconds,
727 generate_jti(),
728 scopes,
729 )
730 .with_camp_id(camp_id)
731 .with_auth_strength(AuthStrength::Bootstrap);
732 if !owns.is_empty() {
733 claims = claims.with_owns(owns);
734 }
735
736 let token = self.minter.mint_mcp(&claims, &self.kid)?;
737 Ok(MintedMcpToken { token, claims })
738 }
739
740 pub async fn mint_token_exchange(
776 &self,
777 user: PrincipalId,
778 camp: PrincipalId,
779 actor: Option<Actor>,
780 aud: impl Into<String>,
781 requested_scope: Vec<Scope>,
782 now: i64,
783 ) -> Result<MintedMcpToken, McpMintError> {
784 if user.kind != PrincipalKind::User {
785 return Err(McpMintError::WrongPrincipalKind {
786 expected: PrincipalKind::User,
787 got: user.kind,
788 });
789 }
790 if camp.kind != PrincipalKind::Camp {
791 return Err(McpMintError::WrongPrincipalKind {
792 expected: PrincipalKind::Camp,
793 got: camp.kind,
794 });
795 }
796 let aud = aud.into();
797
798 let user_entries = self.grants.list_for(&user, &aud).await?;
800 if user_entries.is_empty() {
801 return Err(McpMintError::AudNotEntitled {
802 principal: user.to_string(),
803 aud,
804 });
805 }
806 let user_scopes = expand_scopes(&self.bundles, &user_entries).await?;
807 for s in &user_scopes {
808 validate_grant(&self.scopes, user.kind, s, &aud)?;
809 }
810
811 let camp_entries = self.grants.list_for(&camp, &aud).await?;
815 if camp_entries.is_empty() {
816 return Err(McpMintError::AudNotEntitled {
817 principal: camp.to_string(),
818 aud,
819 });
820 }
821 let camp_scopes = expand_scopes(&self.bundles, &camp_entries).await?;
822 for s in &camp_scopes {
823 validate_grant(&self.scopes, camp.kind, s, &aud)?;
824 }
825
826 for s in &requested_scope {
830 if !user_scopes.contains(s) || !camp_scopes.contains(s) {
831 return Err(McpMintError::InvalidScope {
832 scope: s.clone(),
833 aud: aud.clone(),
834 });
835 }
836 }
837
838 let rows = self.ownership.list_for_principal(&camp).await?;
841 let owns = rows_to_owns(&rows);
842
843 let camp_id = camp.id.clone();
844 let mut claims = McpClaims::new(
845 self.iss.clone(),
846 aud,
847 user,
848 now,
849 now + self.policy.access_ttl_seconds,
850 generate_jti(),
851 requested_scope,
852 )
853 .with_camp_id(camp_id)
854 .with_auth_strength(AuthStrength::UserFresh);
855 if let Some(a) = actor {
856 claims = claims.with_act(a);
857 }
858 if !owns.is_empty() {
859 claims = claims.with_owns(owns);
860 }
861
862 let token = self.minter.mint_mcp(&claims, &self.kid)?;
863 Ok(MintedMcpToken { token, claims })
864 }
865
866 pub async fn mint_service(
883 &self,
884 assertion: &str,
885 aud: impl Into<String>,
886 requested_scopes: &[Scope],
887 now: i64,
888 ) -> Result<MintedMcpToken, McpMintError> {
889 let aud = aud.into();
890 let svc = self
891 .service
892 .as_ref()
893 .ok_or(McpMintError::ServiceAssertionsUnconfigured)?;
894 let malformed = |m: &str| McpMintError::AssertionMalformed(m.to_owned());
895
896 let untrusted = UntrustedToken::<pasetors::token::Public, V4>::try_from(assertion)
897 .map_err(|_| malformed("not a v4.public token"))?;
898 let kid = serde_json::from_slice::<serde_json::Value>(untrusted.untrusted_footer())
901 .ok()
902 .and_then(|f| f.get("kid").and_then(|k| k.as_str()).map(str::to_owned))
903 .ok_or_else(|| malformed("footer has no kid"))?;
904
905 let key = svc
906 .keys
907 .list_all_signing_keys()
908 .await?
909 .into_iter()
910 .find(|k| k.kid == kid)
911 .ok_or_else(|| McpMintError::AssertionUnknownKid(kid.clone()))?;
912 if key.status != SigningKeyStatus::Active {
913 return Err(McpMintError::AssertionRetiredKey(kid));
914 }
915
916 let public = AsymmetricPublicKey::<V4>::from(&key.public_key[..])
917 .map_err(|_| malformed("stored public key is not Ed25519"))?;
918 let trusted = PublicToken::verify(&public, &untrusted, None, None)
919 .map_err(|_| malformed("signature verification failed"))?;
920 let claims: ClientAssertion = serde_json::from_str(trusted.payload())
921 .map_err(|e| McpMintError::AssertionMalformed(format!("payload: {e}")))?;
922
923 if claims.iss != key.principal_id
924 || claims.sub != key.principal_id
925 || key.principal_id.kind != PrincipalKind::Service
926 {
927 return Err(McpMintError::AssertionPrincipalMismatch {
928 kid,
929 owner: key.principal_id,
930 iss: claims.iss,
931 sub: claims.sub,
932 });
933 }
934 let expected = self.token_endpoint();
935 if claims.aud != expected {
936 return Err(McpMintError::AssertionBadAudience {
937 got: claims.aud,
938 expected,
939 });
940 }
941 if claims.exp <= now {
942 return Err(McpMintError::AssertionExpired { exp: claims.exp, now });
943 }
944 let lifetime = claims.exp - claims.iat;
945 if lifetime > ClientAssertion::MAX_LIFETIME_SECONDS {
946 return Err(McpMintError::AssertionLifetimeTooLong {
947 lifetime,
948 max: ClientAssertion::MAX_LIFETIME_SECONDS,
949 });
950 }
951 if claims.jti.is_empty() {
952 return Err(malformed("empty jti"));
953 }
954 if !svc
956 .used_jtis
957 .try_mark_used(&claims.jti, claims.exp)
958 .await
959 .map_err(McpMintError::JtiStore)?
960 {
961 return Err(McpMintError::AssertionReplayed(claims.jti));
962 }
963
964 let principal = claims.sub;
965 let entries = self.grants.list_for(&principal, &aud).await?;
966 let held = expand_scopes(&self.bundles, &entries).await?;
967 for s in &held {
968 validate_grant(&self.scopes, principal.kind, s, &aud)?;
969 }
970 let scopes: Vec<Scope> = if requested_scopes.is_empty() {
971 held
972 } else {
973 held.into_iter()
974 .filter(|s| requested_scopes.contains(s))
975 .collect()
976 };
977 if scopes.is_empty() {
978 return Err(McpMintError::NoGrantedScopes {
979 principal: principal.to_string(),
980 aud,
981 });
982 }
983
984 let ttl = self.policy.access_ttl_seconds.min(SERVICE_MAX_TTL_SECONDS);
985 let claims = McpClaims::new(
986 self.iss.clone(),
987 aud,
988 principal,
989 now,
990 now + ttl,
991 generate_jti(),
992 scopes,
993 );
994 let token = self.minter.mint_mcp(&claims, &self.kid)?;
995 Ok(MintedMcpToken { token, claims })
996 }
997}
998
999const SERVICE_MAX_TTL_SECONDS: i64 = 60 * 60;
1001
1002fn attenuate(
1012 requested: &[Scope],
1013 ceiling: Vec<Scope>,
1014 user: &PrincipalId,
1015 aud: &str,
1016) -> Result<Vec<Scope>, McpMintError> {
1017 if requested.is_empty() {
1018 return Ok(ceiling);
1019 }
1020 let unentitled: Vec<Scope> = requested
1021 .iter()
1022 .filter(|s| !ceiling.contains(s))
1023 .cloned()
1024 .collect();
1025 if !unentitled.is_empty() {
1026 return Err(McpMintError::UnentitledScopes {
1027 principal: user.to_string(),
1028 scopes: unentitled,
1029 aud: aud.to_owned(),
1030 });
1031 }
1032 let mut out: Vec<Scope> = Vec::with_capacity(requested.len());
1035 for s in requested {
1036 if !out.contains(s) {
1037 out.push(s.clone());
1038 }
1039 }
1040 Ok(out)
1041}
1042
1043fn rows_to_owns(rows: &[OwnershipRow]) -> Owns {
1055 fn push_unique(list: &mut Vec<String>, id: &str) {
1056 if !list.iter().any(|existing| existing == id) {
1057 list.push(id.to_owned());
1058 }
1059 }
1060 let mut o = Owns::default();
1061 for r in rows {
1062 if r.is_revoked() || r.resource_kind == cheers_core::KIND_RESOURCE {
1065 continue;
1066 }
1067 match r.resource_kind.as_str() {
1068 "service" => push_unique(&mut o.service, &r.resource_id),
1069 "arch_doc" => push_unique(&mut o.arch_doc, &r.resource_id),
1070 "node" => push_unique(&mut o.node, &r.resource_id),
1071 other => push_unique(
1072 o.extra.entry(other.to_owned()).or_default(),
1073 &r.resource_id,
1074 ),
1075 }
1076 }
1077 o
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082 use super::*;
1083 use crate::bundles::{BundleName, MemoryBundleStore, ScopeOrBundle};
1084 use crate::codec::PasetoV4SecretMinter;
1085 use crate::grants::SchemaGrantStore;
1086 use cheers_core::{RelationDef, ResourceSchema, SchemaRegistry, KIND_RESOURCE};
1087 use std::collections::HashMap;
1088 use crate::ownership::{MemoryOwnershipStore, NewOwnership, OwnershipStore};
1089 use async_trait::async_trait;
1090 use cheers_core::{yah_scopes, Scope, StoreError};
1091 use cheers_verify::PasetoV4PublicVerifier;
1092 use std::sync::Mutex;
1093
1094 const GRANT_KIND: &str = "grant";
1105
1106 fn test_schema(scopes: &cheers_core::ScopeRegistry) -> SchemaRegistry {
1107 let rels: Vec<RelationDef> = yah_scopes::DEFS
1108 .iter()
1109 .map(|d| RelationDef {
1110 name: Box::leak(d.scope.as_wire().to_owned().into_boxed_str()),
1111 membership: true,
1112 implies: &[],
1113 scopes: Box::leak(vec![d.scope.clone()].into_boxed_slice()),
1114 grants: &[],
1115 })
1116 .collect();
1117 SchemaRegistry::build(
1118 &[ResourceSchema {
1119 kind: GRANT_KIND,
1120 relations: &[],
1121 kind_relations: Box::leak(rels.into_boxed_slice()),
1122 }],
1123 scopes,
1124 )
1125 .unwrap()
1126 }
1127
1128 struct TestGrants {
1129 schema: SchemaGrantStore<MemoryOwnershipStore>,
1130 ownership: MemoryOwnershipStore,
1131 raw: Mutex<HashMap<(PrincipalId, String), Vec<ScopeOrBundle>>>,
1132 }
1133
1134 impl TestGrants {
1135 fn put_raw(&self, principal: PrincipalId, aud: &str, entries: Vec<ScopeOrBundle>) {
1136 self.raw.lock().unwrap().insert((principal, aud.to_owned()), entries);
1137 }
1138 }
1139
1140 #[async_trait]
1141 impl GrantStore for TestGrants {
1142 async fn list_for(
1143 &self,
1144 principal: &PrincipalId,
1145 aud: &str,
1146 ) -> Result<Vec<ScopeOrBundle>, StoreError> {
1147 let mut out = self.schema.list_for(principal, aud).await?;
1148 if let Some(raw) = self.raw.lock().unwrap().get(&(principal.clone(), aud.to_owned())) {
1149 out.extend(raw.iter().cloned());
1150 }
1151 Ok(out)
1152 }
1153 }
1154
1155 const RIG_KID: &str = "mcp-authority-test-kid-1";
1160
1161 fn rig() -> (
1162 McpAuthority<MemoryBundleStore, TestGrants, MemoryOwnershipStore>,
1163 PasetoV4PublicVerifier,
1164 ) {
1165 let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
1166 let bundles = MemoryBundleStore::with_defaults();
1167 let ownership = MemoryOwnershipStore::new();
1168 let scopes = Arc::new(
1169 yah_scopes::registry_at([
1170 "https://aud.example",
1171 "https://kamaji.camp.example",
1172 "https://kamaji.example",
1173 ])
1174 .unwrap(),
1175 );
1176 let grants = TestGrants {
1177 schema: SchemaGrantStore::new(
1178 ownership.clone(),
1179 Arc::new(test_schema(&scopes)),
1180 scopes.clone(),
1181 ),
1182 ownership: ownership.clone(),
1183 raw: Mutex::default(),
1184 };
1185 let authority = McpAuthority::new(
1186 minter,
1187 bundles,
1188 grants,
1189 ownership,
1190 scopes,
1191 "https://cheers.example",
1192 RIG_KID,
1193 );
1194 (authority, verifier)
1195 }
1196
1197 fn put_simple_grant(
1201 authority: &McpAuthority<MemoryBundleStore, TestGrants, MemoryOwnershipStore>,
1202 principal: PrincipalId,
1203 aud: &str,
1204 entries: Vec<ScopeOrBundle>,
1205 ) {
1206 let mut raw = Vec::new();
1207 for e in entries {
1208 match e {
1209 ScopeOrBundle::Scope(scope) => {
1210 let n = NewOwnership::new(
1211 principal.clone(),
1212 KIND_RESOURCE,
1213 GRANT_KIND,
1214 scope.as_wire(),
1215 PrincipalId::service("seed"),
1216 None,
1217 )
1218 .unwrap();
1219 pollster::block_on(authority.grants.ownership.insert(&n, 1)).unwrap();
1220 }
1221 bundle => raw.push(bundle),
1222 }
1223 }
1224 if !raw.is_empty() {
1225 authority.grants.put_raw(principal, aud, raw);
1226 }
1227 }
1228
1229 #[test]
1232 fn mcp_policy_default_is_ten_minutes() {
1233 let p = McpPolicy::default();
1234 assert_eq!(p.access_ttl_seconds, 10 * 60);
1235 let p = McpPolicy::new(300);
1236 assert_eq!(p.access_ttl_seconds, 300);
1237 let p = McpPolicy::default().with_access_ttl(5 * 60);
1238 assert_eq!(p.access_ttl_seconds, 5 * 60);
1239 }
1240
1241 #[test]
1244 fn mint_user_fresh_signs_token_verifiable_at_edge() {
1245 let (authority, verifier) = rig();
1246 let user = PrincipalId::user("alice");
1247 let aud = "https://kamaji.camp.example";
1248 put_simple_grant(
1249 &authority,
1250 user.clone(),
1251 aud,
1252 vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY), ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
1253 );
1254
1255 pollster::block_on(async {
1256 let minted = authority
1257 .mint_user_fresh(user.clone(), None, None, aud, 1_000)
1258 .await
1259 .unwrap();
1260
1261 assert!(minted.token.starts_with("v4.public."));
1263 assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
1265 assert!(!minted.claims.jti.is_empty());
1267 assert_eq!(minted.claims.iat, 1_000);
1268 assert_eq!(
1269 minted.claims.exp,
1270 1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
1271 );
1272 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1274 assert_eq!(back, minted.claims);
1275 assert_eq!(back.sub, user);
1276 assert_eq!(back.scope, vec![yah_scopes::CLOUD_DEPLOY, yah_scopes::CLOUD_READ]);
1277 });
1278 }
1279
1280 #[test]
1281 fn mint_user_fresh_expands_bundles_at_mint_time() {
1282 let (authority, _verifier) = rig();
1286 let user = PrincipalId::user("bob");
1287 let aud = "https://kamaji.example";
1288 put_simple_grant(
1289 &authority,
1290 user.clone(),
1291 aud,
1292 vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
1293 );
1294
1295 pollster::block_on(async {
1296 let first = authority
1297 .mint_user_fresh(user.clone(), None, None, aud, 1_000)
1298 .await
1299 .unwrap();
1300 assert!(first.claims.scope.contains(&yah_scopes::CLOUD_DESTROY));
1301
1302 authority
1304 .bundles
1305 .put(
1306 &BundleName::new("deploy-admin"),
1307 &[yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY],
1308 )
1309 .await
1310 .unwrap();
1311
1312 let second = authority
1313 .mint_user_fresh(user, None, None, aud, 1_100)
1314 .await
1315 .unwrap();
1316 assert!(!second.claims.scope.contains(&yah_scopes::CLOUD_DESTROY));
1317 assert_eq!(second.claims.scope, vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY]);
1318 });
1319 }
1320
1321 #[test]
1322 fn mint_user_fresh_populates_act_camp_id_and_owns() {
1323 let (authority, verifier) = rig();
1324 let user = PrincipalId::user("carol");
1325 let camp_id = "camp-xyz";
1326 let aud = "https://aud.example";
1327 put_simple_grant(
1328 &authority,
1329 user.clone(),
1330 aud,
1331 vec![ScopeOrBundle::Scope(yah_scopes::CAMP_READ)],
1332 );
1333 pollster::block_on(async {
1335 let svc = NewOwnership::new(
1336 PrincipalId::camp(camp_id),
1337 "service",
1338 "svc-a",
1339 "owns",
1340 PrincipalId::service("yubaba"),
1341 Some(user.clone()),
1342 )
1343 .unwrap();
1344 authority.ownership.insert(&svc, 500).await.unwrap();
1345
1346 let doc = NewOwnership::new(
1347 PrincipalId::camp(camp_id),
1348 "arch_doc",
1349 "doc-1",
1350 "owns",
1351 PrincipalId::service("yubaba"),
1352 Some(user.clone()),
1353 )
1354 .unwrap();
1355 authority.ownership.insert(&doc, 500).await.unwrap();
1356
1357 let minted = authority
1358 .mint_user_fresh(
1359 user.clone(),
1360 Some(Actor::new(PrincipalId::service("agent-claude"))),
1361 Some(camp_id.to_owned()),
1362 aud,
1363 1_000,
1364 )
1365 .await
1366 .unwrap();
1367
1368 assert_eq!(
1369 minted.claims.act.as_ref().unwrap().sub,
1370 PrincipalId::service("agent-claude")
1371 );
1372 assert_eq!(minted.claims.camp_id.as_deref(), Some(camp_id));
1373 assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
1374 assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
1375
1376 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1378 assert_eq!(back, minted.claims);
1379 });
1380 }
1381
1382 #[test]
1383 fn mint_user_fresh_omits_owns_when_camp_owns_nothing() {
1384 let (authority, _verifier) = rig();
1387 let user = PrincipalId::user("dan");
1388 let aud = "https://aud.example";
1389 put_simple_grant(
1390 &authority,
1391 user.clone(),
1392 aud,
1393 vec![ScopeOrBundle::Scope(yah_scopes::CAMP_READ)],
1394 );
1395
1396 pollster::block_on(async {
1397 let minted = authority
1398 .mint_user_fresh(user, None, Some("camp-empty".into()), aud, 1_000)
1399 .await
1400 .unwrap();
1401 assert!(minted.claims.owns.is_empty());
1402 let json = serde_json::to_string(&minted.claims).unwrap();
1403 assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
1404 });
1405 }
1406
1407 #[test]
1410 fn mint_user_fresh_rejects_unentitled_aud() {
1411 let (authority, _verifier) = rig();
1412 let user = PrincipalId::user("eve");
1413 pollster::block_on(async {
1415 let err = authority
1416 .mint_user_fresh(user, None, None, "https://aud.example", 1_000)
1417 .await
1418 .unwrap_err();
1419 match err {
1420 McpMintError::AudNotEntitled { principal, aud } => {
1421 assert_eq!(principal, "user:eve");
1422 assert_eq!(aud, "https://aud.example");
1423 }
1424 other => panic!("expected AudNotEntitled, got {other:?}"),
1425 }
1426 });
1427 }
1428
1429 #[test]
1430 fn mint_user_fresh_rejects_non_user_principal() {
1431 let (authority, _verifier) = rig();
1432 pollster::block_on(async {
1433 let err = authority
1434 .mint_user_fresh(
1435 PrincipalId::service("yubaba"),
1436 None,
1437 None,
1438 "https://aud.example",
1439 1_000,
1440 )
1441 .await
1442 .unwrap_err();
1443 match err {
1444 McpMintError::WrongPrincipalKind { expected, got } => {
1445 assert_eq!(expected, PrincipalKind::User);
1446 assert_eq!(got, PrincipalKind::Service);
1447 }
1448 other => panic!("expected WrongPrincipalKind, got {other:?}"),
1449 }
1450 });
1451 }
1452
1453 #[test]
1456 fn mint_api_token_defaults_to_ninety_days_and_the_full_effective_scope_set() {
1457 let (authority, verifier) = rig();
1458 let user = PrincipalId::user("alice");
1459 let aud = "https://kamaji.example";
1460 put_simple_grant(
1461 &authority,
1462 user.clone(),
1463 aud,
1464 vec![
1465 ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
1466 ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
1467 ],
1468 );
1469
1470 pollster::block_on(async {
1471 let minted = authority
1472 .mint_api_token(user.clone(), aud, &[], None, 1_000)
1473 .await
1474 .unwrap();
1475 assert_eq!(
1476 minted.claims.exp,
1477 1_000 + McpPolicy::DEFAULT_API_TOKEN_TTL_SECONDS
1478 );
1479 assert_eq!(minted.claims.auth_strength, Some(AuthStrength::ApiToken));
1480 assert_eq!(minted.claims.sub, user);
1481 assert_eq!(
1484 minted.claims.scope,
1485 vec![yah_scopes::CLOUD_DEPLOY, yah_scopes::CLOUD_READ]
1486 );
1487 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1490 assert_eq!(back, minted.claims);
1491 });
1492 }
1493
1494 #[test]
1497 fn mint_api_token_attenuates_to_the_requested_subset() {
1498 let (authority, _verifier) = rig();
1499 let user = PrincipalId::user("alice");
1500 let aud = "https://kamaji.example";
1501 put_simple_grant(
1502 &authority,
1503 user.clone(),
1504 aud,
1505 vec![
1506 ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
1507 ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
1508 ScopeOrBundle::Scope(yah_scopes::CLOUD_DESTROY),
1509 ],
1510 );
1511
1512 pollster::block_on(async {
1513 let minted = authority
1514 .mint_api_token(user, aud, &[yah_scopes::CLOUD_READ], None, 1_000)
1515 .await
1516 .unwrap();
1517 assert_eq!(minted.claims.scope, vec![yah_scopes::CLOUD_READ]);
1518 });
1519 }
1520
1521 #[test]
1525 fn mint_api_token_names_every_unentitled_scope_instead_of_dropping_it() {
1526 let (authority, _verifier) = rig();
1527 let user = PrincipalId::user("alice");
1528 let aud = "https://kamaji.example";
1529 put_simple_grant(
1530 &authority,
1531 user.clone(),
1532 aud,
1533 vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
1534 );
1535
1536 pollster::block_on(async {
1537 let err = authority
1538 .mint_api_token(
1539 user,
1540 aud,
1541 &[yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DESTROY, yah_scopes::CAMP_ADMIN],
1542 None,
1543 1_000,
1544 )
1545 .await
1546 .unwrap_err();
1547 match err {
1548 McpMintError::UnentitledScopes { ref scopes, .. } => {
1549 assert_eq!(scopes, &vec![yah_scopes::CLOUD_DESTROY, yah_scopes::CAMP_ADMIN]);
1550 let msg = err.to_string();
1551 assert!(msg.contains("cloud:destroy"), "{msg}");
1552 assert!(msg.contains("camp:admin"), "{msg}");
1553 }
1554 other => panic!("expected UnentitledScopes, got {other:?}"),
1555 }
1556 });
1557 }
1558
1559 #[test]
1563 fn mint_api_token_rejects_service_only_scope_smuggled_via_bundle() {
1564 let (authority, _verifier) = rig();
1565 let user = PrincipalId::user("mallory");
1566 let aud = "https://aud.example";
1567
1568 pollster::block_on(async {
1569 authority
1570 .bundles
1571 .put(
1572 &BundleName::new("dangerous"),
1573 &[yah_scopes::CAMP_READ, yah_scopes::AUDIT_WRITE],
1574 )
1575 .await
1576 .unwrap();
1577 authority.grants.put_raw(
1578 user.clone(),
1579 aud,
1580 vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1581 );
1582 match authority.mint_api_token(user, aud, &[], None, 1_000).await {
1583 Err(McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope {
1584 scope,
1585 kind,
1586 })) => {
1587 assert_eq!(scope, yah_scopes::AUDIT_WRITE);
1588 assert_eq!(kind, PrincipalKind::User);
1589 }
1590 other => panic!("expected GrantMisconfigured, got {other:?}"),
1591 }
1592 });
1593 }
1594
1595 #[test]
1596 fn mint_api_token_rejects_an_out_of_range_ttl_rather_than_clamping() {
1597 let (authority, _verifier) = rig();
1598 let user = PrincipalId::user("alice");
1599 let aud = "https://kamaji.example";
1600 put_simple_grant(
1601 &authority,
1602 user.clone(),
1603 aud,
1604 vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
1605 );
1606
1607 pollster::block_on(async {
1608 for bad in [0, -1, McpPolicy::MAX_API_TOKEN_TTL_SECONDS + 1] {
1609 match authority
1610 .mint_api_token(user.clone(), aud, &[], Some(bad), 1_000)
1611 .await
1612 {
1613 Err(McpMintError::TtlOutOfRange { requested, max }) => {
1614 assert_eq!(requested, bad);
1615 assert_eq!(max, McpPolicy::MAX_API_TOKEN_TTL_SECONDS);
1616 }
1617 other => panic!("expected TtlOutOfRange for {bad}, got {other:?}"),
1618 }
1619 }
1620 let ok = authority
1622 .mint_api_token(
1623 user,
1624 aud,
1625 &[],
1626 Some(McpPolicy::MAX_API_TOKEN_TTL_SECONDS),
1627 1_000,
1628 )
1629 .await
1630 .unwrap();
1631 assert_eq!(
1632 ok.claims.exp,
1633 1_000 + McpPolicy::MAX_API_TOKEN_TTL_SECONDS
1634 );
1635 });
1636 }
1637
1638 #[test]
1639 fn mint_api_token_rejects_an_unentitled_aud_before_signing() {
1640 let (authority, _verifier) = rig();
1641 let user = PrincipalId::user("alice");
1642 pollster::block_on(async {
1643 match authority
1644 .mint_api_token(user, "https://nothing-granted.example", &[], None, 1_000)
1645 .await
1646 {
1647 Err(McpMintError::AudNotEntitled { principal, aud }) => {
1648 assert_eq!(principal, "user:alice");
1649 assert_eq!(aud, "https://nothing-granted.example");
1650 }
1651 other => panic!("expected AudNotEntitled, got {other:?}"),
1652 }
1653 });
1654 }
1655
1656 #[test]
1662 fn mint_api_token_carries_no_owns_no_camp_id_and_no_act() {
1663 let (authority, _verifier) = rig();
1664 let user = PrincipalId::user("alice");
1665 let aud = "https://kamaji.example";
1666 put_simple_grant(
1667 &authority,
1668 user.clone(),
1669 aud,
1670 vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
1671 );
1672
1673 pollster::block_on(async {
1674 let minted = authority
1675 .mint_api_token(user, aud, &[], None, 1_000)
1676 .await
1677 .unwrap();
1678 assert!(minted.claims.owns.is_empty());
1679 assert_eq!(minted.claims.camp_id, None);
1680 assert_eq!(minted.claims.act, None);
1681 });
1682 }
1683
1684 #[test]
1685 fn mint_api_token_rejects_a_non_user_principal() {
1686 let (authority, _verifier) = rig();
1687 pollster::block_on(async {
1688 match authority
1689 .mint_api_token(PrincipalId::camp("c1"), "https://aud.example", &[], None, 1)
1690 .await
1691 {
1692 Err(McpMintError::WrongPrincipalKind { expected, got }) => {
1693 assert_eq!(expected, PrincipalKind::User);
1694 assert_eq!(got, PrincipalKind::Camp);
1695 }
1696 other => panic!("expected WrongPrincipalKind, got {other:?}"),
1697 }
1698 });
1699 }
1700
1701 #[test]
1712 fn rotate_api_token_mints_from_the_presented_scopes_without_reading_grants() {
1713 let (authority, verifier) = rig();
1714 let user = PrincipalId::user("alice");
1715 let aud = "https://kamaji.example";
1716 let minted = authority
1719 .rotate_api_token(
1720 user.clone(),
1721 aud,
1722 &[yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY],
1723 &[],
1724 None,
1725 1_000,
1726 )
1727 .unwrap();
1728 assert_eq!(minted.claims.scope, vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY]);
1729 assert_eq!(minted.claims.auth_strength, Some(AuthStrength::ApiToken));
1730 assert_eq!(minted.claims.sub, user.clone());
1731 assert_eq!(minted.claims.aud, aud);
1732 assert!(minted.claims.owns.is_empty());
1733 let claims = verifier.verify_mcp_at(&minted.token, 1_001, RIG_KID).unwrap();
1735 assert_eq!(claims.jti, minted.claims.jti);
1736
1737 pollster::block_on(async {
1739 assert!(matches!(
1740 authority.mint_api_token(user, aud, &[], None, 1_000).await,
1741 Err(McpMintError::AudNotEntitled { .. })
1742 ));
1743 });
1744 }
1745
1746 #[test]
1747 fn rotate_api_token_can_narrow_but_never_widen() {
1748 let (authority, _verifier) = rig();
1749 let user = PrincipalId::user("alice");
1750 let aud = "https://kamaji.example";
1751 put_simple_grant(
1753 &authority,
1754 user.clone(),
1755 aud,
1756 vec![
1757 ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
1758 ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
1759 ScopeOrBundle::Scope(yah_scopes::CLOUD_DESTROY),
1760 ],
1761 );
1762
1763 let narrowed = authority
1765 .rotate_api_token(
1766 user.clone(),
1767 aud,
1768 &[yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY],
1769 &[yah_scopes::CLOUD_READ],
1770 None,
1771 1_000,
1772 )
1773 .unwrap();
1774 assert_eq!(narrowed.claims.scope, vec![yah_scopes::CLOUD_READ]);
1775
1776 match authority.rotate_api_token(
1779 user,
1780 aud,
1781 &[yah_scopes::CLOUD_READ],
1782 &[yah_scopes::CLOUD_DESTROY],
1783 None,
1784 1_000,
1785 ) {
1786 Err(McpMintError::UnentitledScopes { scopes, .. }) => {
1787 assert_eq!(scopes, vec![yah_scopes::CLOUD_DESTROY]);
1788 }
1789 other => panic!("expected UnentitledScopes, got {other:?}"),
1790 }
1791 }
1792
1793 #[test]
1797 fn rotate_api_token_gets_a_fresh_ttl_under_the_same_ceiling() {
1798 let (authority, _verifier) = rig();
1799 let user = PrincipalId::user("alice");
1800 let aud = "https://kamaji.example";
1801
1802 let default_ttl = authority
1803 .rotate_api_token(user.clone(), aud, &[yah_scopes::CLOUD_READ], &[], None, 5_000)
1804 .unwrap();
1805 assert_eq!(
1806 default_ttl.claims.exp - default_ttl.claims.iat,
1807 McpPolicy::DEFAULT_API_TOKEN_TTL_SECONDS
1808 );
1809 assert_eq!(default_ttl.claims.iat, 5_000, "the clock restarts at `now`");
1810
1811 for bad in [0, -1, McpPolicy::MAX_API_TOKEN_TTL_SECONDS + 1] {
1812 match authority.rotate_api_token(
1813 user.clone(),
1814 aud,
1815 &[yah_scopes::CLOUD_READ],
1816 &[],
1817 Some(bad),
1818 5_000,
1819 ) {
1820 Err(McpMintError::TtlOutOfRange { requested, max }) => {
1821 assert_eq!(requested, bad);
1822 assert_eq!(max, McpPolicy::MAX_API_TOKEN_TTL_SECONDS);
1823 }
1824 other => panic!("expected TtlOutOfRange for {bad}, got {other:?}"),
1825 }
1826 }
1827 }
1828
1829 #[test]
1833 fn rotate_api_token_rejects_a_service_only_scope_in_the_presented_token() {
1834 let (authority, _verifier) = rig();
1835 assert!(matches!(
1836 authority.rotate_api_token(
1837 PrincipalId::user("alice"),
1838 "https://kamaji.example",
1839 &[yah_scopes::AUDIT_WRITE],
1840 &[],
1841 None,
1842 1_000,
1843 ),
1844 Err(McpMintError::GrantMisconfigured(_))
1845 ));
1846 }
1847
1848 #[test]
1849 fn rotate_api_token_rejects_a_non_user_principal() {
1850 let (authority, _verifier) = rig();
1851 match authority.rotate_api_token(
1852 PrincipalId::camp("c1"),
1853 "https://aud.example",
1854 &[yah_scopes::CLOUD_READ],
1855 &[],
1856 None,
1857 1,
1858 ) {
1859 Err(McpMintError::WrongPrincipalKind { expected, got }) => {
1860 assert_eq!(expected, PrincipalKind::User);
1861 assert_eq!(got, PrincipalKind::Camp);
1862 }
1863 other => panic!("expected WrongPrincipalKind, got {other:?}"),
1864 }
1865 }
1866
1867 #[test]
1868 fn mint_user_fresh_rejects_scope_missing_from_registry() {
1869 let (authority, _verifier) = rig();
1872 let user = PrincipalId::user("alice");
1873 let aud = "https://aud.example";
1874 let unknown: Scope = "cloud:nuke".parse().unwrap();
1875 authority.grants.put_raw(
1876 user.clone(),
1877 aud,
1878 vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ), ScopeOrBundle::Scope(unknown.clone())],
1879 );
1880 let err = pollster::block_on(authority.mint_user_fresh(user, None, None, aud, 1_000))
1881 .unwrap_err();
1882 match err {
1883 McpMintError::GrantMisconfigured(GrantError::UnknownScope { scope }) => {
1884 assert_eq!(scope, unknown);
1885 }
1886 other => panic!("expected UnknownScope, got {other:?}"),
1887 }
1888 }
1889
1890 #[test]
1891 fn mint_user_fresh_rejects_service_only_scope_smuggled_via_bundle() {
1892 let (authority, _verifier) = rig();
1897 let user = PrincipalId::user("mallory");
1898 let aud = "https://aud.example";
1899
1900 pollster::block_on(async {
1901 authority
1902 .bundles
1903 .put(
1904 &BundleName::new("dangerous"),
1905 &[yah_scopes::CAMP_READ, yah_scopes::AUDIT_WRITE],
1906 )
1907 .await
1908 .unwrap();
1909 authority.grants.put_raw(
1910 user.clone(),
1911 aud,
1912 vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
1913 );
1914
1915 let err = authority
1916 .mint_user_fresh(user, None, None, aud, 1_000)
1917 .await
1918 .unwrap_err();
1919 match err {
1920 McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
1921 assert_eq!(scope, yah_scopes::AUDIT_WRITE);
1922 assert_eq!(kind, PrincipalKind::User);
1923 }
1924 other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
1925 }
1926 });
1927 }
1928
1929 #[test]
1932 fn mint_bootstrap_signs_token_verifiable_at_edge() {
1933 let (authority, verifier) = rig();
1934 let camp = PrincipalId::camp("c-xyz");
1935 let aud = "https://kamaji.camp.example";
1936 put_simple_grant(
1937 &authority,
1938 camp.clone(),
1939 aud,
1940 vec![
1941 ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
1942 ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
1943 ],
1944 );
1945
1946 pollster::block_on(async {
1947 let minted = authority
1948 .mint_bootstrap(camp.clone(), aud, 1_000)
1949 .await
1950 .unwrap();
1951
1952 assert!(minted.token.starts_with("v4.public."));
1954 assert_eq!(minted.claims.auth_strength, Some(AuthStrength::Bootstrap));
1956 assert_eq!(minted.claims.sub, camp);
1959 assert_eq!(minted.claims.camp_id.as_deref(), Some("c-xyz"));
1960 assert!(minted.claims.act.is_none());
1963 assert!(!minted.claims.jti.is_empty());
1964 assert_eq!(minted.claims.iat, 1_000);
1965 assert_eq!(
1966 minted.claims.exp,
1967 1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
1968 );
1969
1970 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
1973 assert_eq!(back, minted.claims);
1974 assert_eq!(back.scope, vec![yah_scopes::CLOUD_DEPLOY, yah_scopes::CLOUD_READ]);
1975 });
1976 }
1977
1978 #[test]
1979 fn mint_bootstrap_owns_reflects_ownership_table_state() {
1980 let (authority, _verifier) = rig();
1981 let camp = PrincipalId::camp("c-with-owns");
1982 let user = PrincipalId::user("u-on-behalf");
1983 let aud = "https://aud.example";
1984 put_simple_grant(
1985 &authority,
1986 camp.clone(),
1987 aud,
1988 vec![ScopeOrBundle::Scope(yah_scopes::CAMP_READ)],
1989 );
1990
1991 pollster::block_on(async {
1992 let svc = NewOwnership::new(
1996 camp.clone(),
1997 "service",
1998 "svc-a",
1999 "owns",
2000 PrincipalId::service("yubaba"),
2001 Some(user.clone()),
2002 )
2003 .unwrap();
2004 authority.ownership.insert(&svc, 500).await.unwrap();
2005
2006 let doc = NewOwnership::new(
2007 camp.clone(),
2008 "arch_doc",
2009 "doc-1",
2010 "owns",
2011 PrincipalId::service("yubaba"),
2012 Some(user.clone()),
2013 )
2014 .unwrap();
2015 authority.ownership.insert(&doc, 500).await.unwrap();
2016
2017 let minted = authority
2018 .mint_bootstrap(camp.clone(), aud, 1_000)
2019 .await
2020 .unwrap();
2021
2022 assert_eq!(minted.claims.owns.service, vec!["svc-a".to_string()]);
2023 assert_eq!(minted.claims.owns.arch_doc, vec!["doc-1".to_string()]);
2024 });
2025 }
2026
2027 #[test]
2028 fn rows_to_owns_dedups_duplicate_resource_ids() {
2029 let camp = PrincipalId::camp("c-dup");
2033 let svc = PrincipalId::service("yubaba");
2034 let mk = |id: &str, kind: &str, rid: &str| {
2035 OwnershipRow::new(
2036 id.to_owned(),
2037 camp.clone().into(),
2038 kind.to_owned(),
2039 rid.to_owned(),
2040 "owns".to_owned(),
2041 svc.clone(),
2042 None,
2043 500,
2044 None,
2045 )
2046 };
2047 let rows = vec![
2048 mk("row-1", "service", "svc-a"),
2049 mk("row-2", "service", "svc-a"), mk("row-3", "service", "svc-b"),
2051 mk("row-4", "node", "aa11"),
2052 mk("row-5", "node", "aa11"), mk("row-6", "pond", "p-1"),
2054 mk("row-7", "pond", "p-1"), ];
2056
2057 let owns = rows_to_owns(&rows);
2058 assert_eq!(owns.service, vec!["svc-a".to_string(), "svc-b".to_string()]);
2059 assert_eq!(owns.node, vec!["aa11".to_string()]);
2060 assert_eq!(
2061 owns.extra.get("pond"),
2062 Some(&vec!["p-1".to_string()])
2063 );
2064 }
2065
2066 #[test]
2067 fn mint_bootstrap_omits_owns_when_camp_owns_nothing() {
2068 let (authority, _verifier) = rig();
2073 let camp = PrincipalId::camp("c-empty");
2074 let aud = "https://aud.example";
2075 put_simple_grant(
2076 &authority,
2077 camp.clone(),
2078 aud,
2079 vec![ScopeOrBundle::Scope(yah_scopes::CAMP_READ)],
2080 );
2081
2082 pollster::block_on(async {
2083 let minted = authority
2084 .mint_bootstrap(camp, aud, 1_000)
2085 .await
2086 .unwrap();
2087 assert!(minted.claims.owns.is_empty());
2088 let json = serde_json::to_string(&minted.claims).unwrap();
2089 assert!(!json.contains("\"owns\""), "empty owns must omit: {json}");
2090 assert!(json.contains("\"camp_id\":\"c-empty\""), "camp_id must remain: {json}");
2092 });
2093 }
2094
2095 #[test]
2096 fn mint_bootstrap_expands_bundles_at_mint_time() {
2097 let (authority, _verifier) = rig();
2100 let camp = PrincipalId::camp("c-deploy");
2101 let aud = "https://aud.example";
2102 put_simple_grant(
2103 &authority,
2104 camp.clone(),
2105 aud,
2106 vec![ScopeOrBundle::Bundle(BundleName::new("deploy-admin"))],
2107 );
2108
2109 pollster::block_on(async {
2110 let first = authority
2111 .mint_bootstrap(camp.clone(), aud, 1_000)
2112 .await
2113 .unwrap();
2114 assert!(first.claims.scope.contains(&yah_scopes::CLOUD_DESTROY));
2115
2116 authority
2117 .bundles
2118 .put(
2119 &BundleName::new("deploy-admin"),
2120 &[yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY],
2121 )
2122 .await
2123 .unwrap();
2124
2125 let second = authority
2126 .mint_bootstrap(camp, aud, 1_100)
2127 .await
2128 .unwrap();
2129 assert!(!second.claims.scope.contains(&yah_scopes::CLOUD_DESTROY));
2130 assert_eq!(second.claims.scope, vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY]);
2131 });
2132 }
2133
2134 #[test]
2137 fn mint_bootstrap_rejects_non_camp_principal() {
2138 let (authority, _verifier) = rig();
2139 pollster::block_on(async {
2140 let err = authority
2143 .mint_bootstrap(
2144 PrincipalId::user("alice"),
2145 "https://aud.example",
2146 1_000,
2147 )
2148 .await
2149 .unwrap_err();
2150 match err {
2151 McpMintError::WrongPrincipalKind { expected, got } => {
2152 assert_eq!(expected, PrincipalKind::Camp);
2153 assert_eq!(got, PrincipalKind::User);
2154 }
2155 other => panic!("expected WrongPrincipalKind, got {other:?}"),
2156 }
2157
2158 let err = authority
2160 .mint_bootstrap(
2161 PrincipalId::service("yubaba"),
2162 "https://aud.example",
2163 1_000,
2164 )
2165 .await
2166 .unwrap_err();
2167 match err {
2168 McpMintError::WrongPrincipalKind { expected, got } => {
2169 assert_eq!(expected, PrincipalKind::Camp);
2170 assert_eq!(got, PrincipalKind::Service);
2171 }
2172 other => panic!("expected WrongPrincipalKind, got {other:?}"),
2173 }
2174 });
2175 }
2176
2177 #[test]
2178 fn mint_bootstrap_rejects_unentitled_aud() {
2179 let (authority, _verifier) = rig();
2180 let camp = PrincipalId::camp("c-no-grants");
2181 pollster::block_on(async {
2183 let err = authority
2184 .mint_bootstrap(camp, "https://aud.example", 1_000)
2185 .await
2186 .unwrap_err();
2187 match err {
2188 McpMintError::AudNotEntitled { principal, aud } => {
2189 assert_eq!(principal, "camp:c-no-grants");
2190 assert_eq!(aud, "https://aud.example");
2191 }
2192 other => panic!("expected AudNotEntitled, got {other:?}"),
2193 }
2194 });
2195 }
2196
2197 #[test]
2198 fn mint_bootstrap_rejects_service_only_scope_smuggled_via_bundle() {
2199 let (authority, _verifier) = rig();
2205 let camp = PrincipalId::camp("c-sneaky");
2206 let aud = "https://aud.example";
2207
2208 pollster::block_on(async {
2209 authority
2210 .bundles
2211 .put(
2212 &BundleName::new("dangerous"),
2213 &[yah_scopes::CAMP_READ, yah_scopes::AUDIT_WRITE],
2214 )
2215 .await
2216 .unwrap();
2217 authority.grants.put_raw(
2218 camp.clone(),
2219 aud,
2220 vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
2221 );
2222
2223 let err = authority
2224 .mint_bootstrap(camp, aud, 1_000)
2225 .await
2226 .unwrap_err();
2227 match err {
2228 McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
2229 assert_eq!(scope, yah_scopes::AUDIT_WRITE);
2230 assert_eq!(kind, PrincipalKind::Camp);
2231 }
2232 other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
2233 }
2234 });
2235 }
2236
2237 #[test]
2240 fn mint_token_exchange_attributes_to_user_with_camp_as_context() {
2241 let (authority, verifier) = rig();
2242 let user = PrincipalId::user("alice");
2243 let camp = PrincipalId::camp("c-multi");
2244 let aud = "https://kamaji.camp.example";
2245
2246 put_simple_grant(
2249 &authority,
2250 user.clone(),
2251 aud,
2252 vec![
2253 ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
2254 ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
2255 ],
2256 );
2257 put_simple_grant(
2258 &authority,
2259 camp.clone(),
2260 aud,
2261 vec![
2262 ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
2263 ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
2264 ],
2265 );
2266
2267 pollster::block_on(async {
2269 let svc = NewOwnership::new(
2270 camp.clone(),
2271 "service",
2272 "svc-prod",
2273 "owns",
2274 PrincipalId::service("yubaba"),
2275 Some(user.clone()),
2276 )
2277 .unwrap();
2278 authority.ownership.insert(&svc, 500).await.unwrap();
2279
2280 let minted = authority
2281 .mint_token_exchange(
2282 user.clone(),
2283 camp.clone(),
2284 Some(Actor::new(PrincipalId::service("agent-claude"))),
2285 aud,
2286 vec![yah_scopes::CLOUD_DEPLOY],
2287 1_000,
2288 )
2289 .await
2290 .unwrap();
2291
2292 assert!(minted.token.starts_with("v4.public."));
2293 assert_eq!(minted.claims.sub, user);
2296 assert_eq!(minted.claims.camp_id.as_deref(), Some("c-multi"));
2298 assert_eq!(
2300 minted.claims.act.as_ref().unwrap().sub,
2301 PrincipalId::service("agent-claude"),
2302 );
2303 assert_eq!(minted.claims.auth_strength, Some(AuthStrength::UserFresh));
2305 assert_eq!(minted.claims.scope, vec![yah_scopes::CLOUD_DEPLOY]);
2307 assert_eq!(minted.claims.owns.service, vec!["svc-prod".to_string()]);
2309 assert!(!minted.claims.jti.is_empty());
2311 assert_eq!(minted.claims.iat, 1_000);
2312 assert_eq!(
2313 minted.claims.exp,
2314 1_000 + McpPolicy::DEFAULT_ACCESS_TTL_SECONDS
2315 );
2316 let back = verifier.verify_mcp_at(&minted.token, 1_100, RIG_KID).unwrap();
2318 assert_eq!(back, minted.claims);
2319 });
2320 }
2321
2322 #[test]
2323 fn mint_token_exchange_intersection_drops_scope_one_side_lacks() {
2324 let (authority, _verifier) = rig();
2329 let user = PrincipalId::user("bob");
2330 let camp = PrincipalId::camp("c-narrow");
2331 let aud = "https://aud.example";
2332
2333 put_simple_grant(
2335 &authority,
2336 user.clone(),
2337 aud,
2338 vec![
2339 ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
2340 ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
2341 ],
2342 );
2343 put_simple_grant(
2344 &authority,
2345 camp.clone(),
2346 aud,
2347 vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
2348 );
2349
2350 pollster::block_on(async {
2351 let minted = authority
2353 .mint_token_exchange(
2354 user.clone(),
2355 camp.clone(),
2356 None,
2357 aud,
2358 vec![yah_scopes::CLOUD_READ],
2359 1_000,
2360 )
2361 .await
2362 .unwrap();
2363 assert_eq!(minted.claims.scope, vec![yah_scopes::CLOUD_READ]);
2364
2365 let err = authority
2368 .mint_token_exchange(
2369 user,
2370 camp,
2371 None,
2372 aud,
2373 vec![yah_scopes::CLOUD_READ, yah_scopes::CLOUD_DEPLOY],
2374 1_000,
2375 )
2376 .await
2377 .unwrap_err();
2378 match err {
2379 McpMintError::InvalidScope { scope, aud: a } => {
2380 assert_eq!(scope, yah_scopes::CLOUD_DEPLOY);
2381 assert_eq!(a, aud);
2382 }
2383 other => panic!("expected InvalidScope, got {other:?}"),
2384 }
2385 });
2386 }
2387
2388 #[test]
2389 fn mint_token_exchange_rejects_scope_user_lacks() {
2390 let (authority, _verifier) = rig();
2392 let user = PrincipalId::user("carol");
2393 let camp = PrincipalId::camp("c-wide");
2394 let aud = "https://aud.example";
2395
2396 put_simple_grant(
2397 &authority,
2398 user.clone(),
2399 aud,
2400 vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
2401 );
2402 put_simple_grant(
2403 &authority,
2404 camp.clone(),
2405 aud,
2406 vec![
2407 ScopeOrBundle::Scope(yah_scopes::CLOUD_READ),
2408 ScopeOrBundle::Scope(yah_scopes::CLOUD_DEPLOY),
2409 ],
2410 );
2411
2412 pollster::block_on(async {
2413 let err = authority
2414 .mint_token_exchange(
2415 user,
2416 camp,
2417 None,
2418 aud,
2419 vec![yah_scopes::CLOUD_DEPLOY],
2420 1_000,
2421 )
2422 .await
2423 .unwrap_err();
2424 match err {
2425 McpMintError::InvalidScope { scope, aud: _ } => {
2426 assert_eq!(scope, yah_scopes::CLOUD_DEPLOY);
2427 }
2428 other => panic!("expected InvalidScope, got {other:?}"),
2429 }
2430 });
2431 }
2432
2433 #[test]
2436 fn mint_token_exchange_rejects_wrong_user_kind() {
2437 let (authority, _verifier) = rig();
2438 pollster::block_on(async {
2439 let err = authority
2440 .mint_token_exchange(
2441 PrincipalId::camp("c-as-user"),
2442 PrincipalId::camp("c"),
2443 None,
2444 "https://aud.example",
2445 vec![],
2446 1_000,
2447 )
2448 .await
2449 .unwrap_err();
2450 match err {
2451 McpMintError::WrongPrincipalKind { expected, got } => {
2452 assert_eq!(expected, PrincipalKind::User);
2453 assert_eq!(got, PrincipalKind::Camp);
2454 }
2455 other => panic!("expected WrongPrincipalKind, got {other:?}"),
2456 }
2457 });
2458 }
2459
2460 #[test]
2461 fn mint_token_exchange_rejects_wrong_camp_kind() {
2462 let (authority, _verifier) = rig();
2463 pollster::block_on(async {
2464 let err = authority
2465 .mint_token_exchange(
2466 PrincipalId::user("alice"),
2467 PrincipalId::service("yubaba"),
2468 None,
2469 "https://aud.example",
2470 vec![],
2471 1_000,
2472 )
2473 .await
2474 .unwrap_err();
2475 match err {
2476 McpMintError::WrongPrincipalKind { expected, got } => {
2477 assert_eq!(expected, PrincipalKind::Camp);
2478 assert_eq!(got, PrincipalKind::Service);
2479 }
2480 other => panic!("expected WrongPrincipalKind, got {other:?}"),
2481 }
2482 });
2483 }
2484
2485 #[test]
2486 fn mint_token_exchange_rejects_unentitled_user() {
2487 let (authority, _verifier) = rig();
2488 let user = PrincipalId::user("eve");
2489 let camp = PrincipalId::camp("c");
2490 let aud = "https://aud.example";
2491 put_simple_grant(
2493 &authority,
2494 camp.clone(),
2495 aud,
2496 vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
2497 );
2498
2499 pollster::block_on(async {
2500 let err = authority
2501 .mint_token_exchange(user, camp, None, aud, vec![yah_scopes::CLOUD_READ], 1_000)
2502 .await
2503 .unwrap_err();
2504 match err {
2505 McpMintError::AudNotEntitled { principal, aud: _ } => {
2506 assert_eq!(principal, "user:eve");
2507 }
2508 other => panic!("expected AudNotEntitled, got {other:?}"),
2509 }
2510 });
2511 }
2512
2513 #[test]
2514 fn mint_token_exchange_rejects_unentitled_camp() {
2515 let (authority, _verifier) = rig();
2518 let user = PrincipalId::user("frank");
2519 let camp = PrincipalId::camp("c-no-grants");
2520 let aud = "https://aud.example";
2521 put_simple_grant(
2522 &authority,
2523 user.clone(),
2524 aud,
2525 vec![ScopeOrBundle::Scope(yah_scopes::CLOUD_READ)],
2526 );
2527
2528 pollster::block_on(async {
2529 let err = authority
2530 .mint_token_exchange(user, camp, None, aud, vec![yah_scopes::CLOUD_READ], 1_000)
2531 .await
2532 .unwrap_err();
2533 match err {
2534 McpMintError::AudNotEntitled { principal, aud: _ } => {
2535 assert_eq!(principal, "camp:c-no-grants");
2536 }
2537 other => panic!("expected AudNotEntitled, got {other:?}"),
2538 }
2539 });
2540 }
2541
2542 #[test]
2543 fn mint_token_exchange_catches_service_only_scope_smuggled_via_camp_bundle() {
2544 let (authority, _verifier) = rig();
2548 let user = PrincipalId::user("g");
2549 let camp = PrincipalId::camp("c-sneaky");
2550 let aud = "https://aud.example";
2551
2552 pollster::block_on(async {
2553 authority
2554 .bundles
2555 .put(
2556 &BundleName::new("dangerous"),
2557 &[yah_scopes::CAMP_READ, yah_scopes::AUDIT_WRITE],
2558 )
2559 .await
2560 .unwrap();
2561 put_simple_grant(
2562 &authority,
2563 user.clone(),
2564 aud,
2565 vec![ScopeOrBundle::Scope(yah_scopes::CAMP_READ)],
2566 );
2567 put_simple_grant(
2568 &authority,
2569 camp,
2570 aud,
2571 vec![ScopeOrBundle::Bundle(BundleName::new("dangerous"))],
2572 );
2573
2574 let err = authority
2575 .mint_token_exchange(
2576 user,
2577 PrincipalId::camp("c-sneaky"),
2578 None,
2579 aud,
2580 vec![yah_scopes::CAMP_READ],
2581 1_000,
2582 )
2583 .await
2584 .unwrap_err();
2585 match err {
2586 McpMintError::GrantMisconfigured(GrantError::ServiceOnlyScope { scope, kind }) => {
2587 assert_eq!(scope, yah_scopes::AUDIT_WRITE);
2588 assert_eq!(kind, PrincipalKind::Camp);
2589 }
2590 other => panic!("expected GrantMisconfigured(ServiceOnlyScope), got {other:?}"),
2591 }
2592 });
2593 }
2594
2595 #[test]
2598 fn mcp_mint_error_converts_to_umbrella_error() {
2599 let e: Error = McpMintError::AudNotEntitled {
2600 principal: "user:x".into(),
2601 aud: "a".into(),
2602 }
2603 .into();
2604 match e {
2605 Error::InvalidInput(msg) => assert!(msg.contains("aud")),
2606 other => panic!("expected InvalidInput, got {other:?}"),
2607 }
2608
2609 let e: Error = McpMintError::Store(StoreError::Conflict).into();
2610 assert!(matches!(e, Error::Store(StoreError::Conflict)));
2611
2612 let e: Error = McpMintError::Codec(CodecError::Malformed).into();
2613 assert!(matches!(e, Error::Codec(CodecError::Malformed)));
2614 }
2615
2616 use crate::service_principal::{
2619 MemoryServicePrincipalStore, NewServicePrincipal, ProvisionedKey, ServicePrincipalAuthority,
2620 };
2621 use cheers_core::{ClientAssertion, MemoryUsedJtiStore};
2622
2623 const TOKEN_ENDPOINT: &str = "https://cheers.example/token";
2624 const SVC_AUD: &str = "https://kamaji.example";
2625
2626 struct SvcRig {
2627 authority: McpAuthority<MemoryBundleStore, TestGrants, MemoryOwnershipStore>,
2628 verifier: PasetoV4PublicVerifier,
2629 sps: ServicePrincipalAuthority<MemoryServicePrincipalStore>,
2630 key: ProvisionedKey,
2631 }
2632
2633 fn svc_rig() -> SvcRig {
2634 let (authority, verifier) = rig();
2635 let store = MemoryServicePrincipalStore::new();
2636 let authority = authority.with_service_assertions(
2637 Arc::new(store.clone()),
2638 Arc::new(MemoryUsedJtiStore::new()),
2639 );
2640 let sps = ServicePrincipalAuthority::new(store);
2641 let key = pollster::block_on(sps.provision(NewServicePrincipal::new("yubaba"), 1)).unwrap();
2642 SvcRig { authority, verifier, sps, key }
2643 }
2644
2645 fn sign_assertion(key: &ProvisionedKey, claims: &ClientAssertion) -> String {
2646 let sk = pasetors::keys::AsymmetricSecretKey::<V4>::from(&key.secret_key[..]).unwrap();
2647 let footer = serde_json::to_vec(&serde_json::json!({ "kid": key.signing_key.kid })).unwrap();
2648 let payload = serde_json::to_vec(claims).unwrap();
2649 PublicToken::sign(&sk, &payload, Some(&footer), None).unwrap()
2650 }
2651
2652 fn good_assertion(jti: &str) -> ClientAssertion {
2653 ClientAssertion::new(PrincipalId::service("yubaba"), TOKEN_ENDPOINT, jti, 1000, 1060)
2654 }
2655
2656 fn seed_svc_scope(r: &SvcRig, scope: Scope) {
2657 put_simple_grant(
2658 &r.authority,
2659 PrincipalId::service("yubaba"),
2660 SVC_AUD,
2661 vec![ScopeOrBundle::Scope(scope)],
2662 );
2663 }
2664
2665 fn mint(r: &SvcRig, a: &ClientAssertion) -> Result<MintedMcpToken, McpMintError> {
2666 let t = sign_assertion(&r.key, a);
2667 pollster::block_on(r.authority.mint_service(&t, SVC_AUD, &[], 1010))
2668 }
2669
2670 #[test]
2671 fn mint_service_happy_path_scopes_from_relationship() {
2672 let r = svc_rig();
2673 seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2674 let minted = mint(&r, &good_assertion("j-ok")).unwrap();
2675 let claims = r.verifier.verify_mcp_at(&minted.token, 1010, RIG_KID).unwrap();
2676 assert_eq!(claims.sub, PrincipalId::service("yubaba"));
2677 assert_eq!(claims.aud, SVC_AUD);
2678 assert_eq!(claims.scope, vec![yah_scopes::CLOUD_READ]);
2679 assert!(claims.exp - claims.iat <= 3600);
2680 assert!(claims.ceiling.is_none());
2681 }
2682
2683 #[test]
2684 fn mint_service_intersects_requested_and_rejects_empty() {
2685 let r = svc_rig();
2686 seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2687 let t = sign_assertion(&r.key, &good_assertion("j-a"));
2688 let other = [yah_scopes::CLOUD_DEPLOY];
2689 let err = pollster::block_on(r.authority.mint_service(&t, SVC_AUD, &other, 1010)).unwrap_err();
2690 assert!(matches!(err, McpMintError::NoGrantedScopes { .. }), "{err:?}");
2691 let r = svc_rig();
2693 let err = mint(&r, &good_assertion("j-b")).unwrap_err();
2694 assert!(matches!(err, McpMintError::NoGrantedScopes { .. }), "{err:?}");
2695 }
2696
2697 #[test]
2698 fn mint_service_rejects_unknown_kid() {
2699 let r = svc_rig();
2700 seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2701 let other = pollster::block_on(
2702 ServicePrincipalAuthority::new(MemoryServicePrincipalStore::new())
2703 .provision(NewServicePrincipal::new("yubaba"), 1),
2704 )
2705 .unwrap();
2706 let t = sign_assertion(&other, &good_assertion("j"));
2707 let err = pollster::block_on(r.authority.mint_service(&t, SVC_AUD, &[], 1010)).unwrap_err();
2708 assert!(matches!(err, McpMintError::AssertionUnknownKid(_)), "{err:?}");
2709 }
2710
2711 #[test]
2712 fn mint_service_rejects_retired_key() {
2713 let r = svc_rig();
2714 seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2715 pollster::block_on(r.sps.rotate(&PrincipalId::service("yubaba"), 2)).unwrap();
2716 let err = mint(&r, &good_assertion("j")).unwrap_err();
2717 assert!(matches!(err, McpMintError::AssertionRetiredKey(_)), "{err:?}");
2718 }
2719
2720 #[test]
2721 fn mint_service_rejects_principal_mismatch() {
2722 let r = svc_rig();
2723 seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2724 let mut a = good_assertion("j1");
2725 a.sub = PrincipalId::service("someone-else");
2726 let err = mint(&r, &a).unwrap_err();
2727 assert!(matches!(err, McpMintError::AssertionPrincipalMismatch { .. }), "{err:?}");
2728 let mut a = good_assertion("j2");
2729 a.iss = PrincipalId::service("someone-else");
2730 let err = mint(&r, &a).unwrap_err();
2731 assert!(matches!(err, McpMintError::AssertionPrincipalMismatch { .. }), "{err:?}");
2732 }
2733
2734 #[test]
2735 fn mint_service_rejects_bad_aud() {
2736 let r = svc_rig();
2737 seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2738 let mut a = good_assertion("j");
2739 a.aud = SVC_AUD.into();
2740 let err = mint(&r, &a).unwrap_err();
2741 assert!(matches!(err, McpMintError::AssertionBadAudience { .. }), "{err:?}");
2742 }
2743
2744 #[test]
2745 fn mint_service_rejects_expired_and_too_long_assertions() {
2746 let r = svc_rig();
2747 seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2748 let mut a = good_assertion("j1");
2749 a.exp = 1010;
2750 let err = mint(&r, &a).unwrap_err();
2751 assert!(matches!(err, McpMintError::AssertionExpired { .. }), "{err:?}");
2752 let mut a = good_assertion("j2");
2753 a.exp = a.iat + ClientAssertion::MAX_LIFETIME_SECONDS + 1;
2754 let err = mint(&r, &a).unwrap_err();
2755 assert!(matches!(err, McpMintError::AssertionLifetimeTooLong { .. }), "{err:?}");
2756 let mut a = good_assertion("j3");
2758 a.exp = a.iat + ClientAssertion::MAX_LIFETIME_SECONDS;
2759 mint(&r, &a).unwrap();
2760 }
2761
2762 #[test]
2763 fn mint_service_rejects_replay() {
2764 let r = svc_rig();
2765 seed_svc_scope(&r, yah_scopes::CLOUD_READ);
2766 let a = good_assertion("j-once");
2767 mint(&r, &a).unwrap();
2768 let err = mint(&r, &a).unwrap_err();
2769 assert!(matches!(err, McpMintError::AssertionReplayed(_)), "{err:?}");
2770 }
2771
2772 #[test]
2773 fn mint_service_requires_configuration_and_wellformed_token() {
2774 let (authority, _) = rig();
2775 let err = pollster::block_on(authority.mint_service("x", SVC_AUD, &[], 0)).unwrap_err();
2776 assert!(matches!(err, McpMintError::ServiceAssertionsUnconfigured));
2777 let r = svc_rig();
2778 let err = pollster::block_on(r.authority.mint_service("v4.public.garbage", SVC_AUD, &[], 0)).unwrap_err();
2779 assert!(matches!(err, McpMintError::AssertionMalformed(_)), "{err:?}");
2780 }
2781}