ppoppo-token 0.2.0

JWT (RFC 9068, EdDSA) issuance + verification engine for the Ppoppo ecosystem. Single deep module with a small interface (issue, verify) hiding RFC 8725 mitigations M01-M45, JWKS handling, and substrate ports (epoch, session, replay).
Documentation
//! OIDC id_token verification errors.
//!
//! Disjoint from `access_token::AuthError` by design — id_token's
//! mitigation matrix (M66-M73) is OIDC-specific, and collapsing the two
//! into one enum would force every variant to carry "applies to which
//! profile?" metadata. Instead each profile owns its `AuthError`; the
//! shared engine submodules (`check_algorithm`, `check_header`, `raw`)
//! still surface their JOSE errors via `access_token::AuthError` and the
//! id_token verify wrapper translates them via `From` (Phase 10.1.D).
//!
//! Variants map 1:1 to the M66-M73 rows in
//! `0context/STANDARDS_JWT_DETAILS_MITIGATION_PPOPPO.md` §J.
//!
//! Phase 10.2 lands M66 nonce binding (NonceMissing + NonceMismatch
//! verify-time variants alongside the construction-time NonceConfigEmpty
//! introduced in 10.1). M67-M73 land in their own row commits, per
//! `feedback_no_backcompat_healthy_arch` ("skeleton is a commit, not a
//! temporary scaffold").

