ppoppo_token/id_token/issue_request.rs
1//! Per-issuance id_token principal-assertion payload — phantom-typed by scope.
2//!
3//! Field-for-field mirror of `Claims<S>` on the issuance side. `IssueRequest<S>`
4//! carries everything **only the IdP asserts** (the principal's identity,
5//! when/how they authenticated, profile PII), while the RP-knowable
6//! bindings (`nonce`, at_hash inputs, c_hash inputs) live on `IssueConfig`
7//! per the conceptual split documented there.
8//!
9//! ── The structural invariant (D2 emission half) ─────────────────────────
10//!
11//! PII fields (`email`, `name`, …) are
12//! `pub(crate)` — same shape as `Claims<S>::pub(crate)` on the verify
13//! side. The only way to *populate* them is through the scope-bounded
14//! `impl<S: HasEmail> IssueRequest<S>` builder blocks below; calling
15//! `.with_email(...)` on an `IssueRequest<Openid>` is a *compile error*.
16//! That's the type-system half of D2 (project_phase7_module_naming —
17//! "phantom-typed `IssueRequest<S>` whose `with_*` builders are gated on
18//! `S: HasEmail`/`HasProfile`").
19//!
20//! The runtime half is the M72-symmetric allowlist guard inside
21//! `engine::encode_id_token::IssuePayload::build`: even if intra-crate
22//! code bypasses the builders via struct-literal access to the
23//! `pub(crate)` fields, the engine refuses to emit any populated key
24//! outside `S::claim_names()` (β1 defense in depth — see
25//! `IssueError::EmissionDisallowed`).
26//!
27//! ── Why field-for-field with `Claims<S>` ────────────────────────────────
28//!
29//! Symmetry watch (per NEXT_PROMPT 2026-05-10 architecture-health note):
30//! every field on `Claims<S>` MUST have a corresponding builder on
31//! `IssueRequest<S>` (or be engine-managed from `IssueConfig` + clock).
32//! Forgetting one means the issuance side cannot emit a claim the
33//! verify side can read — silent narrowing. The engine-managed set is
34//! `iss` / `exp` / `iat` (from `cfg.issuer` + clock) and `aud` (from
35//! `cfg.audiences`) plus `nonce` / `at_hash` / `c_hash` (from
36//! IssueConfig per γ1). Everything else lives on this struct.
37//!
38//! Construction goes through [`IssueRequest::new`] which sets all
39//! optional fields to their absent defaults (`None`, `Vec::new()`).
40//! Builders are chainable (`#[must_use]`) and composable; the type
41//! parameter `S` is fixed at `new` time via turbofish:
42//! `IssueRequest::<EmailProfile>::new(...)`.
43
44use std::marker::PhantomData;
45use std::time::Duration;
46
47use super::scopes::{ClaimScope, HasEmail, HasProfile};
48
49/// OIDC id_token issuance payload, phantom-typed by `S: ClaimScope`.
50///
51/// The `S` parameter witnesses the OAuth scope the issuer is honoring.
52/// PII builders (`with_email`, `with_name`, …) are gated by the matching
53/// marker traits (`HasEmail`, `HasProfile`),
54/// making "wrong scope, wrong field" a compile error.
55///
56/// ── compile_fail evidence (D2 emission half) ────────────────────────────
57///
58/// The standing acceptance fixture is the doc-test below; `cargo test
59/// --doc -p ppoppo-token` runs it and asserts the snippet fails to
60/// compile (E0599 — method not found).
61///
62/// ```compile_fail,E0599
63/// use std::time::Duration;
64/// use ppoppo_token::id_token::{IssueRequest, scopes::Openid};
65///
66/// fn _compile_fail() {
67/// let _ = IssueRequest::<Openid>::new(
68/// "01HSAB00000000000000000000",
69/// Duration::from_secs(600),
70/// )
71/// .with_email("u@example.com"); // ERROR: with_email requires S: HasEmail
72/// }
73/// ```
74///
75/// Granting the `email` scope at issuance time satisfies the bound:
76///
77/// ```ignore
78/// use std::time::Duration;
79/// use ppoppo_token::id_token::{IssueRequest, scopes::Email};
80///
81/// fn _compiles() {
82/// let _ = IssueRequest::<Email>::new(
83/// "01HSAB00000000000000000000",
84/// Duration::from_secs(600),
85/// )
86/// .with_email("u@example.com");
87/// }
88/// ```
89#[derive(Debug, Clone)]
90pub struct IssueRequest<S: ClaimScope> {
91 // ── Core principal data (always present) ──────────────────────────────
92 /// `sub` — the principal the id_token is about (RFC 7519 §4.1.2,
93 /// OIDC Core §2). PAS-issued tokens carry `ppnum_id` (ULID); never
94 /// empty.
95 pub sub: String,
96
97 /// Time-to-live from now. The engine computes `exp = iat + ttl` and
98 /// emits both. Per-profile cap is per-deployment; the engine may
99 /// enforce upper bounds in a future row (analogous to access-token
100 /// M19).
101 pub ttl: Duration,
102
103 // Note: no `jti` field. OIDC Core §2 lists jti as neither required
104 // nor recommended for id_tokens (replay defense is the nonce path),
105 // and `Claims<S>` on the verify side carries no `jti` accessor —
106 // adding one to `IssueRequest<S>` and emitting it on the wire would
107 // be an asymmetry the verifier never reads, and would force "jti"
108 // into `BASE_CLAIMS` for no semantic gain. Access-token's
109 // `IssueRequest::jti` exists because RFC 9068 §2.2.2 requires it on
110 // the access-token wire; this profile diverges deliberately.
111
112 // ── IdP-asserted claims (Phase 10.10) ────────────────────────────────
113 /// `auth_time` — when the End-User authentication occurred (Unix
114 /// seconds). The verify-side M70 gate (Phase 10.6) compares this
115 /// against `now - max_age`; the issuer-side just emits what the IdP
116 /// witnessed. Required when the RP requested `max_age` in the auth
117 /// request — but that contract is between RP and IdP at the
118 /// app-protocol level, not the engine; emitting whenever the IdP
119 /// has a value is the safe default.
120 pub auth_time: Option<i64>,
121
122 /// `acr` — Authentication Context Class Reference (OIDC §2). The
123 /// verify-side M71 gate (Phase 10.7) refuses tokens whose acr is
124 /// not in `cfg.acr_values`. Emit a value when the IdP can attest to
125 /// a specific authentication context; absence collapses to "RP has
126 /// no acr policy or IdP cannot assert one".
127 pub acr: Option<String>,
128
129 /// `amr` — Authentication Methods References (e.g. `["pwd", "mfa"]`,
130 /// OIDC §2). Surfaced as data on the verify side; no gate. Emit
131 /// whenever the IdP knows the methods; absence is admitted.
132 pub amr: Option<Vec<String>>,
133
134 /// `azp` — Authorized Party (OIDC §2). The verify-side M69 gate
135 /// (Phase 10.5) requires `azp == client_id` whenever it's present
136 /// AND requires presence on multi-aud tokens. Issue side: set on
137 /// every multi-aud token; optional on single-aud (the §2 guidance
138 /// is silent on single-aud).
139 pub azp: Option<String>,
140
141 // ── PII — gated by scope-bounded builder blocks below ─────────────────
142 pub(crate) email: Option<String>,
143 pub(crate) email_verified: Option<bool>,
144
145 pub(crate) name: Option<String>,
146 pub(crate) given_name: Option<String>,
147 pub(crate) family_name: Option<String>,
148 pub(crate) middle_name: Option<String>,
149 pub(crate) nickname: Option<String>,
150 pub(crate) preferred_username: Option<String>,
151 pub(crate) profile: Option<String>,
152 pub(crate) picture: Option<String>,
153 pub(crate) website: Option<String>,
154 pub(crate) gender: Option<String>,
155 pub(crate) birthdate: Option<String>,
156 pub(crate) zoneinfo: Option<String>,
157 pub(crate) locale: Option<String>,
158 pub(crate) updated_at: Option<i64>,
159
160 pub(crate) _scope: PhantomData<S>,
161}
162
163impl<S: ClaimScope> IssueRequest<S> {
164 /// Construct a new request with the required core fields. All
165 /// optional fields default to absent; every emission is opt-in via a
166 /// `with_*` builder, so a caller who forgets to set a value cannot
167 /// accidentally emit a populated claim.
168 ///
169 /// The scope parameter is fixed at construction via turbofish:
170 /// `IssueRequest::<Email>::new("01H...", Duration::from_secs(600))`.
171 pub fn new(sub: impl Into<String>, ttl: Duration) -> Self {
172 Self {
173 sub: sub.into(),
174 ttl,
175 auth_time: None,
176 acr: None,
177 amr: None,
178 azp: None,
179 email: None,
180 email_verified: None,
181 name: None,
182 given_name: None,
183 family_name: None,
184 middle_name: None,
185 nickname: None,
186 preferred_username: None,
187 profile: None,
188 picture: None,
189 website: None,
190 gender: None,
191 birthdate: None,
192 zoneinfo: None,
193 locale: None,
194 updated_at: None,
195 _scope: PhantomData,
196 }
197 }
198
199 /// Set `auth_time` (Unix seconds) — when the End-User authentication
200 /// occurred. Always available regardless of `S` (auth_time is in
201 /// `BASE_CLAIMS`).
202 #[must_use]
203 pub fn with_auth_time(mut self, auth_time: i64) -> Self {
204 self.auth_time = Some(auth_time);
205 self
206 }
207
208 /// Set the Authentication Context Class Reference.
209 #[must_use]
210 pub fn with_acr(mut self, acr: impl Into<String>) -> Self {
211 self.acr = Some(acr.into());
212 self
213 }
214
215 /// Set the Authentication Methods References.
216 #[must_use]
217 pub fn with_amr(mut self, amr: Vec<String>) -> Self {
218 self.amr = Some(amr);
219 self
220 }
221
222 /// Set the Authorized Party. Required for multi-aud tokens (M69
223 /// verify-side gate); optional on single-aud.
224 #[must_use]
225 pub fn with_azp(mut self, azp: impl Into<String>) -> Self {
226 self.azp = Some(azp.into());
227 self
228 }
229}
230
231// ── Scope-bounded PII builder blocks ────────────────────────────────────
232//
233// Reading these top-down: each `impl<S: HasX>` block exposes exactly
234// the builder set OIDC §5.4 binds to scope `X`. A new claim inside an
235// existing scope is one builder addition here plus one accessor in
236// `claims.rs`; a new scope is a struct + trait impl in `scopes.rs` plus
237// one block here AND in `claims.rs`.
238//
239// Naming convention: `with_<wire_name>`. Wire name == method name suffix
240// so the audit reader greps `with_email` and finds both the issuance
241// builder and the wire-name string in `EMAIL_CLAIMS` without juggling.
242
243/// `email` scope — OIDC §5.4.
244impl<S: HasEmail> IssueRequest<S> {
245 #[must_use]
246 pub fn with_email(mut self, email: impl Into<String>) -> Self {
247 self.email = Some(email.into());
248 self
249 }
250
251 #[must_use]
252 pub fn with_email_verified(mut self, verified: bool) -> Self {
253 self.email_verified = Some(verified);
254 self
255 }
256}
257
258/// `profile` scope — OIDC §5.4 (name family + locale + updated_at).
259impl<S: HasProfile> IssueRequest<S> {
260 #[must_use]
261 pub fn with_name(mut self, name: impl Into<String>) -> Self {
262 self.name = Some(name.into());
263 self
264 }
265
266 #[must_use]
267 pub fn with_given_name(mut self, given_name: impl Into<String>) -> Self {
268 self.given_name = Some(given_name.into());
269 self
270 }
271
272 #[must_use]
273 pub fn with_family_name(mut self, family_name: impl Into<String>) -> Self {
274 self.family_name = Some(family_name.into());
275 self
276 }
277
278 #[must_use]
279 pub fn with_middle_name(mut self, middle_name: impl Into<String>) -> Self {
280 self.middle_name = Some(middle_name.into());
281 self
282 }
283
284 #[must_use]
285 pub fn with_nickname(mut self, nickname: impl Into<String>) -> Self {
286 self.nickname = Some(nickname.into());
287 self
288 }
289
290 #[must_use]
291 pub fn with_preferred_username(mut self, preferred_username: impl Into<String>) -> Self {
292 self.preferred_username = Some(preferred_username.into());
293 self
294 }
295
296 #[must_use]
297 pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
298 self.profile = Some(profile.into());
299 self
300 }
301
302 #[must_use]
303 pub fn with_picture(mut self, picture: impl Into<String>) -> Self {
304 self.picture = Some(picture.into());
305 self
306 }
307
308 #[must_use]
309 pub fn with_website(mut self, website: impl Into<String>) -> Self {
310 self.website = Some(website.into());
311 self
312 }
313
314 #[must_use]
315 pub fn with_gender(mut self, gender: impl Into<String>) -> Self {
316 self.gender = Some(gender.into());
317 self
318 }
319
320 #[must_use]
321 pub fn with_birthdate(mut self, birthdate: impl Into<String>) -> Self {
322 self.birthdate = Some(birthdate.into());
323 self
324 }
325
326 #[must_use]
327 pub fn with_zoneinfo(mut self, zoneinfo: impl Into<String>) -> Self {
328 self.zoneinfo = Some(zoneinfo.into());
329 self
330 }
331
332 #[must_use]
333 pub fn with_locale(mut self, locale: impl Into<String>) -> Self {
334 self.locale = Some(locale.into());
335 self
336 }
337
338 /// `updated_at` is Unix seconds (OIDC §5.1).
339 #[must_use]
340 pub fn with_updated_at(mut self, updated_at: i64) -> Self {
341 self.updated_at = Some(updated_at);
342 self
343 }
344}