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
//! M72 — id_token per-scope PII allowlist (OIDC Core §5.4 wire-side narrowing).
//!
//! The structural twin of `check_domain.rs::ALLOWED_CLAIMS` (M45 access-profile
//! allowlist), but **profile-aware**: the permitted set is derived from the
//! `S: ScopeSet` type witness via `S::names()`, not hardcoded. A token
//! verified at `Claims<Openid>` gets the base set; a token verified at
//! `Claims<Email>` admits `email` / `email_verified` additionally; etc.
//!
//! ── Why a runtime gate when accessor narrowing is already structural ────
//!
//! `claims.rs::pub(crate)` field privacy + scope-bounded `impl<S: HasEmail>
//! Claims<S>` accessor blocks already make `Claims<Openid>::email()` a
//! compile error. M72 closes the *deserialization* loop: without this gate,
//! a wire-borne `email` claim still populates the `pub(crate) email` field
//! on `Claims<Openid>`, where it sits silently — reachable via Debug,
//! serde-roundtrip, or any future accessor that forgets the bound. M72
//! refuses such tokens at parse time so the field is structurally
//! guaranteed to be `None` for an `S` that doesn't permit it.
//!
//! ── Strict-refuse semantics (β1) ────────────────────────────────────────
//!
//! Any payload key NOT in `S::names()` triggers `AuthError::UnknownClaim(key)`.
//! No silent strip, no leniency flag, no allowlist union with M45's access
//! set — the two profiles are claim-disjoint (M45 admits `client_id`, `cat`,
//! `active_ppnum` etc.; M72 forbids them on id_tokens). Mirroring M45's
//! shape gives audit logs a uniform `UnknownClaim(name)` signal across both
//! profiles; the carrying enum (id_token vs access_token AuthError) tells
//! the reader which profile rejected.
//!
//! ── Order in `verify` ───────────────────────────────────────────────────
//!
//! Fires AFTER `check_acr` (last typed gate) and BEFORE `deserialize_claims`.
//! Rationale: structural rejection of unknown claims happens before the
//! engine commits the wire shape into a typed `Claims<S>`. Specific-variant
//! rejects (M66-M71) get the precise audit signal first; only after every
//! typed check passes does the catch-net allowlist run.
use crateAuthError;
/// Refuse any payload key not in `expected`. Returns the FIRST offending
/// claim name in the error variant so audit logs see exactly which key
/// tripped the gate.
///
/// `expected` is conventionally `S::names()` from the verify entry-point
/// — passing an empty slice is a misuse (would refuse every token) but
/// not a soundness issue, so it is not specially-cased.
///
/// Non-object payloads silently no-op: by the time this gate fires,
/// every prior submodule (`check_nonce`, `check_at_hash`, `check_c_hash`,
/// `check_azp`, `check_auth_time`, `check_acr`) has already read claims
/// from the payload as an object — a non-object would have surfaced as
/// the relevant `*Missing` variant earlier. Same defensive shape as
/// `check_domain::run` line 165.
pub