pas-external 0.12.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
//! γ port — `IdTokenVerifier`, `IdAssertion`, `IdVerifyError`.
//!
//! The SDK's OIDC id_token verification surface, format-blind by design.
//! Consumers receive an [`IdAssertion<S>`] that exposes typed accessors
//! for the values they need (`sub`, `iss`, `aud`, `exp`, plus
//! scope-bounded PII via the marker traits in
//! [`ppoppo_token::id_token::scopes`]) without ever seeing the
//! underlying JWT or the `jsonwebtoken` / `ppoppo_token` types. Swapping
//! the production [`super::PasIdTokenVerifier<S>`] adapter for the
//! in-memory test adapter
//! ([`super::MemoryIdTokenVerifier<S>`](super::memory::MemoryIdTokenVerifier),
//! gated behind `test-support`) requires zero consumer changes — the
//! port is the contract.
//!
//! D-04 (locked γ, 2026-05-05): port-and-adapter SDK boundary; the
//! engine becomes the only place that knows the OIDC wire format.
//!
//! ── Why a separate port from `BearerVerifier` ───────────────────────────
//!
//! `BearerVerifier::verify(&self, bearer_token: &str)` and
//! `IdTokenVerifier::verify(&self, id_token: &str, expected_nonce: &Nonce)`
//! are not interchangeable. Engine docs: id_tokens authenticate the user
//! *to the RP*; access_tokens authorize the RP *to the resource server*
//! (OIDC Core §1.2 / RFC 9068 §1). Folding the two into a single port
//! would force every caller to disambiguate at the call site (Phase 6.1
//! audit Finding 4 rationale, transposed).

use std::marker::PhantomData;

use async_trait::async_trait;
use ppoppo_token::id_token::{
    AddressClaim, Claims, HasAddress, HasEmail, HasPhone, HasProfile, Nonce, ScopeSet,
    scopes::{
        Email, EmailProfile, EmailProfilePhone, EmailProfilePhoneAddress, Openid, Profile,
    },
};
use time::OffsetDateTime;

use crate::types::PpnumId;

/// Verification port for incoming OIDC id_tokens.
///
/// Implementations swap the cryptographic backend without altering the
/// caller's surface. The production [`super::PasIdTokenVerifier<S>`]
/// verifies PAS-issued id_tokens against a TTL-cached JWKS; the
/// test-support [`super::MemoryIdTokenVerifier<S>`] returns canned
/// [`IdAssertion<S>`] values keyed by the bare token string.
///
/// `verify` is async because the production adapter performs
/// stale-on-failure JWKS refresh inside the verify path, and any future
/// 3rd-party adapter is free to make HTTP calls. Caller middleware that
/// needs synchronous semantics wraps the call in `tokio::block_on`; the
/// port itself stays uniformly async.
///
/// **Per-request `expected_nonce`**: the RP mints a per-session nonce at
/// the auth-request boundary and stores it bound to the user's browser
/// session (cookie, etc). On callback, the same nonce is fed here as
/// `&Nonce` (engine validates non-empty at construction). The verifier
/// does NOT cache nonce — a single verifier instance handles many
/// concurrent sessions, each with its own nonce.
///
/// `M67` / `M68` access_token / authorization_code bindings (hybrid +
/// implicit flows) are *not* surfaced on this trait — they would force
/// every caller to pass `Option<&str>` for two rarely-used parameters.
/// When the first hybrid-flow consumer arrives, a sibling
/// `verify_with_bindings(...)` method on [`super::PasIdTokenVerifier<S>`]
/// (or a `VerifyRequest` builder) is the right shape; the trait stays
/// minimal until then.
#[async_trait]
pub trait IdTokenVerifier<S: ScopeSet>: Send + Sync {
    async fn verify(
        &self,
        id_token: &str,
        expected_nonce: &Nonce,
    ) -> Result<IdAssertion<S>, IdVerifyError>;
}

// ── Address — SDK-shaped mirror of engine's `AddressClaim` ──────────────
//
// β1 invariant: the engine type never crosses the SDK boundary. The
// engine's `AddressClaim` carries six `Option<String>` fields; the SDK
// mirrors that shape so a future engine-side change (added field,
// renamed field) is an SDK-side mapping update, not a consumer-API break.

