1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
//! 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,
}