/// Verification errors specific to the OIDC id_token profile.
///
/// Shared JOSE errors (M01-M16a algorithm/header, M17-M30 registered
/// claims) reach this enum via the `Jose` carrier variant — the engine
/// submodules emit `access_token::AuthError`, the id_token verify entry
/// re-wraps. This is a deliberate seam: it lets the id_token surface
/// stay narrow (only profile-specific variants enumerated here) while
/// reusing the JOSE checks structurally.
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum AuthError {
    // ── J. OIDC-specific (M66-M73) ────────────────────────────────────────
    /// M66: `nonce` claim is absent from the id_token payload. RFC says
    /// nonce is conditionally required (only when the RP sent one in the
    /// Auth Request) — but the engine's `VerifyConfig::id_token`
    /// constructor *requires* `expected_nonce`, so reaching this error
    /// means the RP requested nonce binding and the IdP failed to honor
    /// it. Treat as suspect: either an issuer drift or a token forged
    /// against an older `VerifyConfig`.
    #[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.
    /// Canonical replay-attack signal — the attacker is presenting an
    /// id_token issued for a different session.
    #[error("M66: nonce does not match expected value")]
    NonceMismatch,

    /// `Nonce::new("")` was called. Construction-time invariant guard;
    /// reaching this at runtime means consumer code violated the
    /// non-empty contract. Surfaced as a verification error rather than
    /// a panic so the consumer gets a structured rejection it can audit.
    #[error("nonce config: empty value")]
    NonceConfigEmpty,

    /// M67: `at_hash` claim absent from payload while the verifier was
    /// configured with an expected access_token binding (i.e.
    /// `VerifyConfig::with_access_token_binding` was called). RFC says
    /// at_hash is conditionally required (only when the response_type
    /// includes `token` — hybrid + implicit flows); reaching this error
    /// means the RP told the engine to expect at_hash and the IdP failed
    /// to honor it. Either issuer drift or a substitution attempt.
    #[error("M67: at_hash claim absent from payload")]
    AtHashMissing,

    /// M67: payload `at_hash` is present but does not match the SHA-256
    /// leftmost-128b base64url of the access_token the RP supplied via
    /// `with_access_token_binding`. Canonical access-token-substitution
    /// signal: an attacker is presenting an id_token issued for a
    /// different access_token than the one the RP just received.
    #[error("M67: at_hash does not match expected access_token binding")]
    AtHashMismatch,

    /// M68: `c_hash` claim absent from payload while the verifier was
    /// configured with an expected authorization-code binding. Mirror of
    /// M67 for the authorization-code flow (OIDC Core §3.3.2.11) — fires
    /// only when `with_authorization_code_binding` was called.
    #[error("M68: c_hash claim absent from payload")]
    CHashMissing,

    /// M68: payload `c_hash` is present but does not match the SHA-256
    /// leftmost-128b base64url of the authorization code the RP received
    /// at the redirect_uri. Canonical code-substitution signal: an
    /// attacker is presenting an id_token issued for a different code
    /// than the one the RP is about to exchange at the token endpoint.
    #[error("M68: c_hash does not match expected authorization_code binding")]
    CHashMismatch,

    /// M69: `azp` (authorized party) claim absent while the id_token has
    /// multiple audiences. OIDC Core §2 SHOULD requires azp on multi-aud
    /// tokens; Phase 7 elevates to MUST. Reaching this error means the
    /// IdP issued a multi-aud id_token without naming the authorized
    /// party — either an issuer drift or a substitution attempt.
    #[error("M69: azp claim absent on multi-audience id_token")]
    AzpMissing,

    /// M69: payload `azp` is present but does not equal the RP's
    /// client_id (sourced from `cfg.shared.audience`). Fires regardless
    /// of aud cardinality — §2 mandates the equality unconditionally
    /// when azp is present. Canonical client-substitution signal: the
    /// id_token was authorized for a sibling client and is being replayed
    /// against this RP.
    #[error("M69: azp does not match expected client_id")]
    AzpMismatch,

    /// M70: `auth_time` claim absent from payload while the verifier
    /// was configured with a `max_age` window. OIDC Core §3.1.3.7 says
    /// auth_time is REQUIRED when max_age was requested; reaching this
    /// error means the RP told the engine to gate freshness and the
    /// IdP failed to honor it — IdP misconfiguration. Operator
    /// response: investigate the IdP, not the user session.
    #[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. Distinct from
    /// `AuthTimeMissing` because the operator response differs:
    /// Stale = re-authenticate the user (force OIDC `prompt=login`);
    /// Missing = IdP misconfig.
    #[error("M70: auth_time exceeds max_age window — re-authentication required")]
    AuthTimeStale,

    /// M71: `acr` claim absent from payload while the verifier was
    /// configured with `acr_values`. OIDC Core §3.1.3.7 SHOULD elevated
    /// to MUST per Phase 7 strictness — RPs that requested a specific
    /// authentication context refuse tokens that don't assert one.
    #[error("M71: acr claim absent while acr_values is configured")]
    AcrMissing,

    /// M71: payload `acr` is present but not in the RP's `acr_values`
    /// allowlist. Canonical step-up bypass signal: the IdP authenticated
    /// the user at a weaker level than this RP requires for the
    /// requested operation. Comparison is case-sensitive (URN values
    /// are case-sensitive by spec); case-folding would silently admit
    /// downgrades.
    #[error("M71: acr value not in configured acr_values allowlist")]
    AcrNotAllowed,

    /// M72: id_token payload contains a claim outside the per-scope
    /// allowlist (`S::names()`). Structurally mirrors M45's
    /// `access_token::AuthError::UnknownClaim` but is profile-aware: the
    /// permitted set is derived from the type-level scope witness `S`,
    /// so the same wire payload is accepted at `Claims<EmailProfile>`
    /// and refused at `Claims<Openid>`.
    ///
    /// Strict-refuse from day 1 (β1): no leniency flag, no silent
    /// stripping. The carried name is the first offending claim — audit
    /// logs distinguish a forgery (`backdoor`) from issuer drift
    /// (`email` at `Openid` scope) by reading the variant payload.
    /// Operator response depends on the name: investigate IdP scope
    /// emission policy or refuse the consumer's scope drift.
    #[error("M72: unknown id_token claim '{0}' outside per-scope allowlist")]
    UnknownClaim(String),

    /// M29-mirror (Phase 10.10): id_token payload carries a `cat` claim
    /// whose value is not `"id"`. Structurally mirrors access-token's
    /// `M29 TokenTypeMismatch` (`engine::check_claims::run` line 137-140
    /// — refuses anything other than `cat="access"`); closes the
    /// asymmetry where `id_token::verify` previously had no profile-
    /// routing assertion and relied on M72 BASE_CLAIMS-omission to
    /// implicitly forbid `cat`. With `cat` now in `BASE_CLAIMS` (so
    /// self-issued tokens round-trip via M72), the value gate moves to
    /// this dedicated check.
    ///
    /// Carries the offending value so audit logs distinguish:
    /// * `CatMismatch("access")` — an attacker presenting an id_token
    ///   with a forged `cat` to make it look like an access token (the
    ///   substitution attack M73 also defends against from the other
    ///   side).
    /// * `CatMismatch("")` — payload missing `cat` entirely, either
    ///   issuer drift (a non-PAS OIDC IdP that doesn't emit ppoppo's
    ///   profile-routing claim) or a stripped-claims forgery attempt.
    /// * `CatMismatch("<other>")` — bespoke forgery; the variant payload
    ///   is itself the audit signal.
    #[error("M29-mirror: id_token cat must be 'id', got '{0}'")]
    CatMismatch(String),

    // ── Carrier for shared JOSE wire errors (M01-M16a, M31-M34) ───────────
    /// JOSE wire-format error from the shared engine pipeline.
    /// Algorithm whitelist, header attack surface, serialization shape,
    /// and structural rejections (oversize, JWE, JSON-form) all surface
    /// here. RFC 9068-specific errors (M17-M30 registered claims, M35-M45)
    /// stay on `access_token::AuthError` and never reach this enum.
    #[error(transparent)]
    Jose(#[from] crate::SharedAuthError),
}