Skip to main content

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 jiff::Timestamp;
32use ppoppo_token::id_token::{
33    AddressClaim, Claims, HasAddress, HasEmail, HasPhone, HasProfile, Nonce, ScopeSet,
34    scopes::{
35        Email, EmailProfile, EmailProfilePhone, EmailProfilePhoneAddress, Openid, Profile,
36    },
37};
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/// `Timestamp`, `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: Timestamp,
154    iat: Timestamp,
155    nonce: String,
156    azp: Option<String>,
157    auth_time: Option<Timestamp>,
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<Timestamp>,
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: Timestamp,
213        iat: Timestamp,
214        nonce: String,
215        azp: Option<String>,
216        auth_time: Option<Timestamp>,
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: Timestamp,
266        iat: Timestamp,
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) -> Timestamp {
317        self.exp
318    }
319
320    /// Issued-at (`iat` claim) as a wall-clock instant.
321    #[must_use]
322    pub fn iat(&self) -> Timestamp {
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<Timestamp> {
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 (`Timestamp` 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    #[allow(clippy::expect_used)] // deliberate: the HasEmail bound proves Some — see doc above
393    pub fn email(&self) -> &str {
394        self.email
395            .as_deref()
396            .expect("HasEmail bound implies email Some — IdP drift if absent")
397    }
398
399    #[must_use]
400    pub fn email_verified(&self) -> Option<bool> {
401        self.email_verified
402    }
403}
404
405/// `profile` scope — OIDC §5.4 (name / locale / updated_at family).
406impl<S: HasProfile> IdAssertion<S> {
407    #[must_use]
408    pub fn name(&self) -> Option<&str> {
409        self.name.as_deref()
410    }
411
412    #[must_use]
413    pub fn given_name(&self) -> Option<&str> {
414        self.given_name.as_deref()
415    }
416
417    #[must_use]
418    pub fn family_name(&self) -> Option<&str> {
419        self.family_name.as_deref()
420    }
421
422    #[must_use]
423    pub fn middle_name(&self) -> Option<&str> {
424        self.middle_name.as_deref()
425    }
426
427    #[must_use]
428    pub fn nickname(&self) -> Option<&str> {
429        self.nickname.as_deref()
430    }
431
432    #[must_use]
433    pub fn preferred_username(&self) -> Option<&str> {
434        self.preferred_username.as_deref()
435    }
436
437    #[must_use]
438    pub fn profile(&self) -> Option<&str> {
439        self.profile.as_deref()
440    }
441
442    #[must_use]
443    pub fn picture(&self) -> Option<&str> {
444        self.picture.as_deref()
445    }
446
447    #[must_use]
448    pub fn website(&self) -> Option<&str> {
449        self.website.as_deref()
450    }
451
452    #[must_use]
453    pub fn gender(&self) -> Option<&str> {
454        self.gender.as_deref()
455    }
456
457    #[must_use]
458    pub fn birthdate(&self) -> Option<&str> {
459        self.birthdate.as_deref()
460    }
461
462    #[must_use]
463    pub fn zoneinfo(&self) -> Option<&str> {
464        self.zoneinfo.as_deref()
465    }
466
467    #[must_use]
468    pub fn locale(&self) -> Option<&str> {
469        self.locale.as_deref()
470    }
471
472    /// `updated_at` claim. Engine deserializes from `i64` Unix seconds
473    /// to [`Timestamp`] at construction; the SDK accessor surfaces
474    /// the wall-clock instant directly (consumer code does not need to
475    /// re-convert from epoch).
476    #[must_use]
477    pub fn updated_at(&self) -> Option<Timestamp> {
478        self.updated_at
479    }
480}
481
482/// `phone` scope — OIDC §5.4.
483impl<S: HasPhone> IdAssertion<S> {
484    #[must_use]
485    pub fn phone_number(&self) -> Option<&str> {
486        self.phone_number.as_deref()
487    }
488
489    #[must_use]
490    pub fn phone_number_verified(&self) -> Option<bool> {
491        self.phone_number_verified
492    }
493}
494
495/// `address` scope — OIDC §5.4 (single structured claim).
496impl<S: HasAddress> IdAssertion<S> {
497    #[must_use]
498    pub fn address(&self) -> Option<&Address> {
499        self.address.as_ref()
500    }
501}
502
503// ── Per-scope PII hydration trait ───────────────────────────────────────
504//
505// The conversion from engine [`Claims<S>`] to SDK [`IdAssertion<S>`]
506// can't read scope-bounded PII fields generically (Rust has no
507// specialization on stable). Each scope marker provides its own
508// hydration recipe via [`ScopePiiReader`]; the production verifier
509// dispatches via the trait at monomorphization. Adding a new scope is
510// one impl block here.
511
512/// Per-scope PII hydration. Implemented for each engine scope marker;
513/// the production [`super::PasIdTokenVerifier<S>`] uses
514/// `S::fill_pii(&claims, &mut assertion)` after building the base
515/// assertion to layer in scope-bounded fields.
516///
517/// **Why a sibling trait, not a method on [`ScopeSet`]**: engine's
518/// [`ScopeSet`] is sealed (only the engine adds variants). The SDK
519/// can't extend the engine trait, but it can define its own trait whose
520/// bound is `Self: ScopeSet` and provide impls per-scope by name.
521///
522/// **Bound rationale**:
523/// - `Sized`: helper functions take `&Claims<Self>` by reference.
524/// - `Send + Sync + 'static`: `PhantomData<S>` inside
525///   [`super::PasIdTokenVerifier<S>`] must be `Send + Sync` for
526///   `#[async_trait]` to produce a `Send + 'static` future.
527/// - `Clone + Debug + PartialEq + Eq`: lifted to make
528///   `#[derive(...)]` on [`IdAssertion<S>`] generate impls without
529///   per-impl boilerplate. Rust's conservative derive adds
530///   `S: Trait` bounds for each generic type parameter (even when only
531///   `PhantomData<S>` carries it), so the trait must promise the
532///   bounds. Every engine scope marker is a `Copy` unit struct
533///   `#[derive(Debug, Clone, Copy, PartialEq, Eq)]`, so all of these
534///   are auto-satisfied.
535pub trait ScopePiiReader:
536    ScopeSet + Sized + Clone + std::fmt::Debug + PartialEq + Eq + Send + Sync + 'static
537{
538    /// Read per-scope PII claims from the engine output into an SDK
539    /// assertion already populated with base fields.
540    fn fill_pii(claims: &Claims<Self>, assertion: &mut IdAssertion<Self>);
541}
542
543// Common scope-bounded helpers — composed into the per-scope impls
544// below. Each helper requires the matching marker bound, which makes
545// the engine's scope-bounded accessors reachable.
546
547fn fill_email<S: HasEmail>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
548    a.email = Some(claims.email().to_owned());
549    a.email_verified = claims.email_verified();
550}
551
552fn fill_profile<S: HasProfile>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
553    a.name = claims.name().map(str::to_owned);
554    a.given_name = claims.given_name().map(str::to_owned);
555    a.family_name = claims.family_name().map(str::to_owned);
556    a.middle_name = claims.middle_name().map(str::to_owned);
557    a.nickname = claims.nickname().map(str::to_owned);
558    a.preferred_username = claims.preferred_username().map(str::to_owned);
559    a.profile = claims.profile().map(str::to_owned);
560    a.picture = claims.picture().map(str::to_owned);
561    a.website = claims.website().map(str::to_owned);
562    a.gender = claims.gender().map(str::to_owned);
563    a.birthdate = claims.birthdate().map(str::to_owned);
564    a.zoneinfo = claims.zoneinfo().map(str::to_owned);
565    a.locale = claims.locale().map(str::to_owned);
566    a.updated_at = claims
567        .updated_at()
568        .and_then(|ts| Timestamp::from_second(ts).ok());
569}
570
571fn fill_phone<S: HasPhone>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
572    a.phone_number = claims.phone_number().map(str::to_owned);
573    a.phone_number_verified = claims.phone_number_verified();
574}
575
576fn fill_address<S: HasAddress>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
577    a.address = claims.address().map(Address::from);
578}
579
580impl ScopePiiReader for Openid {
581    fn fill_pii(_: &Claims<Self>, _: &mut IdAssertion<Self>) {}
582}
583
584impl ScopePiiReader for Email {
585    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
586        fill_email(claims, a);
587    }
588}
589
590impl ScopePiiReader for Profile {
591    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
592        fill_profile(claims, a);
593    }
594}
595
596impl ScopePiiReader for EmailProfile {
597    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
598        fill_email(claims, a);
599        fill_profile(claims, a);
600    }
601}
602
603impl ScopePiiReader for EmailProfilePhone {
604    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
605        fill_email(claims, a);
606        fill_profile(claims, a);
607        fill_phone(claims, a);
608    }
609}
610
611impl ScopePiiReader for EmailProfilePhoneAddress {
612    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
613        fill_email(claims, a);
614        fill_profile(claims, a);
615        fill_phone(claims, a);
616        fill_address(claims, a);
617    }
618}
619
620// ── Test-support PII builders (gated) ────────────────────────────────────
621//
622// These mirror the engine's `IssueRequest<S>` builder shape on the
623// SDK side: scope-bounded so a test cannot construct a `HasEmail`-only
624// `IdAssertion` with profile/phone PII set. The full builder catalog
625// (`with_given_name`, `with_locale`, etc.) is intentionally narrow —
626// boundary tests cover the *structural* assertion that scope-bounded
627// accessors return the populated values; engine `tests/id_token_round_trip.rs`
628// covers the per-claim deserialization fidelity. Add a builder here
629// only when a boundary test would otherwise be unable to exercise a
630// scope-bound accessor at all.
631
632#[cfg(any(test, feature = "test-support"))]
633impl<S: ScopeSet> IdAssertion<S> {
634    /// Test builder — populate `azp` (authorized party) for tests that
635    /// exercise the IdP-asserted claim accessors.
636    #[must_use]
637    pub fn with_azp(mut self, azp: impl Into<String>) -> Self {
638        self.azp = Some(azp.into());
639        self
640    }
641}
642
643#[cfg(any(test, feature = "test-support"))]
644impl<S: HasEmail> IdAssertion<S> {
645    /// Test builder — populate `email` (and optional `email_verified`).
646    #[must_use]
647    pub fn with_email(mut self, email: impl Into<String>, verified: Option<bool>) -> Self {
648        self.email = Some(email.into());
649        self.email_verified = verified;
650        self
651    }
652}
653
654#[cfg(any(test, feature = "test-support"))]
655impl<S: HasProfile> IdAssertion<S> {
656    /// Test builder — populate `name`. Single-claim builder (boundary
657    /// test exercises the `name()` accessor; engine round-trip tests
658    /// cover the rest of the profile family).
659    #[must_use]
660    pub fn with_name(mut self, name: impl Into<String>) -> Self {
661        self.name = Some(name.into());
662        self
663    }
664}
665
666#[cfg(any(test, feature = "test-support"))]
667impl<S: HasPhone> IdAssertion<S> {
668    /// Test builder — populate `phone_number` (and optional
669    /// `phone_number_verified`).
670    #[must_use]
671    pub fn with_phone_number(
672        mut self,
673        phone: impl Into<String>,
674        verified: Option<bool>,
675    ) -> Self {
676        self.phone_number = Some(phone.into());
677        self.phone_number_verified = verified;
678        self
679    }
680}
681
682#[cfg(any(test, feature = "test-support"))]
683impl<S: HasAddress> IdAssertion<S> {
684    /// Test builder — populate `address`.
685    #[must_use]
686    pub fn with_address(mut self, address: Address) -> Self {
687        self.address = Some(address);
688        self
689    }
690}
691
692// ── IdVerifyError ────────────────────────────────────────────────────────
693
694/// id_token verification failure surface.
695///
696/// One variant per logical failure class; mirrors
697/// [`VerifyError`](crate::TokenVerifyError) for access tokens but
698/// adds OIDC-specific rows (M66-M73 + M29-mirror `CatMismatch`). The
699/// PAS-engine variants reflect the boundary contract: audit logs map
700/// them 1:1 to engine [`ppoppo_token::id_token::AuthError`] rows.
701/// Adapter-side variants (`InvalidFormat`) cover failures upstream of
702/// engine entry.
703#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
704pub enum IdVerifyError {
705    // ── Adapter-side rejection (upstream of engine entry) ───────────────
706    /// Token did not parse as a JWS Compact serialization.
707    #[error("invalid id_token format")]
708    InvalidFormat,
709
710    // ── JOSE-layer (shared with access_token) ───────────────────────────
711    /// Cryptographic signature verification failed.
712    #[error("signature verification failed")]
713    SignatureInvalid,
714
715    /// `exp` claim is in the past.
716    #[error("id_token expired")]
717    Expired,
718
719    /// `iss` did not match the verifier's expected issuer.
720    #[error("issuer invalid")]
721    IssuerInvalid,
722
723    /// `aud` did not match the verifier's expected audience.
724    #[error("audience invalid")]
725    AudienceInvalid,
726
727    /// A required claim was absent or malformed (e.g. `sub` present but not
728    /// a parseable ULID, `exp`/`iat` outside representable Unix-timestamp
729    /// range). The variant name is retained for API stability — the error
730    /// message reflects the dual-purpose semantic.
731    #[error("missing or malformed required claim: {0}")]
732    MissingClaim(&'static str),
733
734    /// JWKS fetch failed and there is no usable cached snapshot.
735    #[error("keyset unavailable")]
736    KeysetUnavailable,
737
738    // ── OIDC-specific (M66-M73 + M29-mirror) ────────────────────────────
739    /// M66 — `nonce` claim is absent from the id_token payload.
740    #[error("M66: nonce claim absent from payload")]
741    NonceMissing,
742
743    /// M66 — payload `nonce` is present but does not match the
744    /// `expected_nonce` the RP stored at the auth-request boundary.
745    #[error("M66: nonce does not match expected value")]
746    NonceMismatch,
747
748    /// M67 — `at_hash` claim absent from payload while the verifier was
749    /// configured with an expected access_token binding (hybrid +
750    /// implicit flows).
751    #[error("M67: at_hash claim absent from payload")]
752    AtHashMissing,
753
754    /// M67 — payload `at_hash` is present but does not match the
755    /// expected access_token binding.
756    #[error("M67: at_hash does not match expected access_token binding")]
757    AtHashMismatch,
758
759    /// M68 — `c_hash` claim absent while the verifier was configured
760    /// with an expected authorization-code binding (hybrid flow).
761    #[error("M68: c_hash claim absent from payload")]
762    CHashMissing,
763
764    /// M68 — payload `c_hash` is present but does not match the
765    /// expected authorization-code binding.
766    #[error("M68: c_hash does not match expected authorization_code binding")]
767    CHashMismatch,
768
769    /// M69 — `azp` claim absent on multi-audience id_token.
770    #[error("M69: azp claim absent on multi-audience id_token")]
771    AzpMissing,
772
773    /// M69 — payload `azp` does not equal the RP's client_id.
774    #[error("M69: azp does not match expected client_id")]
775    AzpMismatch,
776
777    /// M70 — `auth_time` claim absent while the verifier was configured
778    /// with a `max_age` window.
779    #[error("M70: auth_time claim absent while max_age is configured")]
780    AuthTimeMissing,
781
782    /// M70 — `now - auth_time > max_age`. The user authenticated too
783    /// long ago for this RP's freshness policy.
784    #[error("M70: auth_time exceeds max_age window — re-authentication required")]
785    AuthTimeStale,
786
787    /// M71 — `acr` claim absent while the verifier was configured with
788    /// `acr_values`.
789    #[error("M71: acr claim absent while acr_values is configured")]
790    AcrMissing,
791
792    /// M71 — payload `acr` not in the RP's `acr_values` allowlist.
793    #[error("M71: acr value not in configured acr_values allowlist")]
794    AcrNotAllowed,
795
796    /// M72 — id_token payload contains a claim outside the per-scope
797    /// allowlist. Carries the offending name for audit log
798    /// disambiguation (forgery vs issuer drift).
799    #[error("M72: unknown id_token claim '{0}' outside per-scope allowlist")]
800    UnknownClaim(String),
801
802    /// M29-mirror — id_token payload carries a `cat` claim whose value
803    /// is not `"id"`. Refuses access_token shapes presented to the
804    /// id_token verifier (the symmetric counterpart to M73 on the
805    /// access-token side). Carries the offending value.
806    #[error("M29-mirror: id_token cat must be 'id', got '{0}'")]
807    CatMismatch(String),
808
809    // ── Catch-all (preserves engine M-row identifier via Display) ──────
810    /// Catch-all for engine variants that don't map to a structural
811    /// SDK rejection. Carries the engine's [`AuthError`] Display so the
812    /// audit log retains the precise M-code.
813    #[error("verification failed: {0}")]
814    Other(String),
815}
816
817#[cfg(test)]
818mod tests {
819    //! Port-level invariant tests. The boundary tests (live in
820    //! `tests/id_token_verifier_boundary.rs`) exercise the production
821    //! adapter; the unit tests below verify the static shape of the
822    //! port surface.
823    use super::*;
824    use ppoppo_token::id_token::scopes;
825    use ulid::Ulid;
826
827    fn fixture_sub() -> PpnumId {
828        PpnumId(
829            Ulid::from_string("01HK0000000000000000000001")
830                .expect("test ulid")
831        )
832    }
833
834    #[test]
835    fn for_test_constructor_yields_openid_assertion() {
836        let now = Timestamp::now();
837        let a: IdAssertion<scopes::Openid> = IdAssertion::for_test(
838            "accounts.ppoppo.com",
839            fixture_sub(),
840            vec!["rp-client".to_owned()],
841            now + jiff::SignedDuration::from_hours(1),
842            now,
843            "n-0S6_WzA2Mj",
844        );
845        assert_eq!(a.iss(), "accounts.ppoppo.com");
846        assert_eq!(a.aud(), &["rp-client".to_owned()]);
847        assert_eq!(a.nonce(), "n-0S6_WzA2Mj");
848        assert!(a.azp().is_none());
849        assert!(a.auth_time().is_none());
850    }
851
852    #[test]
853    fn with_email_builder_populates_pii() {
854        let now = Timestamp::now();
855        let a: IdAssertion<scopes::Email> = IdAssertion::for_test(
856            "accounts.ppoppo.com",
857            fixture_sub(),
858            vec!["rp-client".to_owned()],
859            now + jiff::SignedDuration::from_hours(1),
860            now,
861            "nonce",
862        )
863        .with_email("user@example.com", Some(true));
864        assert_eq!(a.email(), "user@example.com");
865        assert_eq!(a.email_verified(), Some(true));
866    }
867
868    #[test]
869    fn id_verify_error_display_preserves_m_codes() {
870        // Audit log relies on Display to surface the M-row identifier
871        // (caller routes structured fields, but Display is the human-
872        // readable axis for grep over CloudLogging output).
873        assert_eq!(
874            format!("{}", IdVerifyError::NonceMismatch),
875            "M66: nonce does not match expected value"
876        );
877        assert_eq!(
878            format!("{}", IdVerifyError::CatMismatch("access".to_owned())),
879            "M29-mirror: id_token cat must be 'id', got 'access'"
880        );
881        assert_eq!(
882            format!("{}", IdVerifyError::AzpMismatch),
883            "M69: azp does not match expected client_id"
884        );
885    }
886
887    /// Compile-time guard: an `Arc<dyn IdTokenVerifier<S>>` is the
888    /// runtime shape consumer middleware injects. If the trait's
889    /// object-safety regresses, this won't compile.
890    #[allow(dead_code)]
891    fn dyn_object_safety<S: ScopeSet>() {
892        fn _accept<S: ScopeSet>(_: std::sync::Arc<dyn IdTokenVerifier<S>>) {}
893    }
894
895    /// Compile-time check: every published scope marker has a
896    /// [`ScopePiiReader`] impl. If a future scope is added to the
897    /// re-exports without an impl, this fails to compile.
898    #[allow(dead_code)]
899    fn scope_pii_reader_impls_exist() {
900        fn _accept<S: ScopePiiReader>() {}
901        _accept::<Openid>();
902        _accept::<Email>();
903        _accept::<Profile>();
904        _accept::<EmailProfile>();
905        _accept::<EmailProfilePhone>();
906        _accept::<EmailProfilePhoneAddress>();
907    }
908}