/// OIDC Core 1.0 §5.1.1 — `address` is a structured claim, not a flat
/// string. All fields optional; an issuer may emit any subset.
///
/// SDK-shaped mirror of [`ppoppo_token::id_token::AddressClaim`]; the
/// engine type is intentionally not re-exported (γ port invariant).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Address {
    pub formatted: Option<String>,
    pub street_address: Option<String>,
    pub locality: Option<String>,
    pub region: Option<String>,
    pub postal_code: Option<String>,
    pub country: Option<String>,
}

impl From<&AddressClaim> for Address {
    fn from(c: &AddressClaim) -> Self {
        Self {
            formatted: c.formatted.clone(),
            street_address: c.street_address.clone(),
            locality: c.locality.clone(),
            region: c.region.clone(),
            postal_code: c.postal_code.clone(),
            country: c.country.clone(),
        }
    }
}

/// Verified id_token outcome, opaque to the underlying token format.
///
/// Internal storage holds SDK-shaped values (`PpnumId`,
/// `OffsetDateTime`, `Vec<String>` for aud, [`Address`] for address, and
/// `Option<String>` for the PII fields). No `into_inner` escape hatch by
/// design (β1 invariant — same rationale as Phase 6.1 audit Finding 4
/// for [`AuthSession`](crate::VerifiedClaims)): every claim consumer code
/// might need is exposed as a typed accessor. If a future field is
/// needed, add an accessor here before the consumer ships — never widen
/// to raw claims.
///
/// **Scope-bounded PII**: the `email` / `email_verified` / `name` / etc.
/// accessors live in `impl<S: HasX>` blocks below. A
/// `IdAssertion<scopes::Openid>` carries no syntactic path to
/// `.email()` — the call doesn't compile. M72 is structurally enforced
/// at the SDK boundary just as it is in the engine.
///
/// **Compile_fail acceptance**:
///
/// ```compile_fail,E0599
/// use pas_external::oidc::{IdAssertion, Openid};
///
/// fn _compile_fail(a: &IdAssertion<Openid>) -> &str {
///     a.email() // ERROR: method `email` not in scope (requires HasEmail)
/// }
/// ```
///
/// Granting the `email` scope at construction time satisfies the bound:
///
/// ```ignore
/// use pas_external::oidc::{IdAssertion, Email};
///
/// fn _compiles(a: &IdAssertion<Email>) -> &str { a.email() }
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IdAssertion<S: ScopeSet> {
    // ── Core (always present, per OIDC §2) ────────────────────────────────
    iss: String,
    sub: PpnumId,
    aud: Vec<String>,
    exp: OffsetDateTime,
    iat: OffsetDateTime,
    nonce: String,
    azp: Option<String>,
    auth_time: Option<OffsetDateTime>,
    acr: Option<String>,
    amr: Option<Vec<String>>,

    // ── PII — gated by scope-bounded accessor methods below ───────────────
    pub(crate) email: Option<String>,
    pub(crate) email_verified: Option<bool>,

    pub(crate) name: Option<String>,
    pub(crate) given_name: Option<String>,
    pub(crate) family_name: Option<String>,
    pub(crate) middle_name: Option<String>,
    pub(crate) nickname: Option<String>,
    pub(crate) preferred_username: Option<String>,
    pub(crate) profile: Option<String>,
    pub(crate) picture: Option<String>,
    pub(crate) website: Option<String>,
    pub(crate) gender: Option<String>,
    pub(crate) birthdate: Option<String>,
    pub(crate) zoneinfo: Option<String>,
    pub(crate) locale: Option<String>,
    pub(crate) updated_at: Option<OffsetDateTime>,

    pub(crate) phone_number: Option<String>,
    pub(crate) phone_number_verified: Option<bool>,

    pub(crate) address: Option<Address>,

    pub(crate) _scope: PhantomData<S>,
}

