Skip to main content

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`, `phone_number`, `address`, …) 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`/`HasPhone`/`HasAddress`").
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::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::claims::AddressClaim;
48use super::scopes::{HasAddress, HasEmail, HasPhone, HasProfile, ScopeSet};
49
50/// OIDC id_token issuance payload, phantom-typed by `S: ScopeSet`.
51///
52/// The `S` parameter witnesses the OAuth scope the issuer is honoring.
53/// PII builders (`with_email`, `with_name`, …) are gated by the matching
54/// marker traits (`HasEmail`, `HasProfile`, `HasPhone`, `HasAddress`),
55/// making "wrong scope, wrong field" a compile error.
56///
57/// ── compile_fail evidence (D2 emission half) ────────────────────────────
58///
59/// The standing acceptance fixture is the doc-test below; `cargo test
60/// --doc -p ppoppo-token` runs it and asserts the snippet fails to
61/// compile (E0599 — method not found).
62///
63/// ```compile_fail,E0599
64/// use std::time::Duration;
65/// use ppoppo_token::id_token::{IssueRequest, scopes::Openid};
66///
67/// fn _compile_fail() {
68///     let _ = IssueRequest::<Openid>::new(
69///         "01HSAB00000000000000000000",
70///         Duration::from_secs(600),
71///     )
72///     .with_email("u@example.com"); // ERROR: with_email requires S: HasEmail
73/// }
74/// ```
75///
76/// Granting the `email` scope at issuance time satisfies the bound:
77///
78/// ```ignore
79/// use std::time::Duration;
80/// use ppoppo_token::id_token::{IssueRequest, scopes::Email};
81///
82/// fn _compiles() {
83///     let _ = IssueRequest::<Email>::new(
84///         "01HSAB00000000000000000000",
85///         Duration::from_secs(600),
86///     )
87///     .with_email("u@example.com");
88/// }
89/// ```
90#[derive(Debug, Clone)]
91pub struct IssueRequest<S: ScopeSet> {
92    // ── Core principal data (always present) ──────────────────────────────
93    /// `sub` — the principal the id_token is about (RFC 7519 §4.1.2,
94    /// OIDC Core §2). PAS-issued tokens carry `ppnum_id` (ULID); never
95    /// empty.
96    pub sub: String,
97
98    /// Time-to-live from now. The engine computes `exp = iat + ttl` and
99    /// emits both. Per-profile cap is per-deployment; the engine may
100    /// enforce upper bounds in a future row (analogous to access-token
101    /// M19).
102    pub ttl: Duration,
103
104    // Note: no `jti` field. OIDC Core §2 lists jti as neither required
105    // nor recommended for id_tokens (replay defense is the nonce path),
106    // and `Claims<S>` on the verify side carries no `jti` accessor —
107    // adding one to `IssueRequest<S>` and emitting it on the wire would
108    // be an asymmetry the verifier never reads, and would force "jti"
109    // into `BASE_CLAIMS` for no semantic gain. Access-token's
110    // `IssueRequest::jti` exists because RFC 9068 §2.2.2 requires it on
111    // the access-token wire; this profile diverges deliberately.
112
113    // ── IdP-asserted claims (Phase 10.10) ────────────────────────────────
114    /// `auth_time` — when the End-User authentication occurred (Unix
115    /// seconds). The verify-side M70 gate (Phase 10.6) compares this
116    /// against `now - max_age`; the issuer-side just emits what the IdP
117    /// witnessed. Required when the RP requested `max_age` in the auth
118    /// request — but that contract is between RP and IdP at the
119    /// app-protocol level, not the engine; emitting whenever the IdP
120    /// has a value is the safe default.
121    pub auth_time: Option<i64>,
122
123    /// `acr` — Authentication Context Class Reference (OIDC §2). The
124    /// verify-side M71 gate (Phase 10.7) refuses tokens whose acr is
125    /// not in `cfg.acr_values`. Emit a value when the IdP can attest to
126    /// a specific authentication context; absence collapses to "RP has
127    /// no acr policy or IdP cannot assert one".
128    pub acr: Option<String>,
129
130    /// `amr` — Authentication Methods References (e.g. `["pwd", "mfa"]`,
131    /// OIDC §2). Surfaced as data on the verify side; no gate. Emit
132    /// whenever the IdP knows the methods; absence is admitted.
133    pub amr: Option<Vec<String>>,
134
135    /// `azp` — Authorized Party (OIDC §2). The verify-side M69 gate
136    /// (Phase 10.5) requires `azp == client_id` whenever it's present
137    /// AND requires presence on multi-aud tokens. Issue side: set on
138    /// every multi-aud token; optional on single-aud (the §2 guidance
139    /// is silent on single-aud).
140    pub azp: Option<String>,
141
142    // ── PII — gated by scope-bounded builder blocks below ─────────────────
143    pub(crate) email: Option<String>,
144    pub(crate) email_verified: Option<bool>,
145
146    pub(crate) name: Option<String>,
147    pub(crate) given_name: Option<String>,
148    pub(crate) family_name: Option<String>,
149    pub(crate) middle_name: Option<String>,
150    pub(crate) nickname: Option<String>,
151    pub(crate) preferred_username: Option<String>,
152    pub(crate) profile: Option<String>,
153    pub(crate) picture: Option<String>,
154    pub(crate) website: Option<String>,
155    pub(crate) gender: Option<String>,
156    pub(crate) birthdate: Option<String>,
157    pub(crate) zoneinfo: Option<String>,
158    pub(crate) locale: Option<String>,
159    pub(crate) updated_at: Option<i64>,
160
161    pub(crate) phone_number: Option<String>,
162    pub(crate) phone_number_verified: Option<bool>,
163
164    pub(crate) address: Option<AddressClaim>,
165
166    pub(crate) _scope: PhantomData<S>,
167}
168
169impl<S: ScopeSet> IssueRequest<S> {
170    /// Construct a new request with the required core fields. All
171    /// optional fields default to absent; every emission is opt-in via a
172    /// `with_*` builder, so a caller who forgets to set a value cannot
173    /// accidentally emit a populated claim.
174    ///
175    /// The scope parameter is fixed at construction via turbofish:
176    /// `IssueRequest::<Email>::new("01H...", Duration::from_secs(600))`.
177    pub fn new(sub: impl Into<String>, ttl: Duration) -> Self {
178        Self {
179            sub: sub.into(),
180            ttl,
181            auth_time: None,
182            acr: None,
183            amr: None,
184            azp: None,
185            email: None,
186            email_verified: None,
187            name: None,
188            given_name: None,
189            family_name: None,
190            middle_name: None,
191            nickname: None,
192            preferred_username: None,
193            profile: None,
194            picture: None,
195            website: None,
196            gender: None,
197            birthdate: None,
198            zoneinfo: None,
199            locale: None,
200            updated_at: None,
201            phone_number: None,
202            phone_number_verified: None,
203            address: None,
204            _scope: PhantomData,
205        }
206    }
207
208    /// Set `auth_time` (Unix seconds) — when the End-User authentication
209    /// occurred. Always available regardless of `S` (auth_time is in
210    /// `BASE_CLAIMS`).
211    #[must_use]
212    pub fn with_auth_time(mut self, auth_time: i64) -> Self {
213        self.auth_time = Some(auth_time);
214        self
215    }
216
217    /// Set the Authentication Context Class Reference.
218    #[must_use]
219    pub fn with_acr(mut self, acr: impl Into<String>) -> Self {
220        self.acr = Some(acr.into());
221        self
222    }
223
224    /// Set the Authentication Methods References.
225    #[must_use]
226    pub fn with_amr(mut self, amr: Vec<String>) -> Self {
227        self.amr = Some(amr);
228        self
229    }
230
231    /// Set the Authorized Party. Required for multi-aud tokens (M69
232    /// verify-side gate); optional on single-aud.
233    #[must_use]
234    pub fn with_azp(mut self, azp: impl Into<String>) -> Self {
235        self.azp = Some(azp.into());
236        self
237    }
238}
239
240// ── Scope-bounded PII builder blocks ────────────────────────────────────
241//
242// Reading these top-down: each `impl<S: HasX>` block exposes exactly
243// the builder set OIDC §5.4 binds to scope `X`. A new claim inside an
244// existing scope is one builder addition here plus one accessor in
245// `claims.rs`; a new scope is a struct + trait impl in `scopes.rs` plus
246// one block here AND in `claims.rs`.
247//
248// Naming convention: `with_<wire_name>`. Wire name == method name suffix
249// so the audit reader greps `with_email` and finds both the issuance
250// builder and the wire-name string in `EMAIL_CLAIMS` without juggling.
251
252/// `email` scope — OIDC §5.4.
253impl<S: HasEmail> IssueRequest<S> {
254    #[must_use]
255    pub fn with_email(mut self, email: impl Into<String>) -> Self {
256        self.email = Some(email.into());
257        self
258    }
259
260    #[must_use]
261    pub fn with_email_verified(mut self, verified: bool) -> Self {
262        self.email_verified = Some(verified);
263        self
264    }
265}
266
267/// `profile` scope — OIDC §5.4 (name family + locale + updated_at).
268impl<S: HasProfile> IssueRequest<S> {
269    #[must_use]
270    pub fn with_name(mut self, name: impl Into<String>) -> Self {
271        self.name = Some(name.into());
272        self
273    }
274
275    #[must_use]
276    pub fn with_given_name(mut self, given_name: impl Into<String>) -> Self {
277        self.given_name = Some(given_name.into());
278        self
279    }
280
281    #[must_use]
282    pub fn with_family_name(mut self, family_name: impl Into<String>) -> Self {
283        self.family_name = Some(family_name.into());
284        self
285    }
286
287    #[must_use]
288    pub fn with_middle_name(mut self, middle_name: impl Into<String>) -> Self {
289        self.middle_name = Some(middle_name.into());
290        self
291    }
292
293    #[must_use]
294    pub fn with_nickname(mut self, nickname: impl Into<String>) -> Self {
295        self.nickname = Some(nickname.into());
296        self
297    }
298
299    #[must_use]
300    pub fn with_preferred_username(mut self, preferred_username: impl Into<String>) -> Self {
301        self.preferred_username = Some(preferred_username.into());
302        self
303    }
304
305    #[must_use]
306    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
307        self.profile = Some(profile.into());
308        self
309    }
310
311    #[must_use]
312    pub fn with_picture(mut self, picture: impl Into<String>) -> Self {
313        self.picture = Some(picture.into());
314        self
315    }
316
317    #[must_use]
318    pub fn with_website(mut self, website: impl Into<String>) -> Self {
319        self.website = Some(website.into());
320        self
321    }
322
323    #[must_use]
324    pub fn with_gender(mut self, gender: impl Into<String>) -> Self {
325        self.gender = Some(gender.into());
326        self
327    }
328
329    #[must_use]
330    pub fn with_birthdate(mut self, birthdate: impl Into<String>) -> Self {
331        self.birthdate = Some(birthdate.into());
332        self
333    }
334
335    #[must_use]
336    pub fn with_zoneinfo(mut self, zoneinfo: impl Into<String>) -> Self {
337        self.zoneinfo = Some(zoneinfo.into());
338        self
339    }
340
341    #[must_use]
342    pub fn with_locale(mut self, locale: impl Into<String>) -> Self {
343        self.locale = Some(locale.into());
344        self
345    }
346
347    /// `updated_at` is Unix seconds (OIDC §5.1).
348    #[must_use]
349    pub fn with_updated_at(mut self, updated_at: i64) -> Self {
350        self.updated_at = Some(updated_at);
351        self
352    }
353}
354
355/// `phone` scope — OIDC §5.4.
356impl<S: HasPhone> IssueRequest<S> {
357    #[must_use]
358    pub fn with_phone_number(mut self, phone_number: impl Into<String>) -> Self {
359        self.phone_number = Some(phone_number.into());
360        self
361    }
362
363    #[must_use]
364    pub fn with_phone_number_verified(mut self, verified: bool) -> Self {
365        self.phone_number_verified = Some(verified);
366        self
367    }
368}
369
370/// `address` scope — OIDC §5.4 (single structured claim).
371impl<S: HasAddress> IssueRequest<S> {
372    #[must_use]
373    pub fn with_address(mut self, address: AddressClaim) -> Self {
374        self.address = Some(address);
375        self
376    }
377}