ppoppo_token/engine/mod.rs
1//! JWT verification engine — single entry point per STANDARDS_JWT_DETAILS §3.
2//!
3//! Every JWT verification flow in the workspace MUST funnel through
4//! [`verify`]. Direct calls to `jsonwebtoken::decode` / `decode_header`
5//! from outside this module are forbidden (M51/M52 lint, enforced in
6//! Phase 7).
7//!
8//! Module layout reflects the per-area `check_*` discipline (D-09):
9//!
10//! - `check_algorithm` — M01-M06 (alg whitelist + per-request pinning)
11//! - `check_header` — M07-M16a (jku/x5u/jwk/x5c/crit/kid/typ/cty/JWE/extras/b64)
12//! - `check_claims` — M17-M30 + M32 (claims required/value + JSON dup keys)
13//! - `check_domain` — M39-M45 (ppoppo-specific domain rules) [Phase 4]
14//! - `check_replay` — M35 (jti replay-cache enforcement) [Phase 5]
15//! - `check_session` — M36 (session-row liveness via sid) [Phase 5]
16//! - `check_epoch` — sv-port (per-account session_version) [Phase 5]
17//! - `raw` — base64url + JSON parsing helpers (M32 + M33)
18//! Phase 2 wires signature verification implicitly via `check_claims`'s
19//! payload parse; the dedicated cryptographic verification step lands
20//! when the `jsonwebtoken::decode` integration ships in Phase 6 cutover.
21
22pub(crate) mod check_acr;
23pub(crate) mod check_algorithm;
24pub(crate) mod check_at_hash;
25pub(crate) mod check_auth_time;
26pub(crate) mod check_azp;
27pub(crate) mod check_c_hash;
28pub(crate) mod check_claims;
29pub(crate) mod check_domain;
30pub(crate) mod check_epoch;
31pub(crate) mod check_header;
32pub(crate) mod check_id_token_cat;
33pub(crate) mod check_id_token_pii;
34pub(crate) mod check_nonce;
35pub(crate) mod check_replay;
36pub(crate) mod check_session;
37pub(crate) mod encode;
38pub(crate) mod encode_id_token;
39pub(crate) mod raw;
40// `hash_binding` sits with the engine primitives (not the `check_*` axis):
41// it's a building block consumed by `check_at_hash` + `check_c_hash` (M67/M68)
42// and by `id_token::issue::<S>` (Phase 10.10), not a verify-pipeline gate.
43pub(crate) mod hash_binding;
44pub(crate) mod shared_config;
45pub(crate) mod shared_error;
46
47use crate::access_token::{
48 AuthError, Claims, IssueConfig, IssueError, IssueRequest, VerifyConfig,
49};
50use crate::engine::shared_error::SharedAuthError;
51use crate::id_token::scopes::ScopeSet;
52use crate::id_token::{
53 IssueConfig as IdTokenIssueConfig, IssueError as IdTokenIssueError,
54 IssueRequest as IdTokenIssueRequest,
55};
56use crate::{KeySet, SigningKey};
57
58/// Verify a JWS Compact-serialized token against the configured policy.
59///
60/// Returns `Ok(claims)` when every active mitigation (Phase 2: M01-M34
61/// minus replay/revocation) passes — the validated `Claims` payload is
62/// the engine's contract with downstream consumers (audit logs, audit
63/// resolvers, per-client rate limits).
64///
65/// Returns the **first** failing `AuthError` variant. Order matters:
66/// cheaper structural checks fire before signature verification so a
67/// malformed token never reaches the crypto path. Audit logs treat the
68/// variant as the mitigation ID; do not swallow or remap.
69///
70/// `async fn` shape is locked in (D-11) so Phase 5 can add a replay-cache
71/// trait parameter without rippling a sync→async change through every
72/// call site.
73///
74/// ── M38 invariant: transport-blind by signature ─────────────────────────
75///
76/// `token` MUST be the bare JWS Compact string — three base64url-encoded
77/// segments separated by `.` — with NO transport wrapper. The engine
78/// does not strip `"Bearer "` prefixes, cookie attributes (`access_token=
79/// ...; Path=/; Secure`), URL encoding, or any other framing.
80/// Extraction from the wire is the *consumer middleware's*
81/// responsibility — chat-auth, pas-external, and any future RP must
82/// produce a bare token before calling `verify`.
83///
84/// This invariant is what STANDARDS_JWT_DETAILS_MITIGATION §E M38 codifies
85/// as "Cookie + Bearer header treated as the same surface". The engine
86/// achieves it *structurally*: the function signature carries no
87/// transport hint, and no `check_*` submodule reads anything beyond
88/// `(token, cfg, key_set)`. Adding a `transport_hint: Transport`
89/// parameter — or any path-dependent branch inside `verify` — would
90/// break M38 and require re-evaluating the test in
91/// `tests/transport_equivalence.rs`.
92pub async fn verify(
93 token: &str,
94 cfg: &VerifyConfig,
95 key_set: &KeySet,
96 now: i64,
97) -> Result<Claims, AuthError> {
98 // M34: total token size cap. RFC 9068 access-profile uses 8 KB by
99 // default (configurable via `cfg.max_token_size`). Refuses oversized
100 // input before any segment parsing — parser amplification attacks
101 // and misconfigured issuers both surface here.
102 if token.len() > cfg.shared.max_token_size {
103 return Err(AuthError::Jose(SharedAuthError::OversizedToken));
104 }
105
106 // M31: reject JWS JSON serialization (and any other non-compact
107 // form that begins with a JSON object). The profile accepts JWS
108 // Compact only — the JSON form expands the parser surface and has
109 // historically carried polyglot-payload attacks.
110 if token.starts_with('{') {
111 return Err(AuthError::Jose(SharedAuthError::JwsJsonRejected));
112 }
113
114 // M15: structural — JWE compact has 5 segments, JWS has 3. Reject
115 // before any per-segment parsing so a JWE never reaches the
116 // JWS-shaped header decoder.
117 if token.split('.').count() == 5 {
118 return Err(AuthError::Jose(SharedAuthError::JwePayload));
119 }
120 // Algorithm before header: alg checks are the cheapest reject and the
121 // most common attack vector (RFC 8725 §3.1). Audit logs carry the alg
122 // signal even when the header surface also misbehaves.
123 check_algorithm::run(token, &cfg.shared)?;
124 check_header::run(token, &cfg.shared, key_set)?;
125 let claims = check_claims::run(token, cfg, now)?;
126 // M39-M45: ppoppo-specific domain rules. Runs after registered-claim
127 // checks because the domain layer reads the validated `Claims` (and
128 // re-parses the raw payload to populate surfaced domain fields like
129 // `account_type`). Adding rows to the matrix happens inside
130 // `check_domain::run`, never here — the verify entry's shape stays
131 // constant across phases.
132 let claims = check_domain::run(token, claims, cfg)?;
133 // M35: jti replay-cache enforcement (Phase 5 commit 5.1). Runs AFTER
134 // domain checks so a structurally invalid token never reaches the
135 // substrate. Skips silently when `cfg.replay = None` (port opt-in).
136 check_replay::run(token, &claims, cfg, now).await?;
137 // M36: session-row liveness (Phase 5 commit 5.2). Per-session axis,
138 // distinct from sv-port (account-wide epoch). Skips when
139 // `cfg.session = None` (port opt-in) OR `claims.sid = None` (token
140 // not session-bound).
141 check_session::run(&claims, cfg).await?;
142 // sv-port (Phase 5 commit 5.5). Per-account epoch axis — distinct
143 // from `check_session` (per-row). Skips when `cfg.epoch = None` OR
144 // the token carries no `sv` claim (R6 legacy admit / AI-agent path).
145 check_epoch::run(token, &claims, cfg).await?;
146 Ok(claims)
147}
148
149/// Issue a signed Compact JWS for the given request + config + key.
150///
151/// Mirrors `verify` on the issuance side. Order of operations:
152///
153/// 1. **kid match** — fail-fast on a misconfigured pipeline before any
154/// encoding work. The `KeyMismatch` audit signal carries both kids
155/// so operators can diagnose without a debugger.
156/// 2. **clock sanity** — refuse to emit if `now()` is before UNIX_EPOCH
157/// (cannot happen on a correctly configured machine; the check
158/// exists so the engine fails closed rather than emitting garbage
159/// timestamps).
160/// 3. **payload assembly** via `encode::IssuePayload::build`.
161/// 4. **header construction** — pin `alg=EdDSA`, `typ=cfg.typ` (`at+jwt`
162/// for access), `kid=cfg.kid`. Forbidden headers (`jku`/`x5u`/`jwk`/
163/// `x5c`/`crit`/extras) are never set; the invariant test in
164/// `tests/issue_invariants.rs::issue_emits_only_alg_typ_kid_in_header`
165/// is the regression guard.
166/// 5. **encode** via `jsonwebtoken::encode` — the only call site for
167/// `jsonwebtoken::*` on the issue path; the M51 "no jsonwebtoken
168/// outside engine/" lint accommodates this single use site.
169///
170/// `issue` stays sync (D-11): no I/O on the issuance path.
171pub fn issue(
172 req: &IssueRequest,
173 cfg: &IssueConfig,
174 key: &SigningKey,
175 now: i64,
176) -> Result<String, IssueError> {
177 if cfg.kid != key.kid() {
178 return Err(IssueError::KeyMismatch {
179 cfg_kid: cfg.kid.clone(),
180 signer_kid: key.kid().to_string(),
181 });
182 }
183
184 if now < 0 {
185 return Err(IssueError::ClockBackwards);
186 }
187
188 let payload = encode::IssuePayload::build(req, cfg, now);
189
190 let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
191 header.typ = Some(cfg.typ.to_string());
192 header.kid = Some(cfg.kid.clone());
193
194 jsonwebtoken::encode(&header, &payload, key.encoding())
195 .map_err(|e| IssueError::JsonEncode(e.to_string()))
196}
197
198/// Issue a signed Compact JWS for an OIDC id_token (Phase 10.10).
199///
200/// Profile-aware mirror of [`issue`] — same 5-step assembly (kid match
201/// → clock sanity → payload build → header pin → encode), differs only
202/// in (a) the payload is profile-narrowed via
203/// `S: ScopeSet` and built by [`encode_id_token::IssuePayload::build`]
204/// (which runs the β1 runtime allowlist guard before serialization);
205/// (b) `cfg.typ` is `"JWT"` and `cfg.cat` is `"id"` (constructor-pinned
206/// in [`crate::id_token::IssueConfig::id_token`]).
207///
208/// Returns `Err(IssueError::EmissionDisallowed(name))` if the
209/// IssueRequest carries a populated PII field outside `S::names()` —
210/// the runtime mirror of M72 prevents the engine from emitting a claim
211/// it would refuse to accept on the verify side. This is the
212/// defense-in-depth path for hostile struct-literal bypass; correct
213/// builder use ensures the gate never fires.
214///
215/// `issue_id_token` stays sync (D-11): no I/O on the issuance path.
216pub fn issue_id_token<S: ScopeSet>(
217 req: &IdTokenIssueRequest<S>,
218 cfg: &IdTokenIssueConfig,
219 key: &SigningKey,
220 now: i64,
221) -> Result<String, IdTokenIssueError> {
222 if cfg.kid != key.kid() {
223 return Err(IdTokenIssueError::KeyMismatch {
224 cfg_kid: cfg.kid.clone(),
225 signer_kid: key.kid().to_string(),
226 });
227 }
228
229 if now < 0 {
230 return Err(IdTokenIssueError::ClockBackwards);
231 }
232
233 let payload = encode_id_token::IssuePayload::build(req, cfg, now)?;
234
235 let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
236 header.typ = Some(cfg.typ.to_string());
237 header.kid = Some(cfg.kid.clone());
238
239 jsonwebtoken::encode(&header, &payload, key.encoding())
240 .map_err(|e| IdTokenIssueError::JsonEncode(e.to_string()))
241}