impl<S: ScopeSet> IdAssertion<S> {
    /// Build from typed components. SDK-internal —
    /// [`super::PasIdTokenVerifier<S>`] constructs after engine
    /// `verify::<S>` returns; [`super::MemoryIdTokenVerifier<S>`]
    /// constructs in test setup. Marked `pub(crate)` so external
    /// adapters cannot fabricate assertions outside the SDK's
    /// verification path.
    ///
    /// PII fields default to `None`; per-scope hydration happens through
    /// [`ScopePiiReader::fill_pii`] for production paths and
    /// `for_test*` builders for test paths.
    ///
    /// `dead_code` allowed because under just `feature = "token"` (no
    /// `well-known-fetch`, no `test-support`) only the `for_test`
    /// constructor reaches it (and that one is `cfg`-gated). The
    /// production [`PasIdTokenVerifier`](super::PasIdTokenVerifier)
    /// adapter (Phase 10.11.C) is the third call site; until that
    /// lands, the constructor exists for symmetry with
    /// [`AuthSession::new`](crate::VerifiedClaims).
    #[allow(clippy::too_many_arguments, dead_code)]
    pub(crate) fn new_base(
        iss: String,
        sub: PpnumId,
        aud: Vec<String>,
        exp: OffsetDateTime,
        iat: OffsetDateTime,
        nonce: String,
        azp: Option<String>,
        auth_time: Option<OffsetDateTime>,
        acr: Option<String>,
        amr: Option<Vec<String>>,
    ) -> Self {
        Self {
            iss,
            sub,
            aud,
            exp,
            iat,
            nonce,
            azp,
            auth_time,
            acr,
            amr,
            email: None,
            email_verified: None,
            name: None,
            given_name: None,
            family_name: None,
            middle_name: None,
            nickname: None,
            preferred_username: None,
            profile: None,
            picture: None,
            website: None,
            gender: None,
            birthdate: None,
            zoneinfo: None,
            locale: None,
            updated_at: None,
            phone_number: None,
            phone_number_verified: None,
            address: None,
            _scope: PhantomData,
        }
    }

    /// Test-support constructor — minimal openid-scope assertion. PII
    /// fields default to `None`; scope-bounded `for_test_with_*`
    /// builders (gated behind the relevant marker traits) layer PII on
    /// top.
    #[cfg(any(test, feature = "test-support"))]
    #[allow(clippy::too_many_arguments)]
    #[must_use]
    pub fn for_test(
        iss: impl Into<String>,
        sub: PpnumId,
        aud: Vec<String>,
        exp: OffsetDateTime,
        iat: OffsetDateTime,
        nonce: impl Into<String>,
    ) -> Self {
        Self::new_base(
            iss.into(),
            sub,
            aud,
            exp,
            iat,
            nonce.into(),
            None,
            None,
            None,
            None,
        )
    }

    // ── Always-on accessors (any S: ScopeSet) ─────────────────────────────

    /// Issuer (`iss` claim). Stable identity of the OP that minted this
    /// id_token. Consumer middleware verifies it equals the configured
    /// expected issuer (e.g. `accounts.ppoppo.com`) — the engine has
    /// already enforced this in [`IdTokenVerifier::verify`], so the
    /// value is informational by the time it reaches consumer code.
    #[must_use]
    pub fn iss(&self) -> &str {
        &self.iss
    }

    /// Subject identifier (`sub` claim). PAS issues ULIDs for human
    /// users (mirror of [`AuthSession::ppnum_id`](crate::VerifiedClaims)).
    /// Trust decisions key off this stable identifier; downstream
    /// `ppnum` (digit-form) is display-only and not surfaced on
    /// id_tokens (id_tokens carry only the OIDC-canonical `sub`).
    #[must_use]
    pub fn sub(&self) -> &PpnumId {
        &self.sub
    }

    /// Audience (`aud` claim). OIDC Core §2 permits a string OR an
    /// array; the engine normalizes to a `Vec`. Multi-aud tokens MUST
    /// also carry `azp` (M69) — already enforced by the engine.
    #[must_use]
    pub fn aud(&self) -> &[String] {
        &self.aud
    }

    /// Expiry (`exp` claim) as a wall-clock instant. The engine has
    /// already enforced expiry; this value is informational.
    #[must_use]
    pub fn exp(&self) -> OffsetDateTime {
        self.exp
    }

    /// Issued-at (`iat` claim) as a wall-clock instant.
    #[must_use]
    pub fn iat(&self) -> OffsetDateTime {
        self.iat
    }

