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}