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`, `dlg_depth`, `scopes` all default to "deny / empty / 0".
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
26#[derive(Debug, Clone)]
27pub struct IssueRequest {
28 /// Subject — the principal the token is about (RFC 7519 §4.1.2).
29 /// PAS-issued human tokens carry `ppnum_id` (ULID); AI-agent tokens
30 /// carry the agent's ULID. Never empty.
31 pub sub: String,
32
33 /// `client_id` — the OAuth client whose credentials authorized this
34 /// token (RFC 9068 §2.2). 1st-party flows use `"ppoppo-internal"`;
35 /// External Developer flows use the registered OAuth client_id.
36 pub client_id: String,
37
38 /// Time-to-live from now. The engine computes `exp = iat + ttl` and
39 /// emits both. Per-profile cap (24h access / 200d refresh) is
40 /// enforced via M19 on the verify side.
41 pub ttl: Duration,
42
43 /// Optional caller-supplied `jti`. When `None`, `engine::encode::issue`
44 /// generates a fresh ULID at issuance time. Tests pin a known ULID
45 /// so assertions can match by exact value.
46 pub jti: Option<Ulid>,
47
48 // ── Phase 4 domain claims (M39–M45) ──────────────────────────────────
49 /// `account_type` — principal class (M40). Whitelist `{human, ai_agent}`
50 /// enforced verifier-side; absent for legacy tokens. PAS sets `"human"`
51 /// on user-facing flows and `"ai_agent"` on client_credentials.
52 pub account_type: Option<String>,
53
54 /// `admin` — issue-time admin gate flag (M44). When `true`, the
55 /// verifier additionally requires `active_ppnum` (or `sub` band
56 /// fallback) to fall in an admin-allocated band — defense in depth
57 /// against forged tokens with a stolen signing key.
58 pub admin: bool,
59
60 /// `caps` — capability list (M41). Default `[]` is the default-deny
61 /// surface contract: a token with no capabilities cannot perform any
62 /// privileged operation. Engine validates only that the wire shape
63 /// is an array of strings; semantic enforcement is per-surface.
64 pub caps: Vec<String>,
65
66 /// `delegator` — delegating principal's `ppnum_id` (M40-adjacent).
67 /// Set on tokens minted via Token Exchange flows to record who
68 /// authorized the delegated session. Audit logs key off this field.
69 /// (Wire name: `delegator`; the earlier `actor` name was retired —
70 /// RFC 8693 reserves `actor` for token-exchange chain semantics that
71 /// don't apply here.)
72 pub delegator: Option<String>,
73
74 /// `dlg_depth` — delegation chain depth (M43). 0 = original principal,
75 /// each Token Exchange step increments by 1. Engine rejects > 4 to
76 /// bound the audit-trail explosion of arbitrarily deep delegation.
77 /// `u8` is intentional: there is no scenario where depth ≥ 256.
78 pub dlg_depth: u8,
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` — session row id (M36, Phase 5). When set, the verifier's
111 /// `cfg.session_revocation::is_active(sub, sid)` query gates token
112 /// admission against `user_sessions(sub, sid)` row liveness — row
113 /// deletion = revocation per STANDARDS_JWT_DETAILS_MITIGATION §E.
114 /// PAS issuance sets this on every Human-path token bound to a
115 /// session row; AI-agent / machine flows leave it unset and the
116 /// verifier short-circuits the gate. Wire shape: ULID string when
117 /// present (matches `user_sessions.session_id` PK).
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 account_type: None,
133 admin: false,
134 caps: Vec::new(),
135 delegator: None,
136 dlg_depth: 0,
137 cid: None,
138 sv: None,
139 active_ppnum: None,
140 scopes: Vec::new(),
141 sid: None,
142 }
143 }
144
145 /// Pin a specific `jti` instead of letting the engine generate one.
146 /// Test-only escape hatch — production paths should never override.
147 #[must_use]
148 pub fn with_jti(mut self, jti: Ulid) -> Self {
149 self.jti = Some(jti);
150 self
151 }
152
153 /// Set `account_type` (M40). PAS issuance paths pass `"human"` or
154 /// `"ai_agent"`; the verifier's whitelist (Phase 4 commit 4.2)
155 /// rejects anything else.
156 #[must_use]
157 pub fn with_account_type(mut self, account_type: impl Into<String>) -> Self {
158 self.account_type = Some(account_type.into());
159 self
160 }
161
162 /// Set the admin gate flag (M44). Combined with `active_ppnum` band
163 /// check on the verify side, this is the issue-time half of the
164 /// admin-token defense in depth.
165 #[must_use]
166 pub fn with_admin(mut self, admin: bool) -> Self {
167 self.admin = admin;
168 self
169 }
170
171 /// Set the capability list (M41). An empty list (the default) means
172 /// no privileged capabilities; surface code MUST positive-check.
173 #[must_use]
174 pub fn with_caps(mut self, caps: Vec<String>) -> Self {
175 self.caps = caps;
176 self
177 }
178
179 /// Set the delegating principal's `ppnum_id` (M40-adjacent). Token
180 /// Exchange flows record the human authorizer here.
181 #[must_use]
182 pub fn with_delegator(mut self, delegator: impl Into<String>) -> Self {
183 self.delegator = Some(delegator.into());
184 self
185 }
186
187 /// Set the delegation chain depth (M43). 0 = direct, increments by 1
188 /// per Token Exchange step; engine bounds at 4.
189 #[must_use]
190 pub fn with_dlg_depth(mut self, dlg_depth: u8) -> Self {
191 self.dlg_depth = dlg_depth;
192 self
193 }
194
195 /// Set the WebAuthn credential id (`cid`). Call this only on the
196 /// passkey issuance path; other paths MUST leave it unset so audit
197 /// logs distinguish authentication methods without a per-row lookup.
198 #[must_use]
199 pub fn with_credential_id(mut self, credential_id: impl Into<String>) -> Self {
200 self.cid = Some(credential_id.into());
201 self
202 }
203
204 /// Set the per-account `session_version` epoch snapshot. Called by the
205 /// Human session path and by PAS's `AgentService.IssueToken` (ai_agent
206 /// client_credentials), reading `ppnums.session_version`. Token-Exchange
207 /// (delegated / exchange) paths leave it unset — deferred slices — and
208 /// the verifier's `check_epoch` gate short-circuits on absent `sv`.
209 #[must_use]
210 pub fn with_session_version(mut self, sv: u64) -> Self {
211 self.sv = Some(sv);
212 self
213 }
214
215 /// Set the display ppnum (`active_ppnum`). UI surfaces render this;
216 /// `sub` remains the immutable ULID for authorization decisions.
217 #[must_use]
218 pub fn with_active_ppnum(mut self, active_ppnum: impl Into<String>) -> Self {
219 self.active_ppnum = Some(active_ppnum.into());
220 self
221 }
222
223 /// Set the OAuth scope list (M42). Engine bounds the array to ≤ 256
224 /// entries on the verify side.
225 #[must_use]
226 pub fn with_scopes(mut self, scopes: Vec<String>) -> Self {
227 self.scopes = scopes;
228 self
229 }
230
231 /// Set the session row id (`sid` claim, M36 — Phase 5). Call this
232 /// only on issuance paths bound to a `user_sessions` row (Human
233 /// magic-link / passkey / refresh-cycle); AI-agent and machine
234 /// paths MUST leave it unset so the verifier short-circuits the
235 /// session-revocation gate. The verifier compares `(sub, sid)`
236 /// against the substrate; row deletion = revocation per
237 /// STANDARDS_JWT_DETAILS_MITIGATION §E.
238 #[must_use]
239 pub fn with_sid(mut self, sid: impl Into<String>) -> Self {
240 self.sid = Some(sid.into());
241 self
242 }
243}