    /// Per-session nonce (`nonce` claim). The engine has already
    /// matched this against the RP-stored `expected_nonce` (M66); this
    /// accessor exposes the on-wire value for consumer-side echo /
    /// audit logging.
    #[must_use]
    pub fn nonce(&self) -> &str {
        &self.nonce
    }

    /// Authorized party (`azp` claim, OIDC §2). Present whenever the
    /// IdP asserts a multi-aud or sibling-client scenario; equals the
    /// RP's `client_id` when present (M69 enforced engine-side).
    #[must_use]
    pub fn azp(&self) -> Option<&str> {
        self.azp.as_deref()
    }

    /// Authentication time (`auth_time` claim). When the RP configured
    /// `max_age`, the engine has already enforced
    /// `now - auth_time <= max_age` (M70); the accessor exposes the
    /// raw value for consumer-side step-up logic.
    #[must_use]
    pub fn auth_time(&self) -> Option<OffsetDateTime> {
        self.auth_time
    }

    /// Authentication Context Class Reference (`acr` claim, OIDC §2).
    /// When the RP configured `acr_values`, the engine has already
    /// enforced membership (M71).
    #[must_use]
    pub fn acr(&self) -> Option<&str> {
        self.acr.as_deref()
    }

    /// Authentication Methods References (`amr` claim, e.g. `["pwd",
    /// "mfa"]`). OIDC §2 — informational, no engine-side enforcement.
    #[must_use]
    pub fn amr(&self) -> Option<&[String]> {
        self.amr.as_deref()
    }
}

// ── Scope-bounded accessor blocks ───────────────────────────────────────
//
// Reading these top-down: each `impl<S: HasX>` block exposes exactly
// the field set OIDC §5.4 binds to scope `X`. Mirror of
// `ppoppo_token::id_token::Claims<S>` accessor catalog, with SDK-shaped
// types (`OffsetDateTime` for `updated_at`, [`Address`] for `address`).
//
// Adding a new claim inside a scope is one accessor here (and one
// hydration line in the matching [`fill_*`] helper below); adding a new
// scope is a re-export in `oidc/mod.rs` plus one more
// [`ScopePiiReader`] impl.

/// `email` scope — OIDC §5.4.
impl<S: HasEmail> IdAssertion<S> {
    /// `email` is REQUIRED if the issuer emits the email scope at all
    /// (OIDC §5.4). Engine deserialization populates `Some(_)` when the
    /// wire contains the claim; the accessor unwraps via `expect()`
    /// because reaching this method bound (`S: HasEmail`) already
    /// proves the IdP honored the scope. A missing email on a
    /// `HasEmail` token is an issuer drift, surfaced as a panic so the
    /// regression is loud — *if* this path is reachable in production.
    /// Engine Phase 10.8 (M72) verify-time rejection makes the panic
    /// structurally unreachable; the SDK gets the fix transitively.
    #[must_use]
    pub fn email(&self) -> &str {
        self.email
            .as_deref()
            .expect("HasEmail bound implies email Some — IdP drift if absent")
    }

    #[must_use]
    pub fn email_verified(&self) -> Option<bool> {
        self.email_verified
    }
}

/// `profile` scope — OIDC §5.4 (name / locale / updated_at family).
impl<S: HasProfile> IdAssertion<S> {
    #[must_use]
    pub fn name(&self) -> Option<&str> {
        self.name.as_deref()
    }

    #[must_use]
    pub fn given_name(&self) -> Option<&str> {
        self.given_name.as_deref()
    }

    #[must_use]
    pub fn family_name(&self) -> Option<&str> {
        self.family_name.as_deref()
    }

    #[must_use]
    pub fn middle_name(&self) -> Option<&str> {
        self.middle_name.as_deref()
    }

    #[must_use]
    pub fn nickname(&self) -> Option<&str> {
        self.nickname.as_deref()
    }

    #[must_use]
    pub fn preferred_username(&self) -> Option<&str> {
        self.preferred_username.as_deref()
    }

    #[must_use]
    pub fn profile(&self) -> Option<&str> {
        self.profile.as_deref()
    }

    #[must_use]
    pub fn picture(&self) -> Option<&str> {
        self.picture.as_deref()
    }

    #[must_use]
    pub fn website(&self) -> Option<&str> {
        self.website.as_deref()
    }

