ppoppo_token/id_token/error.rs
1//! OIDC id_token verification errors.
2//!
3//! Disjoint from `access_token::AuthError` by design — id_token's
4//! mitigation matrix (M66-M73) is OIDC-specific, and collapsing the two
5//! into one enum would force every variant to carry "applies to which
6//! profile?" metadata. Instead each profile owns its `AuthError`; the
7//! shared engine submodules (`check_algorithm`, `check_header`, `raw`)
8//! still surface their JOSE errors via `access_token::AuthError` and the
9//! id_token verify wrapper translates them via `From` (Phase 10.1.D).
10//!
11//! Variants map 1:1 to the M66-M73 rows in
12//! `0context/STANDARDS_JWT_DETAILS_MITIGATION_PPOPPO.md` §J.
13//!
14//! Phase 10.2 lands M66 nonce binding (NonceMissing + NonceMismatch
15//! verify-time variants alongside the construction-time NonceConfigEmpty
16//! introduced in 10.1). M67-M73 land in their own row commits, per
17//! `feedback_no_backcompat_healthy_arch` ("skeleton is a commit, not a
18//! temporary scaffold").
19
20/// Verification errors specific to the OIDC id_token profile.
21///
22/// Shared JOSE errors (M01-M16a algorithm/header, M17-M30 registered
23/// claims) reach this enum via the `Jose` carrier variant — the engine
24/// submodules emit `access_token::AuthError`, the id_token verify entry
25/// re-wraps. This is a deliberate seam: it lets the id_token surface
26/// stay narrow (only profile-specific variants enumerated here) while
27/// reusing the JOSE checks structurally.
28#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
29pub enum AuthError {
30 // ── J. OIDC-specific (M66-M73) ────────────────────────────────────────
31 /// M66: `nonce` claim is absent from the id_token payload. RFC says
32 /// nonce is conditionally required (only when the RP sent one in the
33 /// Auth Request) — but the engine's `VerifyConfig::id_token`
34 /// constructor *requires* `expected_nonce`, so reaching this error
35 /// means the RP requested nonce binding and the IdP failed to honor
36 /// it. Treat as suspect: either an issuer drift or a token forged
37 /// against an older `VerifyConfig`.
38 #[error("M66: nonce claim absent from payload")]
39 NonceMissing,
40
41 /// M66: payload `nonce` is present but does not match the
42 /// `expected_nonce` the RP stored at the auth request boundary.
43 /// Canonical replay-attack signal — the attacker is presenting an
44 /// id_token issued for a different session.
45 #[error("M66: nonce does not match expected value")]
46 NonceMismatch,
47
48 /// `Nonce::new("")` was called. Construction-time invariant guard;
49 /// reaching this at runtime means consumer code violated the
50 /// non-empty contract. Surfaced as a verification error rather than
51 /// a panic so the consumer gets a structured rejection it can audit.
52 #[error("nonce config: empty value")]
53 NonceConfigEmpty,
54
55 /// M67: `at_hash` claim absent from payload while the verifier was
56 /// configured with an expected access_token binding (i.e.
57 /// `VerifyConfig::with_access_token_binding` was called). RFC says
58 /// at_hash is conditionally required (only when the response_type
59 /// includes `token` — hybrid + implicit flows); reaching this error
60 /// means the RP told the engine to expect at_hash and the IdP failed
61 /// to honor it. Either issuer drift or a substitution attempt.
62 #[error("M67: at_hash claim absent from payload")]
63 AtHashMissing,
64
65 /// M67: payload `at_hash` is present but does not match the SHA-256
66 /// leftmost-128b base64url of the access_token the RP supplied via
67 /// `with_access_token_binding`. Canonical access-token-substitution
68 /// signal: an attacker is presenting an id_token issued for a
69 /// different access_token than the one the RP just received.
70 #[error("M67: at_hash does not match expected access_token binding")]
71 AtHashMismatch,
72
73 /// M68: `c_hash` claim absent from payload while the verifier was
74 /// configured with an expected authorization-code binding. Mirror of
75 /// M67 for the authorization-code flow (OIDC Core §3.3.2.11) — fires
76 /// only when `with_authorization_code_binding` was called.
77 #[error("M68: c_hash claim absent from payload")]
78 CHashMissing,
79
80 /// M68: payload `c_hash` is present but does not match the SHA-256
81 /// leftmost-128b base64url of the authorization code the RP received
82 /// at the redirect_uri. Canonical code-substitution signal: an
83 /// attacker is presenting an id_token issued for a different code
84 /// than the one the RP is about to exchange at the token endpoint.
85 #[error("M68: c_hash does not match expected authorization_code binding")]
86 CHashMismatch,
87
88 /// M69: `azp` (authorized party) claim absent while the id_token has
89 /// multiple audiences. OIDC Core §2 SHOULD requires azp on multi-aud
90 /// tokens; Phase 7 elevates to MUST. Reaching this error means the
91 /// IdP issued a multi-aud id_token without naming the authorized
92 /// party — either an issuer drift or a substitution attempt.
93 #[error("M69: azp claim absent on multi-audience id_token")]
94 AzpMissing,
95
96 /// M69: payload `azp` is present but does not equal the RP's
97 /// client_id (sourced from `cfg.shared.audience`). Fires regardless
98 /// of aud cardinality — §2 mandates the equality unconditionally
99 /// when azp is present. Canonical client-substitution signal: the
100 /// id_token was authorized for a sibling client and is being replayed
101 /// against this RP.
102 #[error("M69: azp does not match expected client_id")]
103 AzpMismatch,
104
105 /// M70: `auth_time` claim absent from payload while the verifier
106 /// was configured with a `max_age` window. OIDC Core §3.1.3.7 says
107 /// auth_time is REQUIRED when max_age was requested; reaching this
108 /// error means the RP told the engine to gate freshness and the
109 /// IdP failed to honor it — IdP misconfiguration. Operator
110 /// response: investigate the IdP, not the user session.
111 #[error("M70: auth_time claim absent while max_age is configured")]
112 AuthTimeMissing,
113
114 /// M70: `now - auth_time > max_age`. The user authenticated too
115 /// long ago for this RP's freshness policy. Distinct from
116 /// `AuthTimeMissing` because the operator response differs:
117 /// Stale = re-authenticate the user (force OIDC `prompt=login`);
118 /// Missing = IdP misconfig.
119 #[error("M70: auth_time exceeds max_age window — re-authentication required")]
120 AuthTimeStale,
121
122 /// M71: `acr` claim absent from payload while the verifier was
123 /// configured with `acr_values`. OIDC Core §3.1.3.7 SHOULD elevated
124 /// to MUST per Phase 7 strictness — RPs that requested a specific
125 /// authentication context refuse tokens that don't assert one.
126 #[error("M71: acr claim absent while acr_values is configured")]
127 AcrMissing,
128
129 /// M71: payload `acr` is present but not in the RP's `acr_values`
130 /// allowlist. Canonical step-up bypass signal: the IdP authenticated
131 /// the user at a weaker level than this RP requires for the
132 /// requested operation. Comparison is case-sensitive (URN values
133 /// are case-sensitive by spec); case-folding would silently admit
134 /// downgrades.
135 #[error("M71: acr value not in configured acr_values allowlist")]
136 AcrNotAllowed,
137
138 /// M72: id_token payload contains a claim outside the per-scope
139 /// allowlist (`S::names()`). Structurally mirrors M45's
140 /// `access_token::AuthError::UnknownClaim` but is profile-aware: the
141 /// permitted set is derived from the type-level scope witness `S`,
142 /// so the same wire payload is accepted at `Claims<EmailProfile>`
143 /// and refused at `Claims<Openid>`.
144 ///
145 /// Strict-refuse from day 1 (β1): no leniency flag, no silent
146 /// stripping. The carried name is the first offending claim — audit
147 /// logs distinguish a forgery (`backdoor`) from issuer drift
148 /// (`email` at `Openid` scope) by reading the variant payload.
149 /// Operator response depends on the name: investigate IdP scope
150 /// emission policy or refuse the consumer's scope drift.
151 #[error("M72: unknown id_token claim '{0}' outside per-scope allowlist")]
152 UnknownClaim(String),
153
154 /// M29-mirror (Phase 10.10): id_token payload carries a `cat` claim
155 /// whose value is not `"id"`. Structurally mirrors access-token's
156 /// `M29 TokenTypeMismatch` (`engine::check_claims::run` line 137-140
157 /// — refuses anything other than `cat="access"`); closes the
158 /// asymmetry where `id_token::verify` previously had no profile-
159 /// routing assertion and relied on M72 BASE_CLAIMS-omission to
160 /// implicitly forbid `cat`. With `cat` now in `BASE_CLAIMS` (so
161 /// self-issued tokens round-trip via M72), the value gate moves to
162 /// this dedicated check.
163 ///
164 /// Carries the offending value so audit logs distinguish:
165 /// * `CatMismatch("access")` — an attacker presenting an id_token
166 /// with a forged `cat` to make it look like an access token (the
167 /// substitution attack M73 also defends against from the other
168 /// side).
169 /// * `CatMismatch("")` — payload missing `cat` entirely, either
170 /// issuer drift (a non-PAS OIDC IdP that doesn't emit ppoppo's
171 /// profile-routing claim) or a stripped-claims forgery attempt.
172 /// * `CatMismatch("<other>")` — bespoke forgery; the variant payload
173 /// is itself the audit signal.
174 #[error("M29-mirror: id_token cat must be 'id', got '{0}'")]
175 CatMismatch(String),
176
177 // ── Carrier for shared JOSE wire errors (M01-M16a, M31-M34) ───────────
178 /// JOSE wire-format error from the shared engine pipeline.
179 /// Algorithm whitelist, header attack surface, serialization shape,
180 /// and structural rejections (oversize, JWE, JSON-form) all surface
181 /// here. RFC 9068-specific errors (M17-M30 registered claims, M35-M45)
182 /// stay on `access_token::AuthError` and never reach this enum.
183 #[error(transparent)]
184 Jose(#[from] crate::SharedAuthError),
185}