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}