Skip to main content

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}