    #[must_use]
    pub fn gender(&self) -> Option<&str> {
        self.gender.as_deref()
    }

    #[must_use]
    pub fn birthdate(&self) -> Option<&str> {
        self.birthdate.as_deref()
    }

    #[must_use]
    pub fn zoneinfo(&self) -> Option<&str> {
        self.zoneinfo.as_deref()
    }

    #[must_use]
    pub fn locale(&self) -> Option<&str> {
        self.locale.as_deref()
    }

    /// `updated_at` claim. Engine deserializes from `i64` Unix seconds
    /// to [`OffsetDateTime`] at construction; the SDK accessor surfaces
    /// the wall-clock instant directly (consumer code does not need to
    /// re-convert from epoch).
    #[must_use]
    pub fn updated_at(&self) -> Option<OffsetDateTime> {
        self.updated_at
    }
}

/// `phone` scope — OIDC §5.4.
impl<S: HasPhone> IdAssertion<S> {
    #[must_use]
    pub fn phone_number(&self) -> Option<&str> {
        self.phone_number.as_deref()
    }

    #[must_use]
    pub fn phone_number_verified(&self) -> Option<bool> {
        self.phone_number_verified
    }
}

/// `address` scope — OIDC §5.4 (single structured claim).
impl<S: HasAddress> IdAssertion<S> {
    #[must_use]
    pub fn address(&self) -> Option<&Address> {
        self.address.as_ref()
    }
}

// ── Per-scope PII hydration trait ───────────────────────────────────────
//
// The conversion from engine [`Claims<S>`] to SDK [`IdAssertion<S>`]
// can't read scope-bounded PII fields generically (Rust has no
// specialization on stable). Each scope marker provides its own
// hydration recipe via [`ScopePiiReader`]; the production verifier
// dispatches via the trait at monomorphization. Adding a new scope is
// one impl block here.

/// Per-scope PII hydration. Implemented for each engine scope marker;
/// the production [`super::PasIdTokenVerifier<S>`] uses
/// `S::fill_pii(&claims, &mut assertion)` after building the base
/// assertion to layer in scope-bounded fields.
///
/// **Why a sibling trait, not a method on [`ScopeSet`]**: engine's
/// [`ScopeSet`] is sealed (only the engine adds variants). The SDK
/// can't extend the engine trait, but it can define its own trait whose
/// bound is `Self: ScopeSet` and provide impls per-scope by name.
///
/// **Bound rationale**:
/// - `Sized`: helper functions take `&Claims<Self>` by reference.
/// - `Send + Sync + 'static`: `PhantomData<S>` inside
///   [`super::PasIdTokenVerifier<S>`] must be `Send + Sync` for
///   `#[async_trait]` to produce a `Send + 'static` future.
/// - `Clone + Debug + PartialEq + Eq`: lifted to make
///   `#[derive(...)]` on [`IdAssertion<S>`] generate impls without
///   per-impl boilerplate. Rust's conservative derive adds
///   `S: Trait` bounds for each generic type parameter (even when only
///   `PhantomData<S>` carries it), so the trait must promise the
///   bounds. Every engine scope marker is a `Copy` unit struct
///   `#[derive(Debug, Clone, Copy, PartialEq, Eq)]`, so all of these
///   are auto-satisfied.
pub trait ScopePiiReader:
    ScopeSet + Sized + Clone + std::fmt::Debug + PartialEq + Eq + Send + Sync + 'static
{
    /// Read per-scope PII claims from the engine output into an SDK
    /// assertion already populated with base fields.
    fn fill_pii(claims: &Claims<Self>, assertion: &mut IdAssertion<Self>);
}

// Common scope-bounded helpers — composed into the per-scope impls
// below. Each helper requires the matching marker bound, which makes
// the engine's scope-bounded accessors reachable.

fn fill_email<S: HasEmail>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
    a.email = Some(claims.email().to_owned());
    a.email_verified = claims.email_verified();
}

