Skip to main content

cheers_core/
claims.rs

1//! Identifiers, device bindings, and the `Claims` carried by a verified session token.
2//!
3//! These types are the **mesofact ↔ cheers contract**: any change after the
4//! mesofact integration (P11) ships requires a coordinated migration. Public
5//! structs and enums are `#[non_exhaustive]` so adding fields or variants
6//! later is not a SemVer-breaking change. Construct new values through the
7//! provided constructors (and builder-style setters where present), not
8//! struct literals.
9//!
10//! @yah:ticket(R020-F2, "Principal kinds: user|service|camp enum + Principal record in cheers-core")
11//! @yah:assignee(agent:claude)
12//! @yah:at(2026-06-04T01:35:04Z)
13//! @yah:status(review)
14//! @yah:phase(P1)
15//! @yah:parent(R020)
16//! @yah:next("Add PrincipalKind { User, Service, Camp } and Principal { id, kind, bound_to: Option<PrincipalId>, status, created_at } to cheers-core.")
17//! @yah:next("Extend sub-claim parser to accept 'user:<id>' | 'svc:<id>' | 'camp:<id>' prefixes; reject unprefixed sub at parse time.")
18//! @yah:verify("cargo test -p cheers-core")
19//! @yah:verify("Roundtrip test: Principal { kind: Camp, bound_to: Some(user) } serializes/parses; bound_to=None on a Camp is a parse error.")
20//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
21//! @yah:depends_on(R019-F5)
22//! @yah:handoff("Landed new module crates/cheers-core/src/principal.rs (exported from lib.rs): PrincipalKind { User, Service, Camp } + PrincipalId { kind, id } + PrincipalStatus { Active, Revoked } + Principal { id: PrincipalId, bound_to: Option<PrincipalId>, status, created_at } + PrincipalError + PrincipalIdParseError. All #[non_exhaustive].")
23//! @yah:handoff("PrincipalId is the typed sub-claim — serializes transparent as 'user:<id>' | 'svc:<id>' | 'camp:<id>'. FromStr/Deserialize reject unprefixed input (MissingPrefix), unknown prefixes (UnknownPrefix incl. legacy 'service'/'agent'), and empty ids — so a session-shaped bare sub cannot silently be read as a user principal. PrincipalKind::prefix uses 'svc' (matches the doc), not 'service'.")
24//! @yah:handoff("Principal invariants enforced in BOTH try_new and the Deserialize impl (via RawPrincipal intermediate): Camp ⇒ bound_to=Some(user:_); User/Service ⇒ bound_to=None; Camp bound_to that isn't a user is rejected. JSON omits bound_to when None (skip_serializing_if).")
25//! @yah:handoff("Did NOT touch existing Claims.sub: UserId — that's the session contract; the MCP-claims shape (act/owns/camp_id/auth_strength) lands in R020-F3 alongside the Scope enum and will be where PrincipalId actually replaces a sub field. Foundation laid; R020-F3 builds on PrincipalId for its sub typing.")
26//! @yah:handoff("Verified GREEN: cargo test -p cheers-core (33 unit incl. 17 new principal tests, 1 doctest), cargo test -p cheers-server (35+9+2+4+0 across binaries/integration), cargo test -p cheers-verify (clean). R020 parent verify smoke passes.")
27//!
28//! @yah:ticket(R020-F3, "Scope vocabulary as typed enum + composition rules in cheers-core")
29//! @yah:assignee(agent:claude)
30//! @yah:at(2026-06-04T01:35:12Z)
31//! @yah:status(review)
32//! @yah:phase(P1)
33//! @yah:parent(R020)
34//! @yah:next("Add Scope enum covering arch:* board:* camp:* cloud:* party:* subagent:* ownership:write audit:* per §Scope vocabulary.")
35//! @yah:next("Enforce composition rules at grant/mint: no wildcards on the wire; <category>:admin does NOT imply read/write; ownership:write and audit:write are kind=service only; aud-scoping mandatory.")
36//! @yah:next("Add MCP claim shapes alongside Scope: act { sub }, owns { service: [], arch_doc: [] }, camp_id, auth_strength enum { Bootstrap, UserFresh }.")
37//! @yah:verify("cargo test -p cheers-core")
38//! @yah:verify("Negative test: serializing a Scope list containing 'cloud:*' fails; granting ownership:write to a User principal returns a typed error.")
39//! @yah:gotcha("A user-kind grant with ownership:write or audit:write must be rejected at write time, not just at mint. Rule (4) is a CHECK that lives in the grant API, not the mint path.")
40//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
41//! @yah:handoff("Landed new module crates/cheers-core/src/mcp.rs (exported from lib.rs): closed-vocabulary Scope enum (16 variants — arch/board/camp/cloud/party/subagent + ownership:write + audit:{read,write}), GrantError, validate_grant(), and the MCP claim shapes (Actor, Owns, AuthStrength, McpClaims). All #[non_exhaustive].")
42//! @yah:handoff("Composition rules: (1) wildcards — enforced by Scope::from_str rejecting any '*' BEFORE the literal match, so a wildcard cannot be deserialized into a Vec<Scope> on the wire (tested via Vec<Scope> mid-list rejection). (3) <category>:admin distinct — enforced structurally: CampAdmin and CampRead are independent variants; a grant of one literally isn't a grant of the other; pinned with a test. (4) ownership:write + audit:write service-only — enforced by validate_grant(kind, scope), which rejects BOTH User and Camp (not just User — doc says 'kind=service only'). Service principals pass. (5) aud-scoping is documented as a mint-path concern, not a per-scope predicate.")
43//! @yah:handoff("Scope serializes as the literal wire string ('cloud:deploy'), not the variant name — hand-rolled Serialize/Deserialize via as_wire()/FromStr, NOT serde rename. McpClaims.sub is a PrincipalId (R020-F2), so a token whose sub is bare 'alice' fails deserialize with the 'must be prefixed' message inherited from PrincipalId. Owns has explicit service+arch_doc Vec<String> fields PLUS #[serde(flatten)] extra: BTreeMap<String,Vec<String>> so adding a new resource kind in the ownership table doesn't break the wire contract.")
44//! @yah:handoff("AuthStrength uses #[serde(rename_all=\"kebab-case\")] — Bootstrap→'bootstrap', UserFresh→'user-fresh' (matches the doc verbatim).")
45//! @yah:handoff("Did NOT touch the existing Claims.sub: UserId (session contract). McpClaims is the peer for MCP-call tokens. R020-F4 (ownership table writers) bolts the ownership lookups onto cheers-server and reads them into Owns at mint.")
46//! @yah:handoff("Verified GREEN: cargo test -p cheers-core (51 unit incl. 18 new mcp tests + 1 doctest), cargo test -p cheers-server (35+9+2+4+0), cargo test -p cheers-verify (clean). R020 parent verify smoke passes.")
47
48use serde::{Deserialize, Serialize};
49
50/// Stable user identifier — minted by `UserStore` on first sight of a credential.
51///
52/// Opaque to consumers; cheers does not interpret the inner string. Products
53/// pick the shape (UUID, base32 ULID, …); cheers passes it through.
54#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(transparent)]
56pub struct UserId(String);
57
58impl UserId {
59    pub fn new(s: impl Into<String>) -> Self {
60        Self(s.into())
61    }
62
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66
67    pub fn into_inner(self) -> String {
68        self.0
69    }
70}
71
72impl std::fmt::Display for UserId {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.write_str(&self.0)
75    }
76}
77
78impl From<String> for UserId {
79    fn from(s: String) -> Self {
80        Self(s)
81    }
82}
83
84impl From<&str> for UserId {
85    fn from(s: &str) -> Self {
86        Self(s.to_owned())
87    }
88}
89
90/// Per-device identifier — minted on the first sign-in from a given device.
91///
92/// One user has many devices; one device has one `DeviceId` per user.
93#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
94#[serde(transparent)]
95pub struct DeviceId(String);
96
97impl DeviceId {
98    pub fn new(s: impl Into<String>) -> Self {
99        Self(s.into())
100    }
101
102    pub fn as_str(&self) -> &str {
103        &self.0
104    }
105
106    pub fn into_inner(self) -> String {
107        self.0
108    }
109}
110
111impl std::fmt::Display for DeviceId {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.write_str(&self.0)
114    }
115}
116
117impl From<String> for DeviceId {
118    fn from(s: String) -> Self {
119        Self(s)
120    }
121}
122
123impl From<&str> for DeviceId {
124    fn from(s: &str) -> Self {
125        Self(s.to_owned())
126    }
127}
128
129/// How a device proved its identity to mint this session.
130///
131/// One variant per first-class provider in the build plan. `OidcGeneric`
132/// is the escape hatch for ad-hoc OIDC issuers (e.g. enterprise SSO) that
133/// aren't named providers.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[non_exhaustive]
136#[serde(tag = "kind", rename_all = "snake_case")]
137pub enum DeviceBinding {
138    Passkey,
139    OidcGoogle,
140    OidcApple,
141    OidcGeneric { issuer: String },
142    EmailPassword,
143    EmailMagicLink,
144    LanPair,
145}
146
147/// Resolved user record returned by `UserStore`.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[non_exhaustive]
150pub struct User {
151    pub id: UserId,
152    pub email: Option<String>,
153    pub name: Option<String>,
154}
155
156impl User {
157    pub fn new(id: UserId) -> Self {
158        Self {
159            id,
160            email: None,
161            name: None,
162        }
163    }
164
165    pub fn with_email(mut self, email: impl Into<String>) -> Self {
166        self.email = Some(email.into());
167        self
168    }
169
170    pub fn with_name(mut self, name: impl Into<String>) -> Self {
171        self.name = Some(name.into());
172        self
173    }
174}
175
176/// One stored proof-of-identity bound to a `(UserId, DeviceId)` pair.
177///
178/// The shape that `CredentialStore` reads and writes. The `binding` field
179/// records *how* the credential was established; provider-specific secrets
180/// live in `material` as an opaque byte blob (e.g. a passkey credential ID,
181/// an Argon2id hash, a refresh-token chain root, …) whose interpretation is
182/// owned by the provider that produced it.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[non_exhaustive]
185pub struct Credential {
186    pub user_id: UserId,
187    pub device_id: DeviceId,
188    pub binding: DeviceBinding,
189    pub material: Vec<u8>,
190}
191
192impl Credential {
193    pub fn new(
194        user_id: UserId,
195        device_id: DeviceId,
196        binding: DeviceBinding,
197        material: Vec<u8>,
198    ) -> Self {
199        Self {
200            user_id,
201            device_id,
202            binding,
203            material,
204        }
205    }
206}
207
208/// Verified session claims — what a `TokenVerifier::verify` returns on success.
209///
210/// Stable shape; new fields land behind `#[non_exhaustive]`. Timestamps are
211/// unix seconds (signed to leave room for pre-epoch sentinels in tests).
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213#[non_exhaustive]
214pub struct Claims {
215    pub sub: UserId,
216    pub device: DeviceId,
217    pub binding: DeviceBinding,
218    pub issued_at: i64,
219    pub expires_at: i64,
220    /// Unique token id — the key the revocation set is keyed on (R019-F4).
221    ///
222    /// Empty means *unset / not individually revocable*; sessions minted through
223    /// `cheers-server`'s `SessionAuthority` get a fresh value via
224    /// [`with_jti`](Self::with_jti). `#[serde(default, skip_serializing_if)]`
225    /// keeps the wire format byte-identical to a pre-`jti` token when unset —
226    /// important for the mesofact cookie contract.
227    #[serde(default, skip_serializing_if = "String::is_empty")]
228    pub jti: String,
229}
230
231impl Claims {
232    pub fn new(
233        sub: UserId,
234        device: DeviceId,
235        binding: DeviceBinding,
236        issued_at: i64,
237        expires_at: i64,
238    ) -> Self {
239        Self {
240            sub,
241            device,
242            binding,
243            issued_at,
244            expires_at,
245            jti: String::new(),
246        }
247    }
248
249    /// Attach a `jti` (the revocation key). Builder-style so existing
250    /// five-arg [`new`](Self::new) call sites are unaffected.
251    pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
252        self.jti = jti.into();
253        self
254    }
255
256    /// `true` if `expires_at` is at or before `now` (unix seconds).
257    pub fn is_expired_at(&self, now: i64) -> bool {
258        self.expires_at <= now
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn user_id_roundtrips_through_string() {
268        let u = UserId::new("alice");
269        assert_eq!(u.as_str(), "alice");
270        assert_eq!(u.to_string(), "alice");
271        assert_eq!(UserId::from("alice"), u);
272    }
273
274    #[test]
275    fn user_id_serde_is_transparent() {
276        let u = UserId::new("u-123");
277        let json = serde_json::to_string(&u).unwrap();
278        assert_eq!(json, "\"u-123\"");
279        let back: UserId = serde_json::from_str(&json).unwrap();
280        assert_eq!(back, u);
281    }
282
283    #[test]
284    fn device_binding_serializes_with_kind_tag() {
285        let b = DeviceBinding::OidcGeneric {
286            issuer: "https://idp.example".into(),
287        };
288        let json = serde_json::to_string(&b).unwrap();
289        assert!(json.contains("\"kind\":\"oidc_generic\""));
290        assert!(json.contains("\"issuer\":\"https://idp.example\""));
291        let back: DeviceBinding = serde_json::from_str(&json).unwrap();
292        assert_eq!(back, b);
293
294        let unit = DeviceBinding::Passkey;
295        let json = serde_json::to_string(&unit).unwrap();
296        assert_eq!(json, "{\"kind\":\"passkey\"}");
297    }
298
299    #[test]
300    fn user_builder_sets_optional_fields() {
301        let u = User::new(UserId::new("u1"))
302            .with_email("a@b")
303            .with_name("Alice");
304        assert_eq!(u.email.as_deref(), Some("a@b"));
305        assert_eq!(u.name.as_deref(), Some("Alice"));
306    }
307
308    #[test]
309    fn claims_expiry_check() {
310        let c = Claims::new(
311            UserId::new("u1"),
312            DeviceId::new("d1"),
313            DeviceBinding::Passkey,
314            100,
315            200,
316        );
317        assert!(!c.is_expired_at(199));
318        assert!(c.is_expired_at(200));
319        assert!(c.is_expired_at(201));
320    }
321
322    #[test]
323    fn claims_jti_defaults_empty_and_omitted_from_wire() {
324        let c = Claims::new(
325            UserId::new("u1"),
326            DeviceId::new("d1"),
327            DeviceBinding::Passkey,
328            100,
329            200,
330        );
331        assert_eq!(c.jti, "");
332        // Unset jti must not appear on the wire — keeps the cookie format
333        // identical to a pre-jti token.
334        let json = serde_json::to_string(&c).unwrap();
335        assert!(!json.contains("jti"), "empty jti must be skipped: {json}");
336
337        let c = c.with_jti("tok-123");
338        assert_eq!(c.jti, "tok-123");
339        let json = serde_json::to_string(&c).unwrap();
340        assert!(json.contains("\"jti\":\"tok-123\""));
341        let back: Claims = serde_json::from_str(&json).unwrap();
342        assert_eq!(back, c);
343    }
344
345    #[test]
346    fn claims_roundtrip_json() {
347        let c = Claims::new(
348            UserId::new("u1"),
349            DeviceId::new("d1"),
350            DeviceBinding::OidcGeneric {
351                issuer: "https://idp".into(),
352            },
353            100,
354            200,
355        );
356        let json = serde_json::to_string(&c).unwrap();
357        let back: Claims = serde_json::from_str(&json).unwrap();
358        assert_eq!(back, c);
359    }
360
361    #[test]
362    fn credential_holds_opaque_material() {
363        let cred = Credential::new(
364            UserId::new("u1"),
365            DeviceId::new("d1"),
366            DeviceBinding::EmailPassword,
367            b"argon2id$...".to_vec(),
368        );
369        assert_eq!(cred.material, b"argon2id$...");
370    }
371}