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`, …) 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}