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 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}