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}