Skip to main content

ppoppo_token/access_token/
claims.rs

1//! Verified claim payload returned by the JWT engine after `verify`.
2//!
3//! Surface discipline (Phase 2 Decision 1, extended Phase 4):
4//!
5//! - **Hidden** — claims the engine fully resolves with no caller
6//!   participation:
7//!     - `aud` (M20-M22 validates against `cfg.audience`)
8//!     - `cat` (M29 validates against `cfg.expected_cat`)
9//!     - `sv` (Phase 5 cache compares against `sv:{sub}`)
10//! - **Surfaced** — claims callers legitimately need post-verify:
11//!     - `client_id` (M28a — audit logs, per-client rate limits)
12//!     - `entity_type` (M40 — admin gate code reads it)
13//!     - `caps`, `scopes` (M41/M42 — capability check is post-verify)
14//!     - `act` (Token Exchange audit logs; the delegation predicate)
15//!     - `cid` (forensic / selective-session-kill)
16//!     - `active_ppnum` (UI display)
17//!     - `admin` (admin RPC handlers gate on it)
18//!
19//! Surfacing wrongly is a forward-compat tax (every caller must handle
20//! the new field). Hiding wrongly leaves callers without info they need.
21//! When in doubt, hide — adding later is cheap; removing later is a
22//! breaking change.
23
24use super::{Act, EntityType};
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Claims {
28    pub iss: String,
29    pub sub: String,
30    pub exp: i64,
31    pub iat: i64,
32    pub nbf: Option<i64>,
33    pub jti: String,
34    pub client_id: String,
35
36    // ── Phase 4 surfaced domain claims (M40+) ───────────────────────────
37    /// `entity_type` (M40) — the identity class of `sub`. `None` for
38    /// 1st-party OTP/passkey tokens, which carry no such claim (legacy
39    /// admit). Typed, so the whitelist is enforced by parsing rather
40    /// than by every consumer re-stating it: a wire value outside
41    /// [`EntityType`] is rejected with `AuthError::EntityTypeInvalid`
42    /// before this struct is constructed.
43    ///
44    /// **Not session mode** — a human identity driven by an agent is
45    /// `Some(EntityType::Human)` *plus* [`Self::act`], never a
46    /// distinct value here. See [`EntityType`] for why that
47    /// distinction is enforced by the type.
48    pub entity_type: Option<EntityType>,
49
50    /// `caps` (M41) — capability list. Empty when absent or empty-array
51    /// on the wire (the engine collapses both to the same surface so
52    /// callers' default-deny check is `caps.contains(&"x")`). Wire-shape
53    /// validation (must be a JSON array of strings) lives in
54    /// `engine::check_domain`; semantic interpretation of each
55    /// capability string is per-surface (PAS, PCS, RCW each own their
56    /// vocabulary).
57    pub caps: Vec<String>,
58
59    /// `scopes` (M42) — OAuth scope list. Empty when absent or empty-
60    /// array (same collapse as `caps`). Engine bounds the array length
61    /// at ≤ 256; entries beyond that are a forgery / misconfiguration
62    /// signal that pessimizes per-request scope checks. Conceptually
63    /// distinct from `caps` (scopes are externally granted via OAuth;
64    /// caps are internally minted by PAS), so the surfacing is duplicated
65    /// rather than unified — collapsing them would force callers to
66    /// untangle two authorization vectors at every check site.
67    pub scopes: Vec<String>,
68
69    /// `admin` (M44) — token claims admin authority. **Admin authority
70    /// is DB-determined** (STS_AUTH_PPOPPO §3.2: `is_admin = TRUE
71    /// AND lifecycle_state = 'active'` AND active passkey ≥ 1). This
72    /// claim is the *fast pre-flight signal* — when `admin == true`,
73    /// the engine has already proven `active_ppnum` falls in the admin
74    /// band (defense in depth against stolen-signing-key forgeries).
75    /// Callers MUST still call the DB-side `is_admin` invariant — this
76    /// flag tells them whether to even bother.
77    pub admin: bool,
78    /// `active_ppnum` (M44 + UI display) — the digit-form ppnum the
79    /// session is currently active under. UI surfaces render this;
80    /// `sub` (ULID) is the immutable authorization axis. Engine reads
81    /// it for the M44 admin-band check and surfaces it unchanged.
82    pub active_ppnum: Option<String>,
83
84    /// `act` (RFC 8693 §4.1) — the party currently acting for `sub`, and
85    /// the delegation chain behind it. Surfaced for audit logs (which
86    /// principal authorized this session) and because it is half of the
87    /// delegation predicate: *delegated* is
88    /// `entity_type == Human && act.is_some()`, derived at one site rather
89    /// than stored as a value in either axis. `None` for tokens outside a
90    /// chain.
91    ///
92    /// The name is the registered claim's. The retired `delegator` claim
93    /// carried a rationale that was backwards on both counts — RFC 8693
94    /// registers `act` (not `actor`), and its token-exchange chain
95    /// semantics apply here **exactly**: these tokens are minted by an RPC
96    /// named `ExchangeToken`. Depth lives in the nesting, which is why
97    /// `dlg_depth` is gone (see [`Act::depth`]).
98    pub act: Option<Act>,
99
100    /// `cid` — WebAuthn credential id that authenticated this session
101    /// (passkey path only). Surfaces for forensic provenance and
102    /// future selective-session-kill flows. `None` on every non-
103    /// passkey path so audit logs distinguish authentication methods
104    /// without a per-row lookup.
105    pub cid: Option<String>,
106
107    /// `sid` (M36) — the issuer's identifier for the session this token
108    /// was minted into. The engine does not define what a session *is*;
109    /// it only carries the id and, when present, asks the issuer via
110    /// `cfg.session_revocation.is_active(sub, sid)`, refusing if the
111    /// answer is "gone" (STS_JWT_DETAILS_MITIGATION §E "row deletion
112    /// = revocation"). `None` on machine tokens / AI-agent flows that
113    /// belong to no session; engine short-circuits the gate when `None`
114    /// so non-session-bound tokens admit.
115    ///
116    /// **In PAS this is the refresh-token `family_id`** — that *is* the
117    /// system's unit of session (`accounts-core`
118    /// `session_management::list_sessions`: "each family represents one
119    /// session"), so `is_active` reduces to "does an unrevoked family
120    /// `sid` still exist for `sub`". Wire shape: ULID string.
121    pub sid: Option<String>,
122}