fn fill_profile<S: HasProfile>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
    a.name = claims.name().map(str::to_owned);
    a.given_name = claims.given_name().map(str::to_owned);
    a.family_name = claims.family_name().map(str::to_owned);
    a.middle_name = claims.middle_name().map(str::to_owned);
    a.nickname = claims.nickname().map(str::to_owned);
    a.preferred_username = claims.preferred_username().map(str::to_owned);
    a.profile = claims.profile().map(str::to_owned);
    a.picture = claims.picture().map(str::to_owned);
    a.website = claims.website().map(str::to_owned);
    a.gender = claims.gender().map(str::to_owned);
    a.birthdate = claims.birthdate().map(str::to_owned);
    a.zoneinfo = claims.zoneinfo().map(str::to_owned);
    a.locale = claims.locale().map(str::to_owned);
    a.updated_at = claims
        .updated_at()
        .and_then(|ts| OffsetDateTime::from_unix_timestamp(ts).ok());
}

fn fill_phone<S: HasPhone>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
    a.phone_number = claims.phone_number().map(str::to_owned);
    a.phone_number_verified = claims.phone_number_verified();
}

fn fill_address<S: HasAddress>(claims: &Claims<S>, a: &mut IdAssertion<S>) {
    a.address = claims.address().map(Address::from);
}

impl ScopePiiReader for Openid {
    fn fill_pii(_: &Claims<Self>, _: &mut IdAssertion<Self>) {}
}

impl ScopePiiReader for Email {
    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
        fill_email(claims, a);
    }
}

impl ScopePiiReader for Profile {
    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
        fill_profile(claims, a);
    }
}

impl ScopePiiReader for EmailProfile {
    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
        fill_email(claims, a);
        fill_profile(claims, a);
    }
}

impl ScopePiiReader for EmailProfilePhone {
    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
        fill_email(claims, a);
        fill_profile(claims, a);
        fill_phone(claims, a);
    }
}

impl ScopePiiReader for EmailProfilePhoneAddress {
    fn fill_pii(claims: &Claims<Self>, a: &mut IdAssertion<Self>) {
        fill_email(claims, a);
        fill_profile(claims, a);
        fill_phone(claims, a);
        fill_address(claims, a);
    }
}

// ── Test-support PII builders (gated) ────────────────────────────────────
//
// These mirror the engine's `IssueRequest<S>` builder shape on the
// SDK side: scope-bounded so a test cannot construct a `HasEmail`-only
// `IdAssertion` with profile/phone PII set. The full builder catalog
// (`with_given_name`, `with_locale`, etc.) is intentionally narrow —
// boundary tests cover the *structural* assertion that scope-bounded
// accessors return the populated values; engine `tests/id_token_round_trip.rs`
// covers the per-claim deserialization fidelity. Add a builder here
// only when a boundary test would otherwise be unable to exercise a
// scope-bound accessor at all.

#[cfg(any(test, feature = "test-support"))]
impl<S: ScopeSet> IdAssertion<S> {
    /// Test builder — populate `azp` (authorized party) for tests that
    /// exercise the IdP-asserted claim accessors.
    #[must_use]
    pub fn with_azp(mut self, azp: impl Into<String>) -> Self {
        self.azp = Some(azp.into());
        self
    }
}

#[cfg(any(test, feature = "test-support"))]
impl<S: HasEmail> IdAssertion<S> {
    /// Test builder — populate `email` (and optional `email_verified`).
    #[must_use]
    pub fn with_email(mut self, email: impl Into<String>, verified: Option<bool>) -> Self {
        self.email = Some(email.into());
        self.email_verified = verified;
        self
    }
}

#[cfg(any(test, feature = "test-support"))]
impl<S: HasProfile> IdAssertion<S> {
    /// Test builder — populate `name`. Single-claim builder (boundary
    /// test exercises the `name()` accessor; engine round-trip tests
    /// cover the rest of the profile family).
    #[must_use]
    pub fn with_name(mut self, name: impl Into<String>) -> Self {
        self.name = Some(name.into());
        self
    }
}

#[cfg(any(test, feature = "test-support"))]
impl<S: HasPhone> IdAssertion<S> {
    /// Test builder — populate `phone_number` (and optional
    /// `phone_number_verified`).
    #[must_use]
    pub fn with_phone_number(
        mut self,
        phone: impl Into<String>,
        verified: Option<bool>,
    ) -> Self {
        self.phone_number = Some(phone.into());
        self.phone_number_verified = verified;
        self
    }
}

