Skip to main content

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}