ppoppo_token/error.rs
1//! Verification errors for the JWT engine.
2//!
3//! Variants map 1:1 to mitigation IDs in
4//! `0context/STANDARDS_JWT_DETAILS_MITIGATION_PPOPPO.md` §4. The mapping lets
5//! audit logs translate a runtime rejection back to the standards row that
6//! authorized it. Adding a mitigation row that has no corresponding variant
7//! here is a drift signal.
8//!
9//! Phase 1 covers M01-M16a (algorithm + header). Subsequent phases append
10//! variants — never reorder or rename existing ones.
11
12#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
13pub enum AuthError {
14 // ── A. Algorithm (M01–M06) ────────────────────────────────────────────
15 /// M01: token header carries `alg: none` (or a value the library cannot
16 /// parse to a known `Algorithm`).
17 #[error("M01: alg=none rejected")]
18 AlgNone,
19
20 /// M02: header `alg` is parseable but not in the per-request whitelist.
21 #[error("M02: algorithm outside whitelist")]
22 AlgNotWhitelisted,
23
24 /// M03: HMAC family (`HS256`/`HS384`/`HS512`) rejected — confusion attack
25 /// against asymmetric public keys.
26 #[error("M03: HMAC algorithm rejected")]
27 AlgHmacRejected,
28
29 /// M04: RSA family (`RS*`/`PS*`) rejected.
30 #[error("M04: RSA algorithm rejected")]
31 AlgRsaRejected,
32
33 /// M05: ECDSA family (`ES*`) rejected.
34 #[error("M05: ECDSA algorithm rejected")]
35 AlgEcdsaRejected,
36
37 // M06 (alg pinned per request, not header) collapses into
38 // `AlgNotWhitelisted` at runtime — the enforcement *is* "compare header
39 // alg against cfg.algorithms", so a header-trusting verifier and a
40 // misconfigured cfg surface the same condition. Audit logs distinguish
41 // via the cfg snapshot, not the variant.
42
43 // ── B. Header (M07–M16a) ──────────────────────────────────────────────
44 /// M07: header carries `jku` (URL-loaded JWK Set).
45 #[error("M07: jku header rejected")]
46 HeaderJku,
47
48 /// M08: header carries `x5u` (URL-loaded X.509 chain).
49 #[error("M08: x5u header rejected")]
50 HeaderX5u,
51
52 /// M09: header carries inline `jwk`.
53 #[error("M09: jwk header rejected")]
54 HeaderJwk,
55
56 /// M10: header carries `x5c` (inline X.509 chain).
57 #[error("M10: x5c header rejected")]
58 HeaderX5c,
59
60 /// M11: header carries `crit` with unknown extensions.
61 #[error("M11: crit header rejected")]
62 HeaderCrit,
63
64 /// M12: `kid` missing or unknown to the server-pinned `KeySet`.
65 #[error("M12: kid missing or unknown")]
66 KidUnknown,
67
68 /// M13/M13a: `typ` does not equal the configured value (default
69 /// `at+jwt`). Strict equality — `JWT` is also rejected.
70 #[error("M13: typ mismatch")]
71 TypMismatch,
72
73 /// M14: nested JWS (payload is itself a JWT) — defended via `cty` header
74 /// inspection.
75 #[error("M14: nested JWS rejected")]
76 NestedJws,
77
78 /// M15: token is JWE (5-part) — encryption forbidden in this profile.
79 #[error("M15: JWE rejected")]
80 JwePayload,
81
82 /// M16: header contains parameters outside the whitelist
83 /// (`typ`, `alg`, `kid`).
84 #[error("M16: extra header params")]
85 HeaderExtraParam,
86
87 /// M16a: header carries `b64=false` (RFC 7797 unencoded payload).
88 #[error("M16a: b64=false rejected")]
89 HeaderB64False,
90
91 // ── C. Claims (M17–M30) ───────────────────────────────────────────────
92 /// M17: `exp` claim absent. RFC 8725 §3.10 — a token without an expiry
93 /// contract has no admissibility window; reject before any value check.
94 #[error("M17: exp claim missing")]
95 ExpMissing,
96
97 /// M18: `exp` is in the past. RFC 8725 §3.10 — leeway = 0; the engine
98 /// refuses any token whose expiry timestamp precedes the current
99 /// instant. Distinct from `ExpMissing`: M17 fires when the claim is
100 /// absent, M18 fires when it's present but stale.
101 #[error("M18: token expired")]
102 Expired,
103
104 /// M19: `exp` exceeds the per-profile upper bound (24h for access,
105 /// 200d for refresh — Phase 2 is access-only; refresh issuance lands
106 /// Phase 4). Bounds the blast radius of a leaked token: a malicious
107 /// issuer cannot mint near-immortal credentials.
108 #[error("M19: exp exceeds upper bound")]
109 ExpUpperBound,
110
111 /// M20: `aud` claim absent. Without an audience binding the engine
112 /// cannot enforce the verifier-specific match (M21/M22) — refuse
113 /// before any value check.
114 #[error("M20: aud claim missing")]
115 AudMissing,
116
117 /// M21 + M22: `aud` value does not match `cfg.audience`. Phase 1's
118 /// M06 documented-collapse pattern applies — M21 covers the string
119 /// form, M22 covers the array form, both surface the same audit
120 /// signal. The variant carries the M-ID via the `#[error]` string;
121 /// audit logs disambiguate via the cfg+token state.
122 #[error("M21/M22: aud value does not match expected audience")]
123 AudMismatch,
124
125 /// M23: `iss` is missing OR does not match the pinned issuer
126 /// (`cfg.issuer`). Both cases collapse into one variant — the audit
127 /// signal is identical: this token did not come from the trusted
128 /// issuer. Distinguishing missing-vs-wrong adds no useful diagnostic
129 /// (an attacker who omits iss and one who forges a wrong iss are
130 /// both probing for issuer trust confusion).
131 #[error("M23: iss missing or does not match pinned issuer")]
132 IssMismatch,
133
134 /// M24 (first clause): `iat` claim absent. Without an issuance
135 /// timestamp the engine can neither bound the token's age (M19) nor
136 /// enforce M24's "must be in past" rule. M24's future-iat clause
137 /// surfaces as `IatFuture`.
138 #[error("M24: iat claim missing")]
139 IatMissing,
140
141 /// M24 (second clause) + M25: `iat` is in the future beyond the 60s
142 /// clock-skew leeway. Phase 1's M06 documented-collapse pattern
143 /// applies — M24's must-be-in-past predicate and M25's far-future
144 /// ceiling enforce the same condition (`iat > now + 60s`); they
145 /// share the variant. Audit logs disambiguate via the iat value
146 /// itself (60s vs hours-in-the-future).
147 #[error("M24/M25: iat is in the future beyond 60s leeway")]
148 IatFuture,
149
150 /// M26: `nbf` (not-before) is present and in the future — the token
151 /// has not yet entered its admissibility window. Distinct from
152 /// `IatFuture`: nbf is the issuer's explicit "valid from" boundary,
153 /// where iat is the issuance instant. nbf is optional; absence is
154 /// not an error.
155 #[error("M26: nbf is in the future — token not yet valid")]
156 NotYetValid,
157
158 /// M27: `jti` claim absent. RFC 7519 §4.1.7 — the unique token
159 /// identifier is the replay-cache key (M35). Without it the engine
160 /// cannot enforce one-shot semantics on per-token operations.
161 #[error("M27: jti claim missing")]
162 JtiMissing,
163
164 /// M28: `sub` claim absent. RFC 7519 §4.1.2 — the subject identifies
165 /// the principal the token is about; no useful authorization
166 /// decision can follow when it's missing.
167 #[error("M28: sub claim missing")]
168 SubMissing,
169
170 /// M28a: `client_id` claim absent. RFC 9068 §2.2 mandates this for
171 /// access JWTs so the resource server can identify the originating
172 /// OAuth client (audit, per-client rate limits).
173 #[error("M28a: client_id claim missing")]
174 ClientIdMissing,
175
176 /// M29: `cat` (token category) is missing or not the expected value.
177 /// RFC 9068 §2.2 + ppoppo extension — `cat ∈ {access, refresh}` is a
178 /// payload-level discriminator that lets a single verifier refuse
179 /// type-confusion attempts (refresh token at an access endpoint).
180 /// Phase 2 verifies access tokens only; Phase 4 (refresh issuance)
181 /// generalizes via `cfg.expected_cat`. Missing-cat collapses into
182 /// the same variant — audit signal is identical (untrusted token
183 /// type).
184 #[error("M29: cat does not match expected token type")]
185 TokenTypeMismatch,
186
187 /// M30: a numeric claim (`exp`/`iat`/`nbf`) is present but not a
188 /// JSON integer. RFC 8725 §2.4 — string-coerced numerics are a
189 /// classic substitution vector (`"exp": "9999"` parsed as a "valid"
190 /// future expiry). Engine refuses any non-integer numeric claim
191 /// before the value-violation rules can fire.
192 #[error("M30: numeric claim is not a JSON integer")]
193 InvalidNumericType,
194
195 // ── D. Serialization (M31–M34) ────────────────────────────────────────
196 /// M31: input is JWS JSON serialization (or any non-compact form).
197 /// RFC 8725 §2.4 — the profile accepts JWS Compact only. JSON-form
198 /// JWS expands the implementation surface and has historically
199 /// carried polyglot-payload attacks; refuse before any segment
200 /// parser runs.
201 #[error("M31: JWS JSON serialization rejected — Compact only")]
202 JwsJsonRejected,
203
204 /// M32: header or payload JSON contains duplicate top-level keys.
205 /// RFC 7515 §3 mandates rejection, but serde_json silently keeps the
206 /// last occurrence by default — making the smuggling case (a forger
207 /// duplicates a claim hoping the verifier reads one value while a
208 /// downstream consumer reads another) invisible. Engine pre-validates
209 /// every JSON object via a key-uniqueness Visitor before parsing.
210 #[error("M32: JSON object contains duplicate keys")]
211 DuplicateJsonKeys,
212
213 /// M33: a segment contains characters from the standard base64
214 /// alphabet (`+`, `/`, `=`) — RFC 8725 §2.4 requires strict
215 /// `base64url` (URL_SAFE_NO_PAD: only `A-Z a-z 0-9 - _`). Standard
216 /// b64 chars are rejected with their own variant so audit logs
217 /// distinguish "intentional + injection" from generic decode
218 /// failures (which surface as `HeaderUnparseable` /
219 /// `PayloadUnparseable`).
220 #[error("M33: segment contains non-URL-safe base64 characters")]
221 LaxBase64,
222
223 /// M34: total token length exceeds `cfg.max_token_size` (8 KB
224 /// default for the access-token profile). A large token is either
225 /// a misconfigured issuer (extras bloating beyond a reasonable
226 /// claim set) or a denial-of-service vector (parser amplification).
227 /// Engine refuses oversized input before any segment parsing runs.
228 #[error("M34: token exceeds maximum size")]
229 OversizedToken,
230
231 // ── F. Domain (M39–M45) ───────────────────────────────────────────────
232 /// M39: `sub` is present but not a 26-character Crockford-base32 ULID.
233 /// PAS-issued tokens carry `ppnum_id` (Human ULID) or an AI-agent ULID
234 /// in `sub`; any other shape is either an issuer drift or a forgery
235 /// attempt. Distinct variant so audit logs distinguish "sub missing"
236 /// (M28) from "sub ill-formed" (M39).
237 #[error("M39: sub is not a valid ULID")]
238 SubFormatInvalid,
239
240 /// M40: `account_type` is present but not in the whitelist
241 /// `{"human", "ai_agent"}`. The claim is optional (legacy tokens
242 /// minted before the field existed are admitted), but a present-
243 /// but-unknown value is a forgery signal — the issuer never emits
244 /// arbitrary strings here. Renamed from the matrix's earlier
245 /// `actor` to avoid collision with RFC 8693 token-exchange.
246 #[error("M40: account_type outside whitelist")]
247 AccountTypeInvalid,
248
249 /// M41: `caps` is present but the wire shape is wrong (not a JSON
250 /// array of strings). The default-deny invariant lives in the
251 /// *interpretation*: absent/empty both mean "no capabilities", and
252 /// any non-empty value MUST be an array of strings. A string-typed
253 /// `caps: "admin"` is the canonical confusion — a forger hoping the
254 /// verifier reads it as a one-element list.
255 #[error("M41: caps is not a JSON array of strings")]
256 CapsShapeInvalid,
257
258 /// M42 (shape): `scopes` is present but not a JSON array of strings.
259 /// Mirrors `CapsShapeInvalid` — collapsed into its own variant
260 /// because the audit signal is meaningfully different (a scope
261 /// confusion attack reads a different threat model than a
262 /// capability shape attack).
263 #[error("M42: scopes is not a JSON array of strings")]
264 ScopesShapeInvalid,
265
266 /// M42 (length): `scopes` has more than 256 entries. Bounds the
267 /// per-token audit surface and stops a misconfigured issuer (or a
268 /// forger who got hold of a signing key) from minting a token whose
269 /// authorization vector is itself a DoS — a 10k-entry scopes array
270 /// pessimizes every per-request scope check.
271 #[error("M42: scopes exceeds 256-entry cap")]
272 ScopesTooLong,
273
274 /// M43: `dlg_depth` is present but exceeds the 4-step delegation
275 /// chain bound (or is the wrong wire shape — non-integer, negative).
276 /// Bounds the audit-trail explosion of arbitrarily deep Token
277 /// Exchange chains; `dlg_depth = 4` is the inclusive bound, matching
278 /// RFC §6.5. Single variant covers both shape and bound errors —
279 /// audit signal is "delegation chain rejected", and the value
280 /// itself surfaces in structured logging at the rejection site.
281 #[error("M43: dlg_depth invalid (non-integer, negative, or > 4)")]
282 DlgDepthInvalid,
283
284 /// M44 (band gate): token claims `admin: true` but the supporting
285 /// `active_ppnum` is either absent or its first 3 digits don't fall
286 /// in the admin allocation band. PAS issues admin tokens only on
287 /// ppnums minted from the admin band, so a token outside the band is
288 /// either a forgery (with a stolen signing key) or an issuer drift.
289 /// **This is defense-in-depth on top of `is_admin` DB lookup**
290 /// (STANDARDS_AUTH_PPOPPO §3.2 — DB is the source of truth); it
291 /// turns a stolen-key forgery into an "active_ppnum needs to be
292 /// banded" forgery, narrowing the attack window meaningfully.
293 #[error("M44: admin claim requires active_ppnum in admin band")]
294 AdminBandRejected,
295
296 /// M45: payload contains a claim outside the engine's strict
297 /// allowlist. The PAS issuance pipeline only emits claims listed
298 /// in `engine::check_domain::ALLOWED_CLAIMS`; anything else is a
299 /// forgery / misconfiguration / PII smuggling attempt (M45 is the
300 /// "no PII in payload" defense — `email` / `phone` / `name` get
301 /// rejected by name). The variant carries the offending claim
302 /// name for audit logs so operators can see at a glance which
303 /// claim triggered the rejection.
304 #[error("M45: unknown claim '{0}'")]
305 UnknownClaim(String),
306
307 // ── E. Replay / revocation (M35–M38) — Phase 5 ────────────────────────
308 /// M35: jti has been seen within the replay-cache TTL — replayed
309 /// token. Engine refuses on the second sighting; implementations of
310 /// `ReplayDefense` MUST treat the cache check and record as a single
311 /// atomic primitive (KVRocks `SET NX EX`, equivalent) to avoid a
312 /// TOCTOU window between check and record.
313 #[error("M35: jti replayed within TTL")]
314 JtiReplayed,
315
316 /// M35 (substrate transient): replay-cache substrate is unreachable.
317 /// Engine fails closed — admitting on substrate failure would let a
318 /// replayer slip through during the outage. Audit logs surface this
319 /// as a SEPARATE signal from `JtiReplayed` so ops can distinguish
320 /// "active attack" (replay) from "infrastructure issue" (cache down).
321 #[error("M35: replay cache substrate unavailable")]
322 ReplayCacheUnavailable,
323
324 /// M36: `(sub, sid)` row absent from `user_sessions` — the session
325 /// was revoked. STANDARDS_JWT_DETAILS_MITIGATION §E "row deletion =
326 /// revocation" — this is the textbook stateful-revocation gate that
327 /// makes the system "stateful by design" (OVERVIEW §6: `stateless
328 /// 환상 폐기`). Distinct from `SessionVersionStale` (account-wide
329 /// epoch) because this axis kicks one device while leaving the
330 /// account's other sessions alive.
331 #[error("M36: session revoked (user_sessions row absent)")]
332 SessionRevoked,
333
334 /// M36 (substrate transient): session-row lookup substrate is
335 /// unreachable. Engine fails closed.
336 #[error("M36: session lookup substrate unavailable")]
337 SessionLookupUnavailable,
338
339 /// sv-port (Phase 5): token's `sv` claim is strictly less than the
340 /// current per-account session_version. Break-glass / `LogoutAll`
341 /// bump `current_sv` inside the substrate; tokens minted before the
342 /// bump fail this gate within the cache TTL (60s default — see
343 /// `SV_CACHE_TTL`). PAS-internal callers preemptively flip
344 /// `sv:{ppnum_id}`; remote consumers (PCS chat-auth, pas-external
345 /// SDK) converge via the cache TTL.
346 #[error("session_version stale: token < current epoch")]
347 SessionVersionStale,
348
349 /// sv-port (substrate transient): `EpochRevocation::current` failed.
350 /// Engine fails closed.
351 #[error("session_version lookup substrate unavailable")]
352 SessionVersionLookupUnavailable,
353
354 // ── Parse / structural ────────────────────────────────────────────────
355 /// Token cannot be split into a JWS Compact form (3 segments).
356 #[error("token is not a JWS Compact serialization")]
357 NotJwsCompact,
358
359 /// Header segment cannot be base64url-decoded or is not valid JSON.
360 #[error("header is not valid JSON")]
361 HeaderUnparseable,
362
363 /// Payload segment cannot be base64url-decoded or is not valid JSON.
364 /// Mirrors `HeaderUnparseable` for the second segment — structural,
365 /// not an M-row enforcement.
366 #[error("payload is not valid JSON")]
367 PayloadUnparseable,
368}