Skip to main content

ppoppo_token/access_token/
issue_request.rs

1//! Per-issuance request payload.
2//!
3//! Phase 3 carved out the registered-claim core (`sub`, `client_id`, `ttl`,
4//! `jti`); Phase 4 (this expansion) adds 9 domain claim fields that the
5//! verifier's `engine/check_domain` mirrors. Every claim that varies per
6//! token lives here, every claim that's stable across many tokens lives on
7//! `IssueConfig`.
8//!
9//! Fields are `pub` rather than enclosed by accessors. The struct is a
10//! data carrier (mirrors `Claims` on the verify side); a builder for one
11//! optional field would be ceremony without payoff. Callers that adopt
12//! struct-literal syntax (none today) will get compile errors when later
13//! phases add fields, which is the right failure mode (silent defaulting
14//! hides intent).
15//!
16//! ── Default-deny invariant ───────────────────────────────────────────────
17//!
18//! `admin`, `caps`, `act`, `scopes` all default to "deny / empty / absent".
19//! Callers MUST opt in explicitly via `.with_admin(true)` / `.with_caps(...)`
20//! / etc. No issuance path can accidentally mint an admin token by
21//! forgetting to set a flag — the absent default is the safe default.
22
23use std::time::Duration;
24use ulid::Ulid;
25
26use super::{Act, EntityType};
27
28#[derive(Debug, Clone)]
29pub struct IssueRequest {
30    /// Subject — the principal the token is about (RFC 7519 §4.1.2).
31    /// PAS-issued human tokens carry `ppnum_id` (ULID); AI-agent tokens
32    /// carry the agent's ULID. Never empty.
33    pub sub: String,
34
35    /// `client_id` — the OAuth client whose credentials authorized this
36    /// token (RFC 9068 §2.2). 1st-party flows use `"ppoppo-internal"`;
37    /// External Developer flows use the registered OAuth client_id.
38    pub client_id: String,
39
40    /// Time-to-live from now. The engine computes `exp = iat + ttl` and
41    /// emits both. Per-profile cap (24h access / 200d refresh) is
42    /// enforced via M19 on the verify side.
43    pub ttl: Duration,
44
45    /// Optional caller-supplied `jti`. When `None`, `engine::encode::issue`
46    /// generates a fresh ULID at issuance time. Tests pin a known ULID
47    /// so assertions can match by exact value.
48    pub jti: Option<Ulid>,
49
50    // ── Phase 4 domain claims (M39–M45) ──────────────────────────────────
51    /// `entity_type` — identity class of `sub` (M40). Absent on
52    /// 1st-party OTP/passkey flows. Typed, so a mint site cannot emit a
53    /// value this crate's own verifier would reject as forgery — the
54    /// emitter⊆admitted half of the K8 reachability invariant is held
55    /// by the type rather than by review.
56    pub entity_type: Option<EntityType>,
57
58    /// `admin` — issue-time admin gate flag (M44). When `true`, the
59    /// verifier additionally requires `active_ppnum` (or `sub` band
60    /// fallback) to fall in an admin-allocated band — defense in depth
61    /// against forged tokens with a stolen signing key.
62    pub admin: bool,
63
64    /// `caps` — capability list (M41). Default `[]` is the default-deny
65    /// surface contract: a token with no capabilities cannot perform any
66    /// privileged operation. Engine validates only that the wire shape
67    /// is an array of strings; semantic enforcement is per-surface.
68    pub caps: Vec<String>,
69
70    /// `act` (RFC 8693 §4.1) — the acting party, set on tokens minted via
71    /// Token Exchange flows to record who is driving this session. Audit
72    /// logs key off it, and it is half of the delegation predicate
73    /// (`entity_type == Human && act.is_some()`).
74    ///
75    /// Chain depth is the nesting depth — there is no separate depth
76    /// claim to keep in step with it, and the engine bounds that nesting
77    /// (M43) on verify.
78    pub act: Option<Act>,
79
80    /// `cid` — WebAuthn credential id that authenticated this session
81    /// (passkey path only). Enables session-to-credential provenance for
82    /// forensic analysis and selective-session-kill flows. Absent on
83    /// every non-passkey path so audit logs distinguish authentication
84    /// methods without a per-row lookup.
85    pub cid: Option<String>,
86
87    /// `sv` — per-account `session_version` epoch snapshot. Validators
88    /// compare `token.sv >= cached(sv:{sub})` and reject stale tokens; the
89    /// counter (on `ppnums.session_version`) bumps inside a revocation TX
90    /// (break-glass / LogoutAll / agent disconnect), invalidating all prior
91    /// tokens within the consumer cache TTL. Carried by Human session
92    /// tokens AND by AI-agent client_credentials tokens minted via PAS's
93    /// `AgentService.IssueToken` (the AI-agent kill-switch). Absent on
94    /// Token-Exchange (delegated / exchange) tokens — deferred slices — so
95    /// the engine `check_epoch` gate short-circuits for those.
96    pub sv: Option<u64>,
97
98    /// `active_ppnum` — display ppnum (e.g. `123-1234-5678`). UI surfaces
99    /// render this; `sub` is the immutable ULID and is the authorization
100    /// axis. Absent on tokens that don't represent a human-facing
101    /// session (raw machine tokens).
102    pub active_ppnum: Option<String>,
103
104    /// `scopes` — OAuth scope list (M42). Engine bounds the array to ≤ 256
105    /// entries (RFC 8725-adjacent — bound the per-token audit surface).
106    /// Default `[]` is "no externally-granted scope"; 1st-party flows
107    /// emit a non-empty list (`profile`, `email`, etc).
108    pub scopes: Vec<String>,
109
110    /// `sid` — the issuer's session identifier (M36, Phase 5). When set,
111    /// the verifier's `cfg.session_revocation::is_active(sub, sid)` query
112    /// gates token admission against that session's liveness — deletion
113    /// = revocation per STS_JWT_DETAILS_MITIGATION §E. PAS issuance sets
114    /// this to the refresh-token `family_id` on every 1st-party session
115    /// path; AI-agent / machine / OAuth flows leave it unset and the
116    /// verifier short-circuits the gate. Wire shape: ULID string when
117    /// present.
118    pub sid: Option<String>,
119}
120
121impl IssueRequest {
122    /// Construct a new request with the required fields. Domain claim
123    /// fields default to "absent / empty / 0 / false" — every emission
124    /// is opt-in via a `with_*` builder, so a caller who forgets to set
125    /// `admin` cannot accidentally mint an admin token.
126    pub fn new(sub: impl Into<String>, client_id: impl Into<String>, ttl: Duration) -> Self {
127        Self {
128            sub: sub.into(),
129            client_id: client_id.into(),
130            ttl,
131            jti: None,
132            entity_type: None,
133            admin: false,
134            caps: Vec::new(),
135            act: None,
136            cid: None,
137            sv: None,
138            active_ppnum: None,
139            scopes: Vec::new(),
140            sid: None,
141        }
142    }
143
144    /// Pin a specific `jti` instead of letting the engine generate one.
145    /// Test-only escape hatch — production paths should never override.
146    #[must_use]
147    pub fn with_jti(mut self, jti: Ulid) -> Self {
148        self.jti = Some(jti);
149        self
150    }
151
152    /// Set `entity_type` (M40).
153    ///
154    /// Takes [`EntityType`], not a string: the verifier rejects any
155    /// value outside the whitelist as forgery, so a mint site that
156    /// could name one would be building a token this crate refuses.
157    /// Callers holding a wider domain enum (PAS's 5-value
158    /// `ppnums.entity_type`) must map it explicitly and decide what to
159    /// do with the values that have no credential flow — the compiler
160    /// will not let that mapping be silently total.
161    #[must_use]
162    pub fn with_entity_type(mut self, entity_type: EntityType) -> Self {
163        self.entity_type = Some(entity_type);
164        self
165    }
166
167    /// Set the admin gate flag (M44). Combined with `active_ppnum` band
168    /// check on the verify side, this is the issue-time half of the
169    /// admin-token defense in depth.
170    #[must_use]
171    pub fn with_admin(mut self, admin: bool) -> Self {
172        self.admin = admin;
173        self
174    }
175
176    /// Set the capability list (M41). An empty list (the default) means
177    /// no privileged capabilities; surface code MUST positive-check.
178    #[must_use]
179    pub fn with_caps(mut self, caps: Vec<String>) -> Self {
180        self.caps = caps;
181        self
182    }
183
184    /// Set the acting party (`act`, RFC 8693 §4.1). Token Exchange flows
185    /// record here who is driving the session.
186    ///
187    /// Takes [`Act`] rather than a bare identifier so a mint site cannot
188    /// express "delegation happened" without naming the actor — the two
189    /// were separable while the claim was a flat string plus a depth
190    /// counter, and that separability is what made the chain length
191    /// forgeable.
192    #[must_use]
193    pub fn with_act(mut self, act: Act) -> Self {
194        self.act = Some(act);
195        self
196    }
197
198    /// Set the WebAuthn credential id (`cid`). Call this only on the
199    /// passkey issuance path; other paths MUST leave it unset so audit
200    /// logs distinguish authentication methods without a per-row lookup.
201    #[must_use]
202    pub fn with_credential_id(mut self, credential_id: impl Into<String>) -> Self {
203        self.cid = Some(credential_id.into());
204        self
205    }
206
207    /// Set the per-account `session_version` epoch snapshot. Called by the
208    /// Human session path and by PAS's `AgentService.IssueToken` (ai_agent
209    /// client_credentials), reading `ppnums.session_version`. Token-Exchange
210    /// (delegated / exchange) paths leave it unset — deferred slices — and
211    /// the verifier's `check_epoch` gate short-circuits on absent `sv`.
212    #[must_use]
213    pub fn with_session_version(mut self, sv: u64) -> Self {
214        self.sv = Some(sv);
215        self
216    }
217
218    /// Set the display ppnum (`active_ppnum`). UI surfaces render this;
219    /// `sub` remains the immutable ULID for authorization decisions.
220    #[must_use]
221    pub fn with_active_ppnum(mut self, active_ppnum: impl Into<String>) -> Self {
222        self.active_ppnum = Some(active_ppnum.into());
223        self
224    }
225
226    /// Set the OAuth scope list (M42). Engine bounds the array to ≤ 256
227    /// entries on the verify side.
228    #[must_use]
229    pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
230        self.scopes = scopes;
231        self
232    }
233
234    /// Set the issuer's session id (`sid` claim, M36 — Phase 5). Call
235    /// this only on issuance paths bound to a session (in PAS: the
236    /// 1st-party Human paths that own a refresh-token family — CLI
237    /// handoff / magic-link / passkey / refresh-cycle, where the value
238    /// is that `family_id`); AI-agent, machine and OAuth paths MUST
239    /// leave it unset so the verifier short-circuits the
240    /// session-revocation gate. The verifier compares `(sub, sid)`
241    /// against the substrate; deletion = revocation per
242    /// STS_JWT_DETAILS_MITIGATION §E.
243    #[must_use]
244    pub fn with_sid(mut self, sid: impl Into<String>) -> Self {
245        self.sid = Some(sid.into());
246        self
247    }
248}