Skip to main content

cheers_core/
mcp.rs

1//! MCP wire contract — scope vocabulary, composition rules, and the
2//! `McpClaims` shape carried on a per-call token.
3//!
4//! See `.yah/docs/working/mcp-auth-and-ownership.md` §Scope vocabulary,
5//! §JWT claim schema, and §Scope vocabulary and composition rules. The
6//! shapes here are the producer side of the verbatim wire contract yah's
7//! kamaji consumes (W159 §The wire / §Layer 2 / §Layer 3).
8//!
9//! Three pieces:
10//!
11//! - [`Scope`] — the closed enum of MCP scopes. `Display`/`FromStr`/serde
12//!   roundtrip through the literal wire string (`"cloud:deploy"`). The parser
13//!   rejects wildcards (`"cloud:*"`) at parse time — composition rule (1).
14//! - [`validate_grant`] — the grant-time check that rejects writing
15//!   `ownership:write` or `audit:write` to a `User` or `Camp` principal
16//!   (composition rule (4)). The rule lives at the **grant API**, not the
17//!   mint path, so a misconfigured grant can never become a mintable token.
18//! - [`McpClaims`] + [`Actor`] / [`Owns`] / [`AuthStrength`] — the per-call
19//!   JWT-style claim bundle. `sub` is a [`PrincipalId`] (prefixed); `scope` is
20//!   a `Vec<Scope>` (no wildcards on the wire); `act` carries the agent
21//!   variant on a user's behalf (RFC 8693); `owns` is the embedded-ownership
22//!   claim cheers reads off the ownership table at mint time.
23
24use serde::{Deserialize, Serialize};
25
26use crate::principal::{PrincipalId, PrincipalKind};
27
28/// The closed set of MCP scopes — verbatim with W159 §Scope vocabulary.
29///
30/// Each variant maps to one literal wire string via [`Scope::as_wire`].
31/// `<category>:admin` is **distinct** from `<category>:read`/`<category>:write`:
32/// granting `camp:admin` does NOT imply `camp:read` (composition rule (3)).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34#[non_exhaustive]
35pub enum Scope {
36    ArchRead,
37    ArchWrite,
38    BoardRead,
39    BoardWrite,
40    CampRead,
41    CampAdmin,
42    CloudRead,
43    CloudDeploy,
44    CloudDestroy,
45    /// Fleet-operator admin — sees every machine/workload in the cloud
46    /// snapshot (the yah-cloud-admin dashboard's gate; R568-F5). Distinct
47    /// from `CloudRead` (which is the tenant-facing read scope, filtered by
48    /// principal ownership).
49    CloudAdmin,
50    PartyRead,
51    PartyWrite,
52    SubagentSpawn,
53    SubagentControl,
54    /// Service-principals only — see [`validate_grant`].
55    OwnershipWrite,
56    AuditRead,
57    /// Service-principals only — see [`validate_grant`].
58    AuditWrite,
59}
60
61impl Scope {
62    /// The literal wire string (e.g. `"cloud:deploy"`).
63    pub const fn as_wire(self) -> &'static str {
64        match self {
65            Self::ArchRead => "arch:read",
66            Self::ArchWrite => "arch:write",
67            Self::BoardRead => "board:read",
68            Self::BoardWrite => "board:write",
69            Self::CampRead => "camp:read",
70            Self::CampAdmin => "camp:admin",
71            Self::CloudRead => "cloud:read",
72            Self::CloudDeploy => "cloud:deploy",
73            Self::CloudDestroy => "cloud:destroy",
74            Self::CloudAdmin => "cloud:admin",
75            Self::PartyRead => "party:read",
76            Self::PartyWrite => "party:write",
77            Self::SubagentSpawn => "subagent:spawn",
78            Self::SubagentControl => "subagent:control",
79            Self::OwnershipWrite => "ownership:write",
80            Self::AuditRead => "audit:read",
81            Self::AuditWrite => "audit:write",
82        }
83    }
84
85    /// `true` iff this scope is grantable only to a [`PrincipalKind::Service`]
86    /// principal — composition rule (4).
87    pub const fn is_service_only(self) -> bool {
88        matches!(self, Self::OwnershipWrite | Self::AuditWrite)
89    }
90
91    /// Every variant in the closed scope vocabulary.
92    ///
93    /// `cheers-axum`'s OIDC discovery endpoint reads `scopes_supported`
94    /// straight from this constant so the discovery doc cannot drift from
95    /// what the mint path accepts. The companion `scope_all_is_exhaustive`
96    /// test below uses an exhaustive intra-crate match against
97    /// [`Scope`] (which is `#[non_exhaustive]` for *external* users but
98    /// fully matchable here) — adding a variant without listing it in
99    /// `ALL` either fails to compile (missing match arm) or fails the
100    /// per-arm assertion.
101    pub const ALL: &'static [Scope] = &[
102        Self::ArchRead,
103        Self::ArchWrite,
104        Self::BoardRead,
105        Self::BoardWrite,
106        Self::CampRead,
107        Self::CampAdmin,
108        Self::CloudRead,
109        Self::CloudDeploy,
110        Self::CloudDestroy,
111        Self::CloudAdmin,
112        Self::PartyRead,
113        Self::PartyWrite,
114        Self::SubagentSpawn,
115        Self::SubagentControl,
116        Self::OwnershipWrite,
117        Self::AuditRead,
118        Self::AuditWrite,
119    ];
120}
121
122impl std::fmt::Display for Scope {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.write_str(self.as_wire())
125    }
126}
127
128/// Why a scope string failed to parse.
129#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
130pub enum ScopeParseError {
131    /// `cloud:*` and friends are rejected — composition rule (1), no wildcards
132    /// on the wire.
133    #[error("wildcard scope '{0}' is not allowed on the wire")]
134    Wildcard(String),
135    /// Not one of the closed-vocabulary literals.
136    #[error("unknown scope '{0}'")]
137    Unknown(String),
138}
139
140impl std::str::FromStr for Scope {
141    type Err = ScopeParseError;
142
143    fn from_str(s: &str) -> Result<Self, Self::Err> {
144        if s.contains('*') {
145            return Err(ScopeParseError::Wildcard(s.to_owned()));
146        }
147        Ok(match s {
148            "arch:read" => Self::ArchRead,
149            "arch:write" => Self::ArchWrite,
150            "board:read" => Self::BoardRead,
151            "board:write" => Self::BoardWrite,
152            "camp:read" => Self::CampRead,
153            "camp:admin" => Self::CampAdmin,
154            "cloud:read" => Self::CloudRead,
155            "cloud:deploy" => Self::CloudDeploy,
156            "cloud:destroy" => Self::CloudDestroy,
157            "cloud:admin" => Self::CloudAdmin,
158            "party:read" => Self::PartyRead,
159            "party:write" => Self::PartyWrite,
160            "subagent:spawn" => Self::SubagentSpawn,
161            "subagent:control" => Self::SubagentControl,
162            "ownership:write" => Self::OwnershipWrite,
163            "audit:read" => Self::AuditRead,
164            "audit:write" => Self::AuditWrite,
165            other => return Err(ScopeParseError::Unknown(other.to_owned())),
166        })
167    }
168}
169
170impl Serialize for Scope {
171    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
172        ser.serialize_str(self.as_wire())
173    }
174}
175
176impl<'de> Deserialize<'de> for Scope {
177    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
178        let s = String::deserialize(de)?;
179        s.parse().map_err(serde::de::Error::custom)
180    }
181}
182
183/// A failed grant — the rule that fired and the offending pair.
184#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
185pub enum GrantError {
186    /// Composition rule (4): `ownership:write` and `audit:write` are
187    /// grantable to `Service` principals only.
188    #[error("scope {scope} is service-only; cannot grant to {kind} principal")]
189    ServiceOnlyScope { scope: Scope, kind: PrincipalKind },
190}
191
192/// Grant-time validation. Call this on the write path of the grant API —
193/// `POST /grants` etc. — before persisting; the mint path is a defense in
194/// depth, not the primary check.
195///
196/// Currently enforces composition rule (4) (service-only scopes). Other rules:
197///
198/// - (1) No wildcards: enforced by [`Scope::from_str`] — a wildcard never
199///   reaches this function because it can't parse into a `Scope`.
200/// - (3) `<category>:admin` is distinct: enforced by the enum shape —
201///   `CampAdmin`, `CampRead`, `CampWrite` are independent variants, so a
202///   grant of one is literally not a grant of the other.
203/// - (5) `aud`-scoping is mandatory: a mint-path concern (the principal's
204///   `aud` membership), not a per-scope predicate.
205pub fn validate_grant(kind: PrincipalKind, scope: Scope) -> Result<(), GrantError> {
206    if scope.is_service_only() && kind != PrincipalKind::Service {
207        return Err(GrantError::ServiceOnlyScope { scope, kind });
208    }
209    Ok(())
210}
211
212/// The `act` claim — RFC 8693 acted-on-by — identifies the agent variant
213/// acting on the primary subject's behalf. The agent is never the primary
214/// `sub`; it appears only here.
215///
216/// `sub` here is the agent's principal id (typically `svc:agent-<variant>`).
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[non_exhaustive]
219pub struct Actor {
220    pub sub: PrincipalId,
221}
222
223impl Actor {
224    pub fn new(sub: PrincipalId) -> Self {
225        Self { sub }
226    }
227}
228
229/// The `owns` claim — embedded ownership cheers bakes into the token at mint
230/// time. Per W159 §Layer 2, this is what lets kamaji check resource
231/// membership locally with no per-call cheers round-trip.
232///
233/// Open-ended: explicit fields for the resource kinds cheers currently writes
234/// (`service`, `arch_doc`, `node`) plus a `flatten`ed catch-all for future
235/// kinds so adding one doesn't break the wire contract.
236///
237/// `node` (W268 §The binding: enrollment is an ownership row) is the
238/// machine-identity resource kind: a row `principal owns node:<NodeId>`
239/// records that a fleet machine (or paired end-user device) is enrolled to
240/// `principal`. `resource_id` is the mshr `NodeId` in the same hex encoding
241/// yubaba's `/identity` route serves.
242#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
243#[non_exhaustive]
244pub struct Owns {
245    #[serde(default, skip_serializing_if = "Vec::is_empty")]
246    pub service: Vec<String>,
247    #[serde(default, skip_serializing_if = "Vec::is_empty")]
248    pub arch_doc: Vec<String>,
249    /// NodeIds (hex-encoded mshr identity) enrolled to this principal.
250    #[serde(default, skip_serializing_if = "Vec::is_empty")]
251    pub node: Vec<String>,
252    /// Forward-compatibility spill for resource kinds added after this lands.
253    #[serde(flatten)]
254    pub extra: std::collections::BTreeMap<String, Vec<String>>,
255}
256
257impl Owns {
258    pub fn is_empty(&self) -> bool {
259        self.service.is_empty()
260            && self.arch_doc.is_empty()
261            && self.node.is_empty()
262            && self.extra.is_empty()
263    }
264}
265
266/// How the principal's identity was last asserted — `bootstrap` for tokens
267/// minted off a camp's long-lived bootstrap credential, `user-fresh` for
268/// tokens minted within ~N minutes of a fresh passkey assertion.
269///
270/// Downstream services MAY require `user-fresh` for sensitive ops
271/// (mirrors W127's elevation pattern).
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
273#[non_exhaustive]
274#[serde(rename_all = "kebab-case")]
275pub enum AuthStrength {
276    Bootstrap,
277    UserFresh,
278}
279
280/// MCP-call token claims — verbatim with W159 §The wire.
281///
282/// Required: `iss`, `aud`, `exp`, `iat`, `jti`, `sub`, `scope`.
283/// Conditional: `act` (when an agent is acting on the user's behalf),
284/// `camp_id` (when scoped to a camp), `owns` (embedded ownership),
285/// `auth_strength` (set by the mint path).
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
287#[non_exhaustive]
288pub struct McpClaims {
289    pub iss: String,
290    pub aud: String,
291    pub sub: PrincipalId,
292    pub iat: i64,
293    pub exp: i64,
294    pub jti: String,
295    pub scope: Vec<Scope>,
296
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub act: Option<Actor>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub camp_id: Option<String>,
301    #[serde(default, skip_serializing_if = "Owns::is_empty")]
302    pub owns: Owns,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub auth_strength: Option<AuthStrength>,
305}
306
307impl McpClaims {
308    /// Build a minimal `McpClaims` with only the required fields set.
309    pub fn new(
310        iss: impl Into<String>,
311        aud: impl Into<String>,
312        sub: PrincipalId,
313        iat: i64,
314        exp: i64,
315        jti: impl Into<String>,
316        scope: Vec<Scope>,
317    ) -> Self {
318        Self {
319            iss: iss.into(),
320            aud: aud.into(),
321            sub,
322            iat,
323            exp,
324            jti: jti.into(),
325            scope,
326            act: None,
327            camp_id: None,
328            owns: Owns::default(),
329            auth_strength: None,
330        }
331    }
332
333    pub fn with_act(mut self, act: Actor) -> Self {
334        self.act = Some(act);
335        self
336    }
337
338    pub fn with_camp_id(mut self, camp_id: impl Into<String>) -> Self {
339        self.camp_id = Some(camp_id.into());
340        self
341    }
342
343    pub fn with_owns(mut self, owns: Owns) -> Self {
344        self.owns = owns;
345        self
346    }
347
348    pub fn with_auth_strength(mut self, strength: AuthStrength) -> Self {
349        self.auth_strength = Some(strength);
350        self
351    }
352
353    /// `true` if `exp` is at or before `now` (unix seconds) — mirrors
354    /// [`crate::Claims::is_expired_at`].
355    pub fn is_expired_at(&self, now: i64) -> bool {
356        self.exp <= now
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use std::str::FromStr;
364
365    #[test]
366    fn scope_wire_string_roundtrips_for_every_variant() {
367        // Every named scope from the doc.
368        let all = [
369            Scope::ArchRead,
370            Scope::ArchWrite,
371            Scope::BoardRead,
372            Scope::BoardWrite,
373            Scope::CampRead,
374            Scope::CampAdmin,
375            Scope::CloudRead,
376            Scope::CloudDeploy,
377            Scope::CloudDestroy,
378            Scope::CloudAdmin,
379            Scope::PartyRead,
380            Scope::PartyWrite,
381            Scope::SubagentSpawn,
382            Scope::SubagentControl,
383            Scope::OwnershipWrite,
384            Scope::AuditRead,
385            Scope::AuditWrite,
386        ];
387        for s in all {
388            let wire = s.as_wire();
389            assert_eq!(Scope::from_str(wire).unwrap(), s, "roundtrip failed for {wire}");
390            assert!(wire.contains(':'), "wire form must contain ':' — {wire}");
391        }
392    }
393
394    #[test]
395    fn scope_all_is_exhaustive() {
396        // Exhaustive intra-crate match — adding a `Scope` variant without
397        // updating this test is a compile error. Each arm asserts the
398        // variant is also present in `Scope::ALL`; forgetting to update
399        // `ALL` fails the assertion at test time.
400        fn assert_in_all(s: Scope) {
401            let in_all = match s {
402                Scope::ArchRead => Scope::ALL.contains(&Scope::ArchRead),
403                Scope::ArchWrite => Scope::ALL.contains(&Scope::ArchWrite),
404                Scope::BoardRead => Scope::ALL.contains(&Scope::BoardRead),
405                Scope::BoardWrite => Scope::ALL.contains(&Scope::BoardWrite),
406                Scope::CampRead => Scope::ALL.contains(&Scope::CampRead),
407                Scope::CampAdmin => Scope::ALL.contains(&Scope::CampAdmin),
408                Scope::CloudRead => Scope::ALL.contains(&Scope::CloudRead),
409                Scope::CloudDeploy => Scope::ALL.contains(&Scope::CloudDeploy),
410                Scope::CloudDestroy => Scope::ALL.contains(&Scope::CloudDestroy),
411                Scope::CloudAdmin => Scope::ALL.contains(&Scope::CloudAdmin),
412                Scope::PartyRead => Scope::ALL.contains(&Scope::PartyRead),
413                Scope::PartyWrite => Scope::ALL.contains(&Scope::PartyWrite),
414                Scope::SubagentSpawn => Scope::ALL.contains(&Scope::SubagentSpawn),
415                Scope::SubagentControl => Scope::ALL.contains(&Scope::SubagentControl),
416                Scope::OwnershipWrite => Scope::ALL.contains(&Scope::OwnershipWrite),
417                Scope::AuditRead => Scope::ALL.contains(&Scope::AuditRead),
418                Scope::AuditWrite => Scope::ALL.contains(&Scope::AuditWrite),
419            };
420            assert!(in_all, "{s} reachable in match but missing from Scope::ALL");
421        }
422        for s in Scope::ALL {
423            assert_in_all(*s);
424        }
425    }
426
427    #[test]
428    fn scope_parser_rejects_wildcards() {
429        for w in ["cloud:*", "*", "*:read", "ownership:*"] {
430            let err = Scope::from_str(w).unwrap_err();
431            assert!(
432                matches!(err, ScopeParseError::Wildcard(ref s) if s == w),
433                "{w}: expected Wildcard, got {err:?}"
434            );
435        }
436    }
437
438    #[test]
439    fn scope_parser_rejects_unknown_literals() {
440        let err = Scope::from_str("cloud:nuke").unwrap_err();
441        assert!(matches!(err, ScopeParseError::Unknown(ref s) if s == "cloud:nuke"));
442    }
443
444    #[test]
445    fn scope_serialize_is_plain_string() {
446        let v = vec![Scope::CloudDeploy, Scope::CloudRead];
447        let json = serde_json::to_string(&v).unwrap();
448        assert_eq!(json, r#"["cloud:deploy","cloud:read"]"#);
449        let back: Vec<Scope> = serde_json::from_str(&json).unwrap();
450        assert_eq!(back, v);
451    }
452
453    #[test]
454    fn scope_deserialize_rejects_wildcard_in_list() {
455        let err = serde_json::from_str::<Vec<Scope>>(r#"["cloud:read","cloud:*"]"#).unwrap_err();
456        assert!(
457            err.to_string().contains("wildcard"),
458            "expected wildcard message, got: {err}"
459        );
460    }
461
462    #[test]
463    fn validate_grant_rejects_service_only_for_user() {
464        let err = validate_grant(PrincipalKind::User, Scope::OwnershipWrite).unwrap_err();
465        assert_eq!(
466            err,
467            GrantError::ServiceOnlyScope {
468                scope: Scope::OwnershipWrite,
469                kind: PrincipalKind::User,
470            }
471        );
472
473        let err = validate_grant(PrincipalKind::User, Scope::AuditWrite).unwrap_err();
474        assert_eq!(
475            err,
476            GrantError::ServiceOnlyScope {
477                scope: Scope::AuditWrite,
478                kind: PrincipalKind::User,
479            }
480        );
481    }
482
483    #[test]
484    fn validate_grant_rejects_service_only_for_camp() {
485        let err = validate_grant(PrincipalKind::Camp, Scope::OwnershipWrite).unwrap_err();
486        assert!(matches!(
487            err,
488            GrantError::ServiceOnlyScope {
489                scope: Scope::OwnershipWrite,
490                kind: PrincipalKind::Camp,
491            }
492        ));
493    }
494
495    #[test]
496    fn validate_grant_allows_service_principal_for_service_only_scopes() {
497        validate_grant(PrincipalKind::Service, Scope::OwnershipWrite).unwrap();
498        validate_grant(PrincipalKind::Service, Scope::AuditWrite).unwrap();
499    }
500
501    #[test]
502    fn validate_grant_allows_normal_scopes_for_any_principal() {
503        for k in [
504            PrincipalKind::User,
505            PrincipalKind::Service,
506            PrincipalKind::Camp,
507        ] {
508            for s in [
509                Scope::ArchRead,
510                Scope::CloudDeploy,
511                Scope::CampAdmin,
512                Scope::AuditRead,
513            ] {
514                validate_grant(k, s).unwrap();
515            }
516        }
517    }
518
519    #[test]
520    fn camp_admin_is_distinct_from_camp_read_and_camp_write() {
521        // The enum shape *is* the enforcement: each is its own variant.
522        // A holder of CampAdmin does not equal a holder of CampRead.
523        assert_ne!(Scope::CampAdmin, Scope::CampRead);
524        // CampWrite isn't even in the vocabulary; the doc lists only
525        // camp:read + camp:admin. This test pins that fact.
526        assert!(Scope::from_str("camp:write").is_err());
527    }
528
529    #[test]
530    fn auth_strength_serializes_kebab_case() {
531        assert_eq!(serde_json::to_string(&AuthStrength::Bootstrap).unwrap(), "\"bootstrap\"");
532        assert_eq!(serde_json::to_string(&AuthStrength::UserFresh).unwrap(), "\"user-fresh\"");
533        let back: AuthStrength = serde_json::from_str("\"user-fresh\"").unwrap();
534        assert_eq!(back, AuthStrength::UserFresh);
535    }
536
537    #[test]
538    fn owns_omits_empty_lists_on_wire_but_roundtrips() {
539        let o = Owns::default();
540        let json = serde_json::to_string(&o).unwrap();
541        assert_eq!(json, "{}");
542
543        let o = Owns {
544            service: vec!["svc-a".into()],
545            arch_doc: vec![],
546            node: vec![],
547            extra: Default::default(),
548        };
549        let json = serde_json::to_string(&o).unwrap();
550        assert_eq!(json, r#"{"service":["svc-a"]}"#);
551        let back: Owns = serde_json::from_str(&json).unwrap();
552        assert_eq!(back, o);
553    }
554
555    #[test]
556    fn owns_extra_carries_unknown_resource_kinds() {
557        let json = r#"{"service":["s1"],"pond":["p1","p2"]}"#;
558        let o: Owns = serde_json::from_str(json).unwrap();
559        assert_eq!(o.service, vec!["s1".to_string()]);
560        assert_eq!(o.extra.get("pond"), Some(&vec!["p1".into(), "p2".into()]));
561
562        // Roundtrip preserves the extra kind.
563        let back = serde_json::to_string(&o).unwrap();
564        assert!(back.contains(r#""pond":["p1","p2"]"#));
565    }
566
567    fn sample_claims() -> McpClaims {
568        McpClaims::new(
569            "https://cheers.example",
570            "https://kamaji.camp.example",
571            PrincipalId::user("alice"),
572            1000,
573            1300,
574            "jti-1",
575            vec![Scope::CloudDeploy, Scope::CloudRead],
576        )
577        .with_act(Actor::new(PrincipalId::service("agent-claude")))
578        .with_camp_id("camp-xyz")
579        .with_owns(Owns {
580            service: vec!["svc-a".into()],
581            arch_doc: vec!["doc-1".into()],
582            node: vec![],
583            extra: Default::default(),
584        })
585        .with_auth_strength(AuthStrength::UserFresh)
586    }
587
588    #[test]
589    fn mcp_claims_roundtrip_full_shape() {
590        let c = sample_claims();
591        let json = serde_json::to_string(&c).unwrap();
592        // sub preserved as a prefixed string.
593        assert!(json.contains(r#""sub":"user:alice""#));
594        assert!(json.contains(r#""act":{"sub":"svc:agent-claude"}"#));
595        assert!(json.contains(r#""camp_id":"camp-xyz""#));
596        assert!(json.contains(r#""auth_strength":"user-fresh""#));
597        assert!(json.contains(r#""scope":["cloud:deploy","cloud:read"]"#));
598        let back: McpClaims = serde_json::from_str(&json).unwrap();
599        assert_eq!(back, c);
600    }
601
602    #[test]
603    fn mcp_claims_minimal_shape_omits_optionals() {
604        let c = McpClaims::new(
605            "iss",
606            "aud",
607            PrincipalId::service("yubaba"),
608            1000,
609            1300,
610            "jti-2",
611            vec![Scope::OwnershipWrite],
612        );
613        let json = serde_json::to_string(&c).unwrap();
614        for absent in ["\"act\"", "\"camp_id\"", "\"owns\"", "\"auth_strength\""] {
615            assert!(
616                !json.contains(absent),
617                "{absent} must be omitted when unset: {json}"
618            );
619        }
620        let back: McpClaims = serde_json::from_str(&json).unwrap();
621        assert_eq!(back, c);
622    }
623
624    #[test]
625    fn mcp_claims_expiry_check() {
626        let c = sample_claims();
627        assert!(!c.is_expired_at(1299));
628        assert!(c.is_expired_at(1300));
629        assert!(c.is_expired_at(1301));
630    }
631
632    #[test]
633    fn mcp_claims_deserialize_rejects_unprefixed_sub() {
634        let json = r#"{"iss":"i","aud":"a","sub":"alice","iat":1,"exp":2,"jti":"j","scope":[]}"#;
635        let err = serde_json::from_str::<McpClaims>(json).unwrap_err();
636        assert!(
637            err.to_string().contains("must be prefixed"),
638            "expected prefix-required error: {err}"
639        );
640    }
641
642    #[test]
643    fn mcp_claims_deserialize_rejects_wildcard_scope() {
644        let json = r#"{"iss":"i","aud":"a","sub":"user:alice","iat":1,"exp":2,"jti":"j","scope":["cloud:*"]}"#;
645        let err = serde_json::from_str::<McpClaims>(json).unwrap_err();
646        assert!(err.to_string().contains("wildcard"), "expected wildcard rejection: {err}");
647    }
648}