ppoppo-token 0.1.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
//! Verification errors for the JWT engine.
//!
//! Variants map 1:1 to mitigation IDs in
//! `0context/STANDARDS_JWT_DETAILS_MITIGATION_PPOPPO.md` §4. The mapping lets
//! audit logs translate a runtime rejection back to the standards row that
//! authorized it. Adding a mitigation row that has no corresponding variant
//! here is a drift signal.
//!
//! Phase 1 covers M01-M16a (algorithm + header). Subsequent phases append
//! variants — never reorder or rename existing ones.

#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum AuthError {
    // ── A. Algorithm (M01–M06) ────────────────────────────────────────────
    /// M01: token header carries `alg: none` (or a value the library cannot
    /// parse to a known `Algorithm`).
    #[error("M01: alg=none rejected")]
    AlgNone,

    /// M02: header `alg` is parseable but not in the per-request whitelist.
    #[error("M02: algorithm outside whitelist")]
    AlgNotWhitelisted,

    /// M03: HMAC family (`HS256`/`HS384`/`HS512`) rejected — confusion attack
    /// against asymmetric public keys.
    #[error("M03: HMAC algorithm rejected")]
    AlgHmacRejected,

    /// M04: RSA family (`RS*`/`PS*`) rejected.
    #[error("M04: RSA algorithm rejected")]
    AlgRsaRejected,

    /// M05: ECDSA family (`ES*`) rejected.
    #[error("M05: ECDSA algorithm rejected")]
    AlgEcdsaRejected,

    // M06 (alg pinned per request, not header) collapses into
    // `AlgNotWhitelisted` at runtime — the enforcement *is* "compare header
    // alg against cfg.algorithms", so a header-trusting verifier and a
    // misconfigured cfg surface the same condition. Audit logs distinguish
    // via the cfg snapshot, not the variant.

    // ── B. Header (M07–M16a) ──────────────────────────────────────────────
    /// M07: header carries `jku` (URL-loaded JWK Set).
    #[error("M07: jku header rejected")]
    HeaderJku,

    /// M08: header carries `x5u` (URL-loaded X.509 chain).
    #[error("M08: x5u header rejected")]
    HeaderX5u,

    /// M09: header carries inline `jwk`.
    #[error("M09: jwk header rejected")]
    HeaderJwk,

    /// M10: header carries `x5c` (inline X.509 chain).
    #[error("M10: x5c header rejected")]
    HeaderX5c,

    /// M11: header carries `crit` with unknown extensions.
    #[error("M11: crit header rejected")]
    HeaderCrit,

    /// M12: `kid` missing or unknown to the server-pinned `KeySet`.
    #[error("M12: kid missing or unknown")]
    KidUnknown,

    /// M13/M13a: `typ` does not equal the configured value (default
    /// `at+jwt`). Strict equality — `JWT` is also rejected.
    #[error("M13: typ mismatch")]
    TypMismatch,

    /// M14: nested JWS (payload is itself a JWT) — defended via `cty` header
    /// inspection.
    #[error("M14: nested JWS rejected")]
    NestedJws,

    /// M15: token is JWE (5-part) — encryption forbidden in this profile.
    #[error("M15: JWE rejected")]
    JwePayload,

    /// M16: header contains parameters outside the whitelist
    /// (`typ`, `alg`, `kid`).
    #[error("M16: extra header params")]
    HeaderExtraParam,

    /// M16a: header carries `b64=false` (RFC 7797 unencoded payload).
    #[error("M16a: b64=false rejected")]
    HeaderB64False,

    // ── C. Claims (M17–M30) ───────────────────────────────────────────────
    /// M17: `exp` claim absent. RFC 8725 §3.10 — a token without an expiry
    /// contract has no admissibility window; reject before any value check.
    #[error("M17: exp claim missing")]
    ExpMissing,

    /// M18: `exp` is in the past. RFC 8725 §3.10 — leeway = 0; the engine
    /// refuses any token whose expiry timestamp precedes the current
    /// instant. Distinct from `ExpMissing`: M17 fires when the claim is
    /// absent, M18 fires when it's present but stale.
    #[error("M18: token expired")]
    Expired,

    /// M19: `exp` exceeds the per-profile upper bound (24h for access,
    /// 200d for refresh — Phase 2 is access-only; refresh issuance lands
    /// Phase 4). Bounds the blast radius of a leaked token: a malicious
    /// issuer cannot mint near-immortal credentials.
    #[error("M19: exp exceeds upper bound")]
    ExpUpperBound,

    /// M20: `aud` claim absent. Without an audience binding the engine
    /// cannot enforce the verifier-specific match (M21/M22) — refuse
    /// before any value check.
    #[error("M20: aud claim missing")]
    AudMissing,

    /// M21 + M22: `aud` value does not match `cfg.audience`. Phase 1's
    /// M06 documented-collapse pattern applies — M21 covers the string
    /// form, M22 covers the array form, both surface the same audit
    /// signal. The variant carries the M-ID via the `#[error]` string;
    /// audit logs disambiguate via the cfg+token state.
    #[error("M21/M22: aud value does not match expected audience")]
    AudMismatch,

    /// M23: `iss` is missing OR does not match the pinned issuer
    /// (`cfg.issuer`). Both cases collapse into one variant — the audit
    /// signal is identical: this token did not come from the trusted
    /// issuer. Distinguishing missing-vs-wrong adds no useful diagnostic
    /// (an attacker who omits iss and one who forges a wrong iss are
    /// both probing for issuer trust confusion).
    #[error("M23: iss missing or does not match pinned issuer")]
    IssMismatch,

    /// M24 (first clause): `iat` claim absent. Without an issuance
    /// timestamp the engine can neither bound the token's age (M19) nor
    /// enforce M24's "must be in past" rule. M24's future-iat clause
    /// surfaces as `IatFuture`.
    #[error("M24: iat claim missing")]
    IatMissing,

    /// M24 (second clause) + M25: `iat` is in the future beyond the 60s
    /// clock-skew leeway. Phase 1's M06 documented-collapse pattern
    /// applies — M24's must-be-in-past predicate and M25's far-future
    /// ceiling enforce the same condition (`iat > now + 60s`); they
    /// share the variant. Audit logs disambiguate via the iat value
    /// itself (60s vs hours-in-the-future).
    #[error("M24/M25: iat is in the future beyond 60s leeway")]
    IatFuture,

    /// M26: `nbf` (not-before) is present and in the future — the token
    /// has not yet entered its admissibility window. Distinct from
    /// `IatFuture`: nbf is the issuer's explicit "valid from" boundary,
    /// where iat is the issuance instant. nbf is optional; absence is
    /// not an error.
    #[error("M26: nbf is in the future — token not yet valid")]
    NotYetValid,

    /// M27: `jti` claim absent. RFC 7519 §4.1.7 — the unique token
    /// identifier is the replay-cache key (M35). Without it the engine
    /// cannot enforce one-shot semantics on per-token operations.
    #[error("M27: jti claim missing")]
    JtiMissing,

    /// M28: `sub` claim absent. RFC 7519 §4.1.2 — the subject identifies
    /// the principal the token is about; no useful authorization
    /// decision can follow when it's missing.
    #[error("M28: sub claim missing")]
    SubMissing,

    /// M28a: `client_id` claim absent. RFC 9068 §2.2 mandates this for
    /// access JWTs so the resource server can identify the originating
    /// OAuth client (audit, per-client rate limits).
    #[error("M28a: client_id claim missing")]
    ClientIdMissing,

    /// M29: `cat` (token category) is missing or not the expected value.
    /// RFC 9068 §2.2 + ppoppo extension — `cat ∈ {access, refresh}` is a
    /// payload-level discriminator that lets a single verifier refuse
    /// type-confusion attempts (refresh token at an access endpoint).
    /// Phase 2 verifies access tokens only; Phase 4 (refresh issuance)
    /// generalizes via `cfg.expected_cat`. Missing-cat collapses into
    /// the same variant — audit signal is identical (untrusted token
    /// type).
    #[error("M29: cat does not match expected token type")]
    TokenTypeMismatch,

    /// M30: a numeric claim (`exp`/`iat`/`nbf`) is present but not a
    /// JSON integer. RFC 8725 §2.4 — string-coerced numerics are a
    /// classic substitution vector (`"exp": "9999"` parsed as a "valid"
    /// future expiry). Engine refuses any non-integer numeric claim
    /// before the value-violation rules can fire.
    #[error("M30: numeric claim is not a JSON integer")]
    InvalidNumericType,

    // ── D. Serialization (M31–M34) ────────────────────────────────────────
    /// M31: input is JWS JSON serialization (or any non-compact form).
    /// RFC 8725 §2.4 — the profile accepts JWS Compact only. JSON-form
    /// JWS expands the implementation surface and has historically
    /// carried polyglot-payload attacks; refuse before any segment
    /// parser runs.
    #[error("M31: JWS JSON serialization rejected — Compact only")]
    JwsJsonRejected,

    /// M32: header or payload JSON contains duplicate top-level keys.
    /// RFC 7515 §3 mandates rejection, but serde_json silently keeps the
    /// last occurrence by default — making the smuggling case (a forger
    /// duplicates a claim hoping the verifier reads one value while a
    /// downstream consumer reads another) invisible. Engine pre-validates
    /// every JSON object via a key-uniqueness Visitor before parsing.
    #[error("M32: JSON object contains duplicate keys")]
    DuplicateJsonKeys,

    /// M33: a segment contains characters from the standard base64
    /// alphabet (`+`, `/`, `=`) — RFC 8725 §2.4 requires strict
    /// `base64url` (URL_SAFE_NO_PAD: only `A-Z a-z 0-9 - _`). Standard
    /// b64 chars are rejected with their own variant so audit logs
    /// distinguish "intentional + injection" from generic decode
    /// failures (which surface as `HeaderUnparseable` /
    /// `PayloadUnparseable`).
    #[error("M33: segment contains non-URL-safe base64 characters")]
    LaxBase64,

    /// M34: total token length exceeds `cfg.max_token_size` (8 KB
    /// default for the access-token profile). A large token is either
    /// a misconfigured issuer (extras bloating beyond a reasonable
    /// claim set) or a denial-of-service vector (parser amplification).
    /// Engine refuses oversized input before any segment parsing runs.
    #[error("M34: token exceeds maximum size")]
    OversizedToken,

    // ── F. Domain (M39–M45) ───────────────────────────────────────────────
    /// M39: `sub` is present but not a 26-character Crockford-base32 ULID.
    /// PAS-issued tokens carry `ppnum_id` (Human ULID) or an AI-agent ULID
    /// in `sub`; any other shape is either an issuer drift or a forgery
    /// attempt. Distinct variant so audit logs distinguish "sub missing"
    /// (M28) from "sub ill-formed" (M39).
    #[error("M39: sub is not a valid ULID")]
    SubFormatInvalid,

    /// M40: `account_type` is present but not in the whitelist
    /// `{"human", "ai_agent"}`. The claim is optional (legacy tokens
    /// minted before the field existed are admitted), but a present-
    /// but-unknown value is a forgery signal — the issuer never emits
    /// arbitrary strings here. Renamed from the matrix's earlier
    /// `actor` to avoid collision with RFC 8693 token-exchange.
    #[error("M40: account_type outside whitelist")]
    AccountTypeInvalid,

    /// M41: `caps` is present but the wire shape is wrong (not a JSON
    /// array of strings). The default-deny invariant lives in the
    /// *interpretation*: absent/empty both mean "no capabilities", and
    /// any non-empty value MUST be an array of strings. A string-typed
    /// `caps: "admin"` is the canonical confusion — a forger hoping the
    /// verifier reads it as a one-element list.
    #[error("M41: caps is not a JSON array of strings")]
    CapsShapeInvalid,

    /// M42 (shape): `scopes` is present but not a JSON array of strings.
    /// Mirrors `CapsShapeInvalid` — collapsed into its own variant
    /// because the audit signal is meaningfully different (a scope
    /// confusion attack reads a different threat model than a
    /// capability shape attack).
    #[error("M42: scopes is not a JSON array of strings")]
    ScopesShapeInvalid,

    /// M42 (length): `scopes` has more than 256 entries. Bounds the
    /// per-token audit surface and stops a misconfigured issuer (or a
    /// forger who got hold of a signing key) from minting a token whose
    /// authorization vector is itself a DoS — a 10k-entry scopes array
    /// pessimizes every per-request scope check.
    #[error("M42: scopes exceeds 256-entry cap")]
    ScopesTooLong,

    /// M43: `dlg_depth` is present but exceeds the 4-step delegation
    /// chain bound (or is the wrong wire shape — non-integer, negative).
    /// Bounds the audit-trail explosion of arbitrarily deep Token
    /// Exchange chains; `dlg_depth = 4` is the inclusive bound, matching
    /// RFC §6.5. Single variant covers both shape and bound errors —
    /// audit signal is "delegation chain rejected", and the value
    /// itself surfaces in structured logging at the rejection site.
    #[error("M43: dlg_depth invalid (non-integer, negative, or > 4)")]
    DlgDepthInvalid,

    /// M44 (band gate): token claims `admin: true` but the supporting
    /// `active_ppnum` is either absent or its first 3 digits don't fall
    /// in the admin allocation band. PAS issues admin tokens only on
    /// ppnums minted from the admin band, so a token outside the band is
    /// either a forgery (with a stolen signing key) or an issuer drift.
    /// **This is defense-in-depth on top of `is_admin` DB lookup**
    /// (STANDARDS_AUTH_PPOPPO §3.2 — DB is the source of truth); it
    /// turns a stolen-key forgery into an "active_ppnum needs to be
    /// banded" forgery, narrowing the attack window meaningfully.
    #[error("M44: admin claim requires active_ppnum in admin band")]
    AdminBandRejected,

    /// M45: payload contains a claim outside the engine's strict
    /// allowlist. The PAS issuance pipeline only emits claims listed
    /// in `engine::check_domain::ALLOWED_CLAIMS`; anything else is a
    /// forgery / misconfiguration / PII smuggling attempt (M45 is the
    /// "no PII in payload" defense — `email` / `phone` / `name` get
    /// rejected by name). The variant carries the offending claim
    /// name for audit logs so operators can see at a glance which
    /// claim triggered the rejection.
    #[error("M45: unknown claim '{0}'")]
    UnknownClaim(String),

    // ── E. Replay / revocation (M35–M38) — Phase 5 ────────────────────────
    /// M35: jti has been seen within the replay-cache TTL — replayed
    /// token. Engine refuses on the second sighting; implementations of
    /// `ReplayDefense` MUST treat the cache check and record as a single
    /// atomic primitive (KVRocks `SET NX EX`, equivalent) to avoid a
    /// TOCTOU window between check and record.
    #[error("M35: jti replayed within TTL")]
    JtiReplayed,

    /// M35 (substrate transient): replay-cache substrate is unreachable.
    /// Engine fails closed — admitting on substrate failure would let a
    /// replayer slip through during the outage. Audit logs surface this
    /// as a SEPARATE signal from `JtiReplayed` so ops can distinguish
    /// "active attack" (replay) from "infrastructure issue" (cache down).
    #[error("M35: replay cache substrate unavailable")]
    ReplayCacheUnavailable,

    /// M36: `(sub, sid)` row absent from `user_sessions` — the session
    /// was revoked. STANDARDS_JWT_DETAILS_MITIGATION §E "row deletion =
    /// revocation" — this is the textbook stateful-revocation gate that
    /// makes the system "stateful by design" (OVERVIEW §6: `stateless
    /// 환상 폐기`). Distinct from `SessionVersionStale` (account-wide
    /// epoch) because this axis kicks one device while leaving the
    /// account's other sessions alive.
    #[error("M36: session revoked (user_sessions row absent)")]
    SessionRevoked,

    /// M36 (substrate transient): session-row lookup substrate is
    /// unreachable. Engine fails closed.
    #[error("M36: session lookup substrate unavailable")]
    SessionLookupUnavailable,

    /// sv-port (Phase 5): token's `sv` claim is strictly less than the
    /// current per-account session_version. Break-glass / `LogoutAll`
    /// bump `current_sv` inside the substrate; tokens minted before the
    /// bump fail this gate within the cache TTL (60s default — see
    /// `SV_CACHE_TTL`). PAS-internal callers preemptively flip
    /// `sv:{ppnum_id}`; remote consumers (PCS chat-auth, pas-external
    /// SDK) converge via the cache TTL.
    #[error("session_version stale: token < current epoch")]
    SessionVersionStale,

    /// sv-port (substrate transient): `EpochRevocation::current` failed.
    /// Engine fails closed.
    #[error("session_version lookup substrate unavailable")]
    SessionVersionLookupUnavailable,

    // ── Parse / structural ────────────────────────────────────────────────
    /// Token cannot be split into a JWS Compact form (3 segments).
    #[error("token is not a JWS Compact serialization")]
    NotJwsCompact,

    /// Header segment cannot be base64url-decoded or is not valid JSON.
    #[error("header is not valid JSON")]
    HeaderUnparseable,

    /// Payload segment cannot be base64url-decoded or is not valid JSON.
    /// Mirrors `HeaderUnparseable` for the second segment — structural,
    /// not an M-row enforcement.
    #[error("payload is not valid JSON")]
    PayloadUnparseable,
}