#[cfg(any(test, feature = "test-support"))]
impl<S: HasAddress> IdAssertion<S> {
    /// Test builder — populate `address`.
    #[must_use]
    pub fn with_address(mut self, address: Address) -> Self {
        self.address = Some(address);
        self
    }
}

// ── IdVerifyError ────────────────────────────────────────────────────────

/// id_token verification failure surface.
///
/// One variant per logical failure class; mirrors
/// [`VerifyError`](crate::TokenVerifyError) for access tokens but
/// adds OIDC-specific rows (M66-M73 + M29-mirror `CatMismatch`). The
/// PAS-engine variants reflect the boundary contract: audit logs map
/// them 1:1 to engine [`ppoppo_token::id_token::AuthError`] rows.
/// Adapter-side variants (`InvalidFormat`) cover failures upstream of
/// engine entry.
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum IdVerifyError {
    // ── Adapter-side rejection (upstream of engine entry) ───────────────
    /// Token did not parse as a JWS Compact serialization.
    #[error("invalid id_token format")]
    InvalidFormat,

    // ── JOSE-layer (shared with access_token) ───────────────────────────
    /// Cryptographic signature verification failed.
    #[error("signature verification failed")]
    SignatureInvalid,

    /// `exp` claim is in the past.
    #[error("id_token expired")]
    Expired,

    /// `iss` did not match the verifier's expected issuer.
    #[error("issuer invalid")]
    IssuerInvalid,

    /// `aud` did not match the verifier's expected audience.
    #[error("audience invalid")]
    AudienceInvalid,

    /// A required claim was absent or malformed.
    #[error("missing required claim: {0}")]
    MissingClaim(&'static str),

    /// JWKS fetch failed and there is no usable cached snapshot.
    #[error("keyset unavailable")]
    KeysetUnavailable,

    // ── OIDC-specific (M66-M73 + M29-mirror) ────────────────────────────
    /// M66 — `nonce` claim is absent from the id_token payload.
    #[error("M66: nonce claim absent from payload")]
    NonceMissing,

    /// M66 — payload `nonce` is present but does not match the
    /// `expected_nonce` the RP stored at the auth-request boundary.
    #[error("M66: nonce does not match expected value")]
    NonceMismatch,

    /// M67 — `at_hash` claim absent from payload while the verifier was
    /// configured with an expected access_token binding (hybrid +
    /// implicit flows).
    #[error("M67: at_hash claim absent from payload")]
    AtHashMissing,

    /// M67 — payload `at_hash` is present but does not match the
    /// expected access_token binding.
    #[error("M67: at_hash does not match expected access_token binding")]
    AtHashMismatch,

    /// M68 — `c_hash` claim absent while the verifier was configured
    /// with an expected authorization-code binding (hybrid flow).
    #[error("M68: c_hash claim absent from payload")]
    CHashMissing,

    /// M68 — payload `c_hash` is present but does not match the
    /// expected authorization-code binding.
    #[error("M68: c_hash does not match expected authorization_code binding")]
    CHashMismatch,

    /// M69 — `azp` claim absent on multi-audience id_token.
    #[error("M69: azp claim absent on multi-audience id_token")]
    AzpMissing,

    /// M69 — payload `azp` does not equal the RP's client_id.
    #[error("M69: azp does not match expected client_id")]
    AzpMismatch,

    /// M70 — `auth_time` claim absent while the verifier was configured
    /// with a `max_age` window.
    #[error("M70: auth_time claim absent while max_age is configured")]
    AuthTimeMissing,

    /// M70 — `now - auth_time > max_age`. The user authenticated too
    /// long ago for this RP's freshness policy.
    #[error("M70: auth_time exceeds max_age window — re-authentication required")]
    AuthTimeStale,

    /// M71 — `acr` claim absent while the verifier was configured with
    /// `acr_values`.
    #[error("M71: acr claim absent while acr_values is configured")]
    AcrMissing,

    /// M71 — payload `acr` not in the RP's `acr_values` allowlist.
    #[error("M71: acr value not in configured acr_values allowlist")]
    AcrNotAllowed,

    /// M72 — id_token payload contains a claim outside the per-scope
    /// allowlist. Carries the offending name for audit log
    /// disambiguation (forgery vs issuer drift).
    #[error("M72: unknown id_token claim '{0}' outside per-scope allowlist")]
    UnknownClaim(String),

    /// M29-mirror — id_token payload carries a `cat` claim whose value
    /// is not `"id"`. Refuses access_token shapes presented to the
    /// id_token verifier (the symmetric counterpart to M73 on the
    /// access-token side). Carries the offending value.
    #[error("M29-mirror: id_token cat must be 'id', got '{0}'")]
    CatMismatch(String),

    // ── Catch-all (preserves engine M-row identifier via Display) ──────
    /// Catch-all for engine variants that don't map to a structural
    /// SDK rejection. Carries the engine's [`AuthError`] Display so the
    /// audit log retains the precise M-code.
    #[error("verification failed: {0}")]
    Other(String),
}

#[cfg(test)]
mod tests {
    //! Port-level invariant tests. The boundary tests (live in
    //! `tests/id_token_verifier_boundary.rs`) exercise the production
    //! adapter; the unit tests below verify the static shape of the
    //! port surface.
    use super::*;
    use ppoppo_token::id_token::scopes;
    use ulid::Ulid;

    fn fixture_sub() -> PpnumId {
        PpnumId(
            Ulid::from_string("01HK0000000000000000000001")
                .expect("test ulid")
        )
    }

    #[test]
    fn for_test_constructor_yields_openid_assertion() {
        let now = OffsetDateTime::now_utc();
        let a: IdAssertion<scopes::Openid> = IdAssertion::for_test(
            "accounts.ppoppo.com",
            fixture_sub(),
            vec!["rp-client".to_owned()],
            now + time::Duration::hours(1),
            now,
            "n-0S6_WzA2Mj",
        );
        assert_eq!(a.iss(), "accounts.ppoppo.com");
        assert_eq!(a.aud(), &["rp-client".to_owned()]);
        assert_eq!(a.nonce(), "n-0S6_WzA2Mj");
        assert!(a.azp().is_none());
        assert!(a.auth_time().is_none());
    }

    #[test]
    fn with_email_builder_populates_pii() {
        let now = OffsetDateTime::now_utc();
        let a: IdAssertion<scopes::Email> = IdAssertion::for_test(
            "accounts.ppoppo.com",
            fixture_sub(),
            vec!["rp-client".to_owned()],
            now + time::Duration::hours(1),
            now,
            "nonce",
        )
        .with_email("user@example.com", Some(true));
        assert_eq!(a.email(), "user@example.com");
        assert_eq!(a.email_verified(), Some(true));
    }

    #[test]
    fn id_verify_error_display_preserves_m_codes() {
        // Audit log relies on Display to surface the M-row identifier
        // (caller routes structured fields, but Display is the human-
        // readable axis for grep over CloudLogging output).
        assert_eq!(
            format!("{}", IdVerifyError::NonceMismatch),
            "M66: nonce does not match expected value"
        );
        assert_eq!(
            format!("{}", IdVerifyError::CatMismatch("access".to_owned())),
            "M29-mirror: id_token cat must be 'id', got 'access'"
        );
        assert_eq!(
            format!("{}", IdVerifyError::AzpMismatch),
            "M69: azp does not match expected client_id"
        );
    }

    /// Compile-time guard: an `Arc<dyn IdTokenVerifier<S>>` is the
    /// runtime shape consumer middleware injects. If the trait's
    /// object-safety regresses, this won't compile.
    #[allow(dead_code)]
    fn dyn_object_safety<S: ScopeSet>() {
        fn _accept<S: ScopeSet>(_: std::sync::Arc<dyn IdTokenVerifier<S>>) {}
    }

    /// Compile-time check: every published scope marker has a
    /// [`ScopePiiReader`] impl. If a future scope is added to the
    /// re-exports without an impl, this fails to compile.
    #[allow(dead_code)]
    fn scope_pii_reader_impls_exist() {
        fn _accept<S: ScopePiiReader>() {}
        _accept::<Openid>();
        _accept::<Email>();
        _accept::<Profile>();
        _accept::<EmailProfile>();
        _accept::<EmailProfilePhone>();
        _accept::<EmailProfilePhoneAddress>();
    }
}