pas_external/oidc/port.rs
1//! γ port — `IdTokenVerifier`, `IdAssertion`, `IdVerifyError`.
2//!
3//! The SDK's OIDC id_token verification surface, format-blind by design.
4//! Consumers receive an [`IdAssertion<S>`] that exposes typed accessors
5//! for the values they need (`sub`, `iss`, `aud`, `exp`, plus
6//! scope-bounded PII via the marker traits in
7//! [`ppoppo_token::id_token::scopes`]) without ever seeing the
8//! underlying JWT or the `jsonwebtoken` / `ppoppo_token` types. Swapping
9//! the production [`super::PasIdTokenVerifier<S>`] adapter for the
10//! in-memory test adapter
11//! ([`super::MemoryIdTokenVerifier<S>`](super::memory::MemoryIdTokenVerifier),
12//! gated behind `test-support`) requires zero consumer changes — the
13//! port is the contract.
14//!
15//! D-04 (locked γ, 2026-05-05): port-and-adapter SDK boundary; the
16//! engine becomes the only place that knows the OIDC wire format.
17//!
18//! ── Why a separate port from `BearerVerifier` ───────────────────────────
19//!
20//! `BearerVerifier::verify(&self, bearer_token: &str)` and
21//! `IdTokenVerifier::verify(&self, id_token: &str, expected_nonce: &Nonce)`
22//! are not interchangeable. Engine docs: id_tokens authenticate the user
23//! *to the RP*; access_tokens authorize the RP *to the resource server*
24//! (OIDC Core §1.2 / RFC 9068 §1). Folding the two into a single port
25//! would force every caller to disambiguate at the call site (Phase 6.1
26//! audit Finding 4 rationale, transposed).
27
28use std::marker::PhantomData;
29
30use async_trait::async_trait;
31use ppoppo_token::id_token::{
32 AddressClaim, Claims, HasAddress, HasEmail, HasPhone, HasProfile, Nonce, ScopeSet,
33 scopes::{
34 Email, EmailProfile, EmailProfilePhone, EmailProfilePhoneAddress, Openid, Profile,
35 },
36};
37use time::OffsetDateTime;
38
39use crate::types::PpnumId;
40
41/// Verification port for incoming OIDC id_tokens.
42///
43/// Implementations swap the cryptographic backend without altering the
44/// caller's surface. The production [`super::PasIdTokenVerifier<S>`]
45/// verifies PAS-issued id_tokens against a TTL-cached JWKS; the
46/// test-support [`super::MemoryIdTokenVerifier<S>`] returns canned
47/// [`IdAssertion<S>`] values keyed by the bare token string.
48///
49/// `verify` is async because the production adapter performs
50/// stale-on-failure JWKS refresh inside the verify path, and any future
51/// 3rd-party adapter is free to make HTTP calls. Caller middleware that
52/// needs synchronous semantics wraps the call in `tokio::block_on`; the
53/// port itself stays uniformly async.
54///
55/// **Per-request `expected_nonce`**: the RP mints a per-session nonce at
56/// the auth-request boundary and stores it bound to the user's browser
57/// session (cookie, etc). On callback, the same nonce is fed here as
58/// `&Nonce` (engine validates non-empty at construction). The verifier
59/// does NOT cache nonce — a single verifier instance handles many
60/// concurrent sessions, each with its own nonce.
61///
62/// `M67` / `M68` access_token / authorization_code bindings (hybrid +
63/// implicit flows) are *not* surfaced on this trait — they would force
64/// every caller to pass `Option<&str>` for two rarely-used parameters.
65/// When the first hybrid-flow consumer arrives, a sibling
66/// `verify_with_bindings(...)` method on [`super::PasIdTokenVerifier<S>`]
67/// (or a `VerifyRequest` builder) is the right shape; the trait stays
68/// minimal until then.
69#[async_trait]
70pub trait IdTokenVerifier<S: ScopeSet>: Send + Sync {
71 async fn verify(
72 &self,
73 id_token: &str,
74 expected_nonce: &Nonce,
75 ) -> Result<IdAssertion<S>, IdVerifyError>;
76}
77
78// ── Address — SDK-shaped mirror of engine's `AddressClaim` ──────────────
79//
80// β1 invariant: the engine type never crosses the SDK boundary. The
81// engine's `AddressClaim` carries six `Option<String>` fields; the SDK
82// mirrors that shape so a future engine-side change (added field,
83// renamed field) is an SDK-side mapping update, not a consumer-API break.
84
85/// OIDC Core 1.0 §5.1.1 — `address` is a structured claim, not a flat
86/// string. All fields optional; an issuer may emit any subset.
87///
88/// SDK-shaped mirror of [`ppoppo_token::id_token::AddressClaim`]; the
89/// engine type is intentionally not re-exported (γ port invariant).
90#[derive(Debug, Clone, PartialEq, Eq, Default)]
91pub struct Address {
92 pub formatted: Option<String>,
93 pub street_address: Option<String>,
94 pub locality: Option<String>,
95 pub region: Option<String>,
96 pub postal_code: Option<String>,
97 pub country: Option<String>,
98}
99
100impl From<&AddressClaim> for Address {
101 fn from(c: &AddressClaim) -> Self {
102 Self {
103 formatted: c.formatted.clone(),
104 street_address: c.street_address.clone(),
105 locality: c.locality.clone(),
106 region: c.region.clone(),
107 postal_code: c.postal_code.clone(),
108 country: c.country.clone(),
109 }
110 }
111}
112
113/// Verified id_token outcome, opaque to the underlying token format.
114///
115/// Internal storage holds SDK-shaped values (`PpnumId`,
116/// `OffsetDateTime`, `Vec<String>` for aud, [`Address`] for address, and
117/// `Option<String>` for the PII fields). No `into_inner` escape hatch by
118/// design (β1 invariant — same rationale as Phase 6.1 audit Finding 4
119/// for [`AuthSession`](crate::VerifiedClaims)): every claim consumer code
120/// might need is exposed as a typed accessor. If a future field is
121/// needed, add an accessor here before the consumer ships — never widen
122/// to raw claims.
123///
124/// **Scope-bounded PII**: the `email` / `email_verified` / `name` / etc.
125/// accessors live in `impl<S: HasX>` blocks below. A
126/// `IdAssertion<scopes::Openid>` carries no syntactic path to
127/// `.email()` — the call doesn't compile. M72 is structurally enforced
128/// at the SDK boundary just as it is in the engine.
129///
130/// **Compile_fail acceptance**:
131///
132/// ```compile_fail,E0599
133/// use pas_external::oidc::{IdAssertion, Openid};
134///
135/// fn _compile_fail(a: &IdAssertion<Openid>) -> &str {
136/// a.email() // ERROR: method `email` not in scope (requires HasEmail)
137/// }
138/// ```
139///
140/// Granting the `email` scope at construction time satisfies the bound:
141///
142/// ```ignore
143/// use pas_external::oidc::{IdAssertion, Email};
144///
145/// fn _compiles(a: &IdAssertion<Email>) -> &str { a.email() }
146/// ```
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct IdAssertion<S: ScopeSet> {
149 // ── Core (always present, per OIDC §2) ────────────────────────────────
150 iss: String,
151 sub: PpnumId,
152 aud: Vec<String>,
153 exp: OffsetDateTime,
154 iat: OffsetDateTime,
155 nonce: String,
156 azp: Option<String>,
157 auth_time: Option<OffsetDateTime>,
158 acr: Option<String>,
159 amr: Option<Vec<String>>,
160
161 // ── PII — gated by scope-bounded accessor methods below ───────────────
162 pub(crate) email: Option<String>,
163 pub(crate) email_verified: Option<bool>,
164
165 pub(crate) name: Option<String>,
166 pub(crate) given_name: Option<String>,
167 pub(crate) family_name: Option<String>,
168 pub(crate) middle_name: Option<String>,
169 pub(crate) nickname: Option<String>,
170 pub(crate) preferred_username: Option<String>,
171 pub(crate) profile: Option<String>,
172 pub(crate) picture: Option<String>,
173 pub(crate) website: Option<String>,
174 pub(crate) gender: Option<String>,
175 pub(crate) birthdate: Option<String>,
176 pub(crate) zoneinfo: Option<String>,
177 pub(crate) locale: Option<String>,
178 pub(crate) updated_at: Option<OffsetDateTime>,
179
180 pub(crate) phone_number: Option<String>,
181 pub(crate) phone_number_verified: Option<bool>,
182
183 pub(crate) address: Option<Address>,
184
185 pub(crate) _scope: PhantomData<S>,
186}
187
188impl<S: ScopeSet> IdAssertion<S> {
189 /// Build from typed components. SDK-internal —
190 /// [`super::PasIdTokenVerifier<S>`] constructs after engine
191 /// `verify::<S>` returns; [`super::MemoryIdTokenVerifier<S>`]
192 /// constructs in test setup. Marked `pub(crate)` so external
193 /// adapters cannot fabricate assertions outside the SDK's
194 /// verification path.
195 ///
196 /// PII fields default to `None`; per-scope hydration happens through
197 /// [`ScopePiiReader::fill_pii`] for production paths and
198 /// `for_test*` builders for test paths.
199 ///
200 /// `dead_code` allowed because under just `feature = "token"` (no
201 /// `well-known-fetch`, no `test-support`) only the `for_test`
202 /// constructor reaches it (and that one is `cfg`-gated). The
203 /// production [`PasIdTokenVerifier`](super::PasIdTokenVerifier)
204 /// adapter (Phase 10.11.C) is the third call site; until that
205 /// lands, the constructor exists for symmetry with
206 /// [`AuthSession::new`](crate::VerifiedClaims).
207 #[allow(clippy::too_many_arguments, dead_code)]
208 pub(crate) fn new_base(
209 iss: String,
210 sub: PpnumId,
211 aud: Vec<String>,
212 exp: OffsetDateTime,
213 iat: OffsetDateTime,
214 nonce: String,
215 azp: Option<String>,
216 auth_time: Option<OffsetDateTime>,
217 acr: Option<String>,
218 amr: Option<Vec<String>>,
219 ) -> Self {
220 Self {
221 iss,
222 sub,
223 aud,
224 exp,
225 iat,
226 nonce,
227 azp,
228 auth_time,
229 acr,
230 amr,
231 email: None,
232 email_verified: None,
233 name: None,
234 given_name: None,
235 family_name: None,
236 middle_name: None,
237 nickname: None,
238 preferred_username: None,
239 profile: None,
240 picture: None,
241 website: None,
242 gender: None,
243 birthdate: None,
244 zoneinfo: None,
245 locale: None,
246 updated_at: None,
247 phone_number: None,
248 phone_number_verified: None,
249 address: None,
250 _scope: PhantomData,
251 }
252 }
253
254 /// Test-support constructor — minimal openid-scope assertion. PII
255 /// fields default to `None`; scope-bounded `for_test_with_*`
256 /// builders (gated behind the relevant marker traits) layer PII on
257 /// top.
258 #[cfg(any(test, feature = "test-support"))]
259 #[allow(clippy::too_many_arguments)]
260 #[must_use]
261 pub fn for_test(
262 iss: impl Into<String>,
263 sub: PpnumId,
264 aud: Vec<String>,
265 exp: OffsetDateTime,
266 iat: OffsetDateTime,
267 nonce: impl Into<String>,
268 ) -> Self {
269 Self::new_base(
270 iss.into(),
271 sub,
272 aud,
273 exp,
274 iat,
275 nonce.into(),
276 None,
277 None,
278 None,
279 None,
280 )
281 }
282
283 // ── Always-on accessors (any S: ScopeSet) ─────────────────────────────
284
285 /// Issuer (`iss` claim). Stable identity of the OP that minted this
286 /// id_token. Consumer middleware verifies it equals the configured
287 /// expected issuer (e.g. `accounts.ppoppo.com`) — the engine has
288 /// already enforced this in [`IdTokenVerifier::verify`], so the
289 /// value is informational by the time it reaches consumer code.
290 #[must_use]
291 pub fn iss(&self) -> &str {
292 &self.iss
293 }
294
295 /// Subject identifier (`sub` claim). PAS issues ULIDs for human
296 /// users (mirror of [`AuthSession::ppnum_id`](crate::VerifiedClaims)).
297 /// Trust decisions key off this stable identifier; downstream
298 /// `ppnum` (digit-form) is display-only and not surfaced on
299 /// id_tokens (id_tokens carry only the OIDC-canonical `sub`).
300 #[must_use]
301 pub fn sub(&self) -> &PpnumId {
302 &self.sub
303 }
304
305 /// Audience (`aud` claim). OIDC Core §2 permits a string OR an
306 /// array; the engine normalizes to a `Vec`. Multi-aud tokens MUST
307 /// also carry `azp` (M69) — already enforced by the engine.
308 #[must_use]
309 pub fn aud(&self) -> &[String] {
310 &self.aud
311 }
312
313 /// Expiry (`exp` claim) as a wall-clock instant. The engine has
314 /// already enforced expiry; this value is informational.
315 #[must_use]
316 pub fn exp(&self) -> OffsetDateTime {
317 self.exp
318 }
319
320 /// Issued-at (`iat` claim) as a wall-clock instant.
321 #[must_use]
322 pub fn iat(&self) -> OffsetDateTime {
323 self.iat
324 }
325
326 /// Per-session nonce (`nonce` claim). The engine has already
327 /// matched this against the RP-stored `expected_nonce` (M66); this
328 /// accessor exposes the on-wire value for consumer-side echo /
329 /// audit logging.
330 #[must_use]
331 pub fn nonce(&self) -> &str {
332 &self.nonce
333 }
334
335 /// Authorized party (`azp` claim, OIDC §2). Present whenever the
336 /// IdP asserts a multi-aud or sibling-client scenario; equals the
337 /// RP's `client_id` when present (M69 enforced engine-side).
338 #[must_use]
339 pub fn azp(&self) -> Option<&str> {
340 self.azp.as_deref()
341 }
342
343 /// Authentication time (`auth_time` claim). When the RP configured
344 /// `max_age`, the engine has already enforced
345 /// `now - auth_time <= max_age` (M70); the accessor exposes the
346 /// raw value for consumer-side step-up logic.
347 #[must_use]
348 pub fn auth_time(&self) -> Option<OffsetDateTime> {
349 self.auth_time
350 }
351
352 /// Authentication Context Class Reference (`acr` claim, OIDC §2).
353 /// When the RP configured `acr_values`, the engine has already
354 /// enforced membership (M71).
355 #[must_use]
356 pub fn acr(&self) -> Option<&str> {
357 self.acr.as_deref()
358 }
359
360 /// Authentication Methods References (`amr` claim, e.g. `["pwd",
361 /// "mfa"]`). OIDC §2 — informational, no engine-side enforcement.
362 #[must_use]
363 pub fn amr(&self) -> Option<&[String]> {
364 self.amr.as_deref()
365 }
366}
367
368// ── Scope-bounded accessor blocks ───────────────────────────────────────
369//
370// Reading these top-down: each `impl<S: HasX>` block exposes exactly
371// the field set OIDC §5.4 binds to scope `X`. Mirror of
372// `ppoppo_token::id_token::Claims<S>` accessor catalog, with SDK-shaped
373// types (`OffsetDateTime` for `updated_at`, [`Address`] for `address`).
374//
375// Adding a new claim inside a scope is one accessor here (and one
376// hydration line in the matching [`fill_*`] helper below); adding a new
377// scope is a re-export in `oidc/mod.rs` plus one more
378// [`ScopePiiReader`] impl.
379
380/// `email` scope — OIDC §5.4.
381impl<S: HasEmail> IdAssertion<S> {
382 /// `email` is REQUIRED if the issuer emits the email scope at all
383 /// (OIDC §5.4). Engine deserialization populates `Some(_)` when the
384 /// wire contains the claim; the accessor unwraps via `expect()`
385 /// because reaching this method bound (`S: HasEmail`) already
386 /// proves the IdP honored the scope. A missing email on a
387 /// `HasEmail` token is an issuer drift, surfaced as a panic so the
388 /// regression is loud — *if* this path is reachable in production.
389 /// Engine Phase 10.8 (M72) verify-time rejection makes the panic
390 /// structurally unreachable; the SDK gets the fix transitively.
391 #[must_use]
392 pub fn email(&self) -> &str {
393 self.email
394 .as_deref()
395 .expect("HasEmail bound implies email Some — IdP drift if absent")
396 }
397
398 #[must_use]
399 pub fn email_verified(&self) -> Option<bool> {
400 self.email_verified
401 }
402}
403
404/// `profile` scope — OIDC §5.4 (name / locale / updated_at family).
405impl<S: HasProfile> IdAssertion<S> {
406 #[must_use]
407 pub fn name(&self) -> Option<&str> {
408 self.name.as_deref()
409 }
410
411 #[must_use]
412 pub fn given_name(&self) -> Option<&str> {
413 self.given_name.as_deref()
414 }
415
416 #[must_use]
417 pub fn family_name(&self) -> Option<&str> {
418 self.family_name.as_deref()
419 }
420
421 #[must_use]
422 pub fn middle_name(&self) -> Option<&str> {
423 self.middle_name.as_deref()
424 }
425
426 #[must_use]
427 pub fn nickname(&self) -> Option<&str> {
428 self.nickname.as_deref()
429 }
430
431 #[must_use]
432 pub fn preferred_username(&self) -> Option<&str> {
433 self.preferred_username.as_deref()
434 }
435
436 #[must_use]
437 pub fn profile(&self) -> Option<&str> {
438 self.profile.as_deref()
439 }
440
441 #[must_use]
442 pub fn picture(&self) -> Option<&str> {
443 self.picture.as_deref()
444 }
445
446 #[must_use]
447 pub fn website(&self) -> Option<&str> {
448 self.website.as_deref()
449 }
450
451 #[must_use]
452 pub fn gender(&self) -> Option<&str> {
453 self.gender.as_deref()
454 }
455
456 #[must_use]
457 pub fn birthdate(&self) -> Option<&str> {
458 self.birthdate.as_deref()
459 }
460
461 #[must_use]
462 pub fn zoneinfo(&self) -> Option<&str> {
463 self.zoneinfo.as_deref()
464 }
465
466 #[must_use]
467 pub fn locale(&self) -> Option<&str> {
468 self.locale.as_deref()
469 }
470
471 /// `updated_at` claim. Engine deserializes from `i64` Unix seconds
472 /// to [`OffsetDateTime`] at construction; the SDK accessor surfaces
473 /// the wall-clock instant directly (consumer code does not need to
474 /// re-convert from epoch).
475 #[must_use]
476 pub fn updated_at(&self) -> Option<OffsetDateTime> {
477 self.updated_at
478 }
479}
480
481/// `phone` scope — OIDC §5.4.
482impl<S: HasPhone> IdAssertion<S> {
483 #[must_use]
484 pub fn phone_number(&self) -> Option<&str> {
485 self.phone_number.as_deref()
486 }
487
488 #[must_use]
489 pub fn phone_number_verified(&self) -> Option<bool> {
490 self.phone_number_verified
491 }
492}
493
494/// `address` scope — OIDC §5.4 (single structured claim).
495impl<S: HasAddress> IdAssertion<S> {
496 #[must_use]
497 pub fn address(&self) -> Option<&Address> {
498 self.address.as_ref()
499 }
500}
501
502// ── Per-scope PII hydration trait ───────────────────────────────────────
503//
504// The conversion from engine [`Claims<S>`] to SDK [`IdAssertion<S>`]
505// can't read scope-bounded PII fields generically (Rust has no
506// specialization on stable). Each scope marker provides its own
507// hydration recipe via [`ScopePiiReader`]; the production verifier
508// dispatches via the trait at monomorphization. Adding a new scope is
509// one impl block here.
510
511/// Per-scope PII hydration. Implemented for each engine scope marker;
512/// the production [`super::PasIdTokenVerifier<S>`] uses
513/// `S::fill_pii(&claims, &mut assertion)` after building the base
514/// assertion to layer in scope-bounded fields.
515///
516/// **Why a sibling trait, not a method on [`ScopeSet`]**: engine's
517/// [`ScopeSet`] is sealed (only the engine adds variants). The SDK
518/// can't extend the engine trait, but it can define its own trait whose
519/// bound is `Self: ScopeSet` and provide impls per-scope by name.
520///
521/// **Bound rationale**:
522/// - `Sized`: helper functions take `&Claims<Self>` by reference.
523/// - `Send + Sync + 'static`: `PhantomData<S>` inside
524/// [`super::PasIdTokenVerifier<S>`] must be `Send + Sync` for
525/// `#[async_trait]` to produce a `Send + 'static` future.
526/// - `Clone + Debug + PartialEq + Eq`: lifted to make
527/// `#[derive(...)]` on [`IdAssertion<S>`] generate impls without
528/// per-impl boilerplate. Rust's conservative derive adds
529/// `S: Trait` bounds for each generic type parameter (even when only
530/// `PhantomData<S>` carries it), so the trait must promise the
531/// bounds. Every engine scope marker is a `Copy` unit struct
532/// `#[derive(Debug, Clone, Copy, PartialEq, Eq)]`, so all of these
533/// are auto-satisfied.
534pub trait ScopePiiReader:
535 ScopeSet + Sized + Clone + std::fmt::Debug + PartialEq + Eq + Send + Sync + 'static
536{
537 /// Read per-scope PII claims from the engine output into an SDK
538 /// assertion already populated with base fields.
539 fn fill_pii(claims: &Claims<Self>, assertion: &mut IdAssertion<Self>);
540}
541
542// Common scope-bounded helpers — composed into the per-scope impls
543// below. Each helper requires the matching marker bound, which makes
544// the engine's scope-bounded accessors reachable.
545
546fn fill_email<S: HasEmail>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
547 a.email = Some(claims.email().to_owned());
548 a.email_verified = claims.email_verified();
549}
550
551fn fill_profile<S: HasProfile>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
552 a.name = claims.name().map(str::to_owned);
553 a.given_name = claims.given_name().map(str::to_owned);
554 a.family_name = claims.family_name().map(str::to_owned);
555 a.middle_name = claims.middle_name().map(str::to_owned);
556 a.nickname = claims.nickname().map(str::to_owned);
557 a.preferred_username = claims.preferred_username().map(str::to_owned);
558 a.profile = claims.profile().map(str::to_owned);
559 a.picture = claims.picture().map(str::to_owned);
560 a.website = claims.website().map(str::to_owned);
561 a.gender = claims.gender().map(str::to_owned);
562 a.birthdate = claims.birthdate().map(str::to_owned);
563 a.zoneinfo = claims.zoneinfo().map(str::to_owned);
564 a.locale = claims.locale().map(str::to_owned);
565 a.updated_at = claims
566 .updated_at()
567 .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok());
568}
569
570fn fill_phone<S: HasPhone>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
571 a.phone_number = claims.phone_number().map(str::to_owned);
572 a.phone_number_verified = claims.phone_number_verified();
573}
574
575fn fill_address<S: HasAddress>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
576 a.address = claims.address().map(Address::from);
577}
578
579impl ScopePiiReader for Openid {
580 fn fill_pii(_: &Claims<Self>, _: &mut IdAssertion<Self>) {}
581}
582
583impl ScopePiiReader for Email {
584 fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
585 fill_email(claims, a);
586 }
587}
588
589impl ScopePiiReader for Profile {
590 fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
591 fill_profile(claims, a);
592 }
593}
594
595impl ScopePiiReader for EmailProfile {
596 fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
597 fill_email(claims, a);
598 fill_profile(claims, a);
599 }
600}
601
602impl ScopePiiReader for EmailProfilePhone {
603 fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
604 fill_email(claims, a);
605 fill_profile(claims, a);
606 fill_phone(claims, a);
607 }
608}
609
610impl ScopePiiReader for EmailProfilePhoneAddress {
611 fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
612 fill_email(claims, a);
613 fill_profile(claims, a);
614 fill_phone(claims, a);
615 fill_address(claims, a);
616 }
617}
618
619// ── Test-support PII builders (gated) ────────────────────────────────────
620//
621// These mirror the engine's `IssueRequest<S>` builder shape on the
622// SDK side: scope-bounded so a test cannot construct a `HasEmail`-only
623// `IdAssertion` with profile/phone PII set. The full builder catalog
624// (`with_given_name`, `with_locale`, etc.) is intentionally narrow —
625// boundary tests cover the *structural* assertion that scope-bounded
626// accessors return the populated values; engine `tests/id_token_round_trip.rs`
627// covers the per-claim deserialization fidelity. Add a builder here
628// only when a boundary test would otherwise be unable to exercise a
629// scope-bound accessor at all.
630
631#[cfg(any(test, feature = "test-support"))]
632impl<S: ScopeSet> IdAssertion<S> {
633 /// Test builder — populate `azp` (authorized party) for tests that
634 /// exercise the IdP-asserted claim accessors.
635 #[must_use]
636 pub fn with_azp(mut self, azp: impl Into<String>) -> Self {
637 self.azp = Some(azp.into());
638 self
639 }
640}
641
642#[cfg(any(test, feature = "test-support"))]
643impl<S: HasEmail> IdAssertion<S> {
644 /// Test builder — populate `email` (and optional `email_verified`).
645 #[must_use]
646 pub fn with_email(mut self, email: impl Into<String>, verified: Option<bool>) -> Self {
647 self.email = Some(email.into());
648 self.email_verified = verified;
649 self
650 }
651}
652
653#[cfg(any(test, feature = "test-support"))]
654impl<S: HasProfile> IdAssertion<S> {
655 /// Test builder — populate `name`. Single-claim builder (boundary
656 /// test exercises the `name()` accessor; engine round-trip tests
657 /// cover the rest of the profile family).
658 #[must_use]
659 pub fn with_name(mut self, name: impl Into<String>) -> Self {
660 self.name = Some(name.into());
661 self
662 }
663}
664
665#[cfg(any(test, feature = "test-support"))]
666impl<S: HasPhone> IdAssertion<S> {
667 /// Test builder — populate `phone_number` (and optional
668 /// `phone_number_verified`).
669 #[must_use]
670 pub fn with_phone_number(
671 mut self,
672 phone: impl Into<String>,
673 verified: Option<bool>,
674 ) -> Self {
675 self.phone_number = Some(phone.into());
676 self.phone_number_verified = verified;
677 self
678 }
679}
680
681#[cfg(any(test, feature = "test-support"))]
682impl<S: HasAddress> IdAssertion<S> {
683 /// Test builder — populate `address`.
684 #[must_use]
685 pub fn with_address(mut self, address: Address) -> Self {
686 self.address = Some(address);
687 self
688 }
689}
690
691// ── IdVerifyError ────────────────────────────────────────────────────────
692
693/// id_token verification failure surface.
694///
695/// One variant per logical failure class; mirrors
696/// [`VerifyError`](crate::TokenVerifyError) for access tokens but
697/// adds OIDC-specific rows (M66-M73 + M29-mirror `CatMismatch`). The
698/// PAS-engine variants reflect the boundary contract: audit logs map
699/// them 1:1 to engine [`ppoppo_token::id_token::AuthError`] rows.
700/// Adapter-side variants (`InvalidFormat`) cover failures upstream of
701/// engine entry.
702#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
703pub enum IdVerifyError {
704 // ── Adapter-side rejection (upstream of engine entry) ───────────────
705 /// Token did not parse as a JWS Compact serialization.
706 #[error("invalid id_token format")]
707 InvalidFormat,
708
709 // ── JOSE-layer (shared with access_token) ───────────────────────────
710 /// Cryptographic signature verification failed.
711 #[error("signature verification failed")]
712 SignatureInvalid,
713
714 /// `exp` claim is in the past.
715 #[error("id_token expired")]
716 Expired,
717
718 /// `iss` did not match the verifier's expected issuer.
719 #[error("issuer invalid")]
720 IssuerInvalid,
721
722 /// `aud` did not match the verifier's expected audience.
723 #[error("audience invalid")]
724 AudienceInvalid,
725
726 /// A required claim was absent or malformed.
727 #[error("missing required claim: {0}")]
728 MissingClaim(&'static str),
729
730 /// JWKS fetch failed and there is no usable cached snapshot.
731 #[error("keyset unavailable")]
732 KeysetUnavailable,
733
734 // ── OIDC-specific (M66-M73 + M29-mirror) ────────────────────────────
735 /// M66 — `nonce` claim is absent from the id_token payload.
736 #[error("M66: nonce claim absent from payload")]
737 NonceMissing,
738
739 /// M66 — payload `nonce` is present but does not match the
740 /// `expected_nonce` the RP stored at the auth-request boundary.
741 #[error("M66: nonce does not match expected value")]
742 NonceMismatch,
743
744 /// M67 — `at_hash` claim absent from payload while the verifier was
745 /// configured with an expected access_token binding (hybrid +
746 /// implicit flows).
747 #[error("M67: at_hash claim absent from payload")]
748 AtHashMissing,
749
750 /// M67 — payload `at_hash` is present but does not match the
751 /// expected access_token binding.
752 #[error("M67: at_hash does not match expected access_token binding")]
753 AtHashMismatch,
754
755 /// M68 — `c_hash` claim absent while the verifier was configured
756 /// with an expected authorization-code binding (hybrid flow).
757 #[error("M68: c_hash claim absent from payload")]
758 CHashMissing,
759
760 /// M68 — payload `c_hash` is present but does not match the
761 /// expected authorization-code binding.
762 #[error("M68: c_hash does not match expected authorization_code binding")]
763 CHashMismatch,
764
765 /// M69 — `azp` claim absent on multi-audience id_token.
766 #[error("M69: azp claim absent on multi-audience id_token")]
767 AzpMissing,
768
769 /// M69 — payload `azp` does not equal the RP's client_id.
770 #[error("M69: azp does not match expected client_id")]
771 AzpMismatch,
772
773 /// M70 — `auth_time` claim absent while the verifier was configured
774 /// with a `max_age` window.
775 #[error("M70: auth_time claim absent while max_age is configured")]
776 AuthTimeMissing,
777
778 /// M70 — `now - auth_time > max_age`. The user authenticated too
779 /// long ago for this RP's freshness policy.
780 #[error("M70: auth_time exceeds max_age window — re-authentication required")]
781 AuthTimeStale,
782
783 /// M71 — `acr` claim absent while the verifier was configured with
784 /// `acr_values`.
785 #[error("M71: acr claim absent while acr_values is configured")]
786 AcrMissing,
787
788 /// M71 — payload `acr` not in the RP's `acr_values` allowlist.
789 #[error("M71: acr value not in configured acr_values allowlist")]
790 AcrNotAllowed,
791
792 /// M72 — id_token payload contains a claim outside the per-scope
793 /// allowlist. Carries the offending name for audit log
794 /// disambiguation (forgery vs issuer drift).
795 #[error("M72: unknown id_token claim '{0}' outside per-scope allowlist")]
796 UnknownClaim(String),
797
798 /// M29-mirror — id_token payload carries a `cat` claim whose value
799 /// is not `"id"`. Refuses access_token shapes presented to the
800 /// id_token verifier (the symmetric counterpart to M73 on the
801 /// access-token side). Carries the offending value.
802 #[error("M29-mirror: id_token cat must be 'id', got '{0}'")]
803 CatMismatch(String),
804
805 // ── Catch-all (preserves engine M-row identifier via Display) ──────
806 /// Catch-all for engine variants that don't map to a structural
807 /// SDK rejection. Carries the engine's [`AuthError`] Display so the
808 /// audit log retains the precise M-code.
809 #[error("verification failed: {0}")]
810 Other(String),
811}
812
813#[cfg(test)]
814mod tests {
815 //! Port-level invariant tests. The boundary tests (live in
816 //! `tests/id_token_verifier_boundary.rs`) exercise the production
817 //! adapter; the unit tests below verify the static shape of the
818 //! port surface.
819 use super::*;
820 use ppoppo_token::id_token::scopes;
821 use ulid::Ulid;
822
823 fn fixture_sub() -> PpnumId {
824 PpnumId(
825 Ulid::from_string("01HK0000000000000000000001")
826 .expect("test ulid")
827 )
828 }
829
830 #[test]
831 fn for_test_constructor_yields_openid_assertion() {
832 let now = OffsetDateTime::now_utc();
833 let a: IdAssertion<scopes::Openid> = IdAssertion::for_test(
834 "accounts.ppoppo.com",
835 fixture_sub(),
836 vec!["rp-client".to_owned()],
837 now + time::Duration::hours(1),
838 now,
839 "n-0S6_WzA2Mj",
840 );
841 assert_eq!(a.iss(), "accounts.ppoppo.com");
842 assert_eq!(a.aud(), &["rp-client".to_owned()]);
843 assert_eq!(a.nonce(), "n-0S6_WzA2Mj");
844 assert!(a.azp().is_none());
845 assert!(a.auth_time().is_none());
846 }
847
848 #[test]
849 fn with_email_builder_populates_pii() {
850 let now = OffsetDateTime::now_utc();
851 let a: IdAssertion<scopes::Email> = IdAssertion::for_test(
852 "accounts.ppoppo.com",
853 fixture_sub(),
854 vec!["rp-client".to_owned()],
855 now + time::Duration::hours(1),
856 now,
857 "nonce",
858 )
859 .with_email("user@example.com", Some(true));
860 assert_eq!(a.email(), "user@example.com");
861 assert_eq!(a.email_verified(), Some(true));
862 }
863
864 #[test]
865 fn id_verify_error_display_preserves_m_codes() {
866 // Audit log relies on Display to surface the M-row identifier
867 // (caller routes structured fields, but Display is the human-
868 // readable axis for grep over CloudLogging output).
869 assert_eq!(
870 format!("{}", IdVerifyError::NonceMismatch),
871 "M66: nonce does not match expected value"
872 );
873 assert_eq!(
874 format!("{}", IdVerifyError::CatMismatch("access".to_owned())),
875 "M29-mirror: id_token cat must be 'id', got 'access'"
876 );
877 assert_eq!(
878 format!("{}", IdVerifyError::AzpMismatch),
879 "M69: azp does not match expected client_id"
880 );
881 }
882
883 /// Compile-time guard: an `Arc<dyn IdTokenVerifier<S>>` is the
884 /// runtime shape consumer middleware injects. If the trait's
885 /// object-safety regresses, this won't compile.
886 #[allow(dead_code)]
887 fn dyn_object_safety<S: ScopeSet>() {
888 fn _accept<S: ScopeSet>(_: std::sync::Arc<dyn IdTokenVerifier<S>>) {}
889 }
890
891 /// Compile-time check: every published scope marker has a
892 /// [`ScopePiiReader`] impl. If a future scope is added to the
893 /// re-exports without an impl, this fails to compile.
894 #[allow(dead_code)]
895 fn scope_pii_reader_impls_exist() {
896 fn _accept<S: ScopePiiReader>() {}
897 _accept::<Openid>();
898 _accept::<Email>();
899 _accept::<Profile>();
900 _accept::<EmailProfile>();
901 _accept::<EmailProfilePhone>();
902 _accept::<EmailProfilePhoneAddress>();
903 }
904}