ppoppo-token 0.2.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
//! JWT verification engine — single entry point per STANDARDS_JWT_DETAILS §3.
//!
//! Every JWT verification flow in the workspace MUST funnel through
//! [`verify`]. Direct calls to `jsonwebtoken::decode` / `decode_header`
//! from outside this module are forbidden (M51/M52 lint, enforced in
//! Phase 7).
//!
//! Module layout reflects the per-area `check_*` discipline (D-09):
//!
//! - `check_algorithm` — M01-M06 (alg whitelist + per-request pinning)
//! - `check_header`    — M07-M16a (jku/x5u/jwk/x5c/crit/kid/typ/cty/JWE/extras/b64)
//! - `check_claims`    — M17-M30 + M32 (claims required/value + JSON dup keys)
//! - `check_domain`    — M39-M45 (ppoppo-specific domain rules) [Phase 4]
//! - `check_replay`    — M35 (jti replay-cache enforcement)        [Phase 5]
//! - `check_session`   — M36 (session-row liveness via sid)         [Phase 5]
//! - `check_epoch`     — sv-port (per-account session_version)      [Phase 5]
//! - `raw`             — base64url + JSON parsing helpers (M32 + M33)
//! Phase 2 wires signature verification implicitly via `check_claims`'s
//! payload parse; the dedicated cryptographic verification step lands
//! when the `jsonwebtoken::decode` integration ships in Phase 6 cutover.

pub(crate) mod check_acr;
pub(crate) mod check_algorithm;
pub(crate) mod check_at_hash;
pub(crate) mod check_auth_time;
pub(crate) mod check_azp;
pub(crate) mod check_c_hash;
pub(crate) mod check_claims;
pub(crate) mod check_domain;
pub(crate) mod check_epoch;
pub(crate) mod check_header;
pub(crate) mod check_id_token_cat;
pub(crate) mod check_id_token_pii;
pub(crate) mod check_nonce;
pub(crate) mod check_replay;
pub(crate) mod check_session;
pub(crate) mod encode;
pub(crate) mod encode_id_token;
pub(crate) mod raw;
// `hash_binding` sits with the engine primitives (not the `check_*` axis):
// it's a building block consumed by `check_at_hash` + `check_c_hash` (M67/M68)
// and by `id_token::issue::<S>` (Phase 10.10), not a verify-pipeline gate.
pub(crate) mod hash_binding;
pub(crate) mod shared_config;
pub(crate) mod shared_error;

use crate::access_token::{
    AuthError, Claims, IssueConfig, IssueError, IssueRequest, VerifyConfig,
};
use crate::engine::shared_error::SharedAuthError;
use crate::id_token::scopes::ScopeSet;
use crate::id_token::{
    IssueConfig as IdTokenIssueConfig, IssueError as IdTokenIssueError,
    IssueRequest as IdTokenIssueRequest,
};
use crate::{KeySet, SigningKey};

/// Verify a JWS Compact-serialized token against the configured policy.
///
/// Returns `Ok(claims)` when every active mitigation (Phase 2: M01-M34
/// minus replay/revocation) passes — the validated `Claims` payload is
/// the engine's contract with downstream consumers (audit logs, audit
/// resolvers, per-client rate limits).
///
/// Returns the **first** failing `AuthError` variant. Order matters:
/// cheaper structural checks fire before signature verification so a
/// malformed token never reaches the crypto path. Audit logs treat the
/// variant as the mitigation ID; do not swallow or remap.
///
/// `async fn` shape is locked in (D-11) so Phase 5 can add a replay-cache
/// trait parameter without rippling a sync→async change through every
/// call site.
///
/// ── M38 invariant: transport-blind by signature ─────────────────────────
///
/// `token` MUST be the bare JWS Compact string — three base64url-encoded
/// segments separated by `.` — with NO transport wrapper. The engine
/// does not strip `"Bearer "` prefixes, cookie attributes (`access_token=
/// ...; Path=/; Secure`), URL encoding, or any other framing.
/// Extraction from the wire is the *consumer middleware's*
/// responsibility — chat-auth, pas-external, and any future RP must
/// produce a bare token before calling `verify`.
///
/// This invariant is what STANDARDS_JWT_DETAILS_MITIGATION §E M38 codifies
/// as "Cookie + Bearer header treated as the same surface". The engine
/// achieves it *structurally*: the function signature carries no
/// transport hint, and no `check_*` submodule reads anything beyond
/// `(token, cfg, key_set)`. Adding a `transport_hint: Transport`
/// parameter — or any path-dependent branch inside `verify` — would
/// break M38 and require re-evaluating the test in
/// `tests/transport_equivalence.rs`.
pub async fn verify(
    token: &str,
    cfg: &VerifyConfig,
    key_set: &KeySet,
) -> Result<Claims, AuthError> {
    // M34: total token size cap. RFC 9068 access-profile uses 8 KB by
    // default (configurable via `cfg.max_token_size`). Refuses oversized
    // input before any segment parsing — parser amplification attacks
    // and misconfigured issuers both surface here.
    if token.len() > cfg.shared.max_token_size {
        return Err(AuthError::Jose(SharedAuthError::OversizedToken));
    }

    // M31: reject JWS JSON serialization (and any other non-compact
    // form that begins with a JSON object). The profile accepts JWS
    // Compact only — the JSON form expands the parser surface and has
    // historically carried polyglot-payload attacks.
    if token.starts_with('{') {
        return Err(AuthError::Jose(SharedAuthError::JwsJsonRejected));
    }

    // M15: structural — JWE compact has 5 segments, JWS has 3. Reject
    // before any per-segment parsing so a JWE never reaches the
    // JWS-shaped header decoder.
    if token.split('.').count() == 5 {
        return Err(AuthError::Jose(SharedAuthError::JwePayload));
    }
    // Algorithm before header: alg checks are the cheapest reject and the
    // most common attack vector (RFC 8725 §3.1). Audit logs carry the alg
    // signal even when the header surface also misbehaves.
    check_algorithm::run(token, &cfg.shared)?;
    check_header::run(token, &cfg.shared, key_set)?;
    let claims = check_claims::run(token, cfg)?;
    // M39-M45: ppoppo-specific domain rules. Runs after registered-claim
    // checks because the domain layer reads the validated `Claims` (and
    // re-parses the raw payload to populate surfaced domain fields like
    // `account_type`). Adding rows to the matrix happens inside
    // `check_domain::run`, never here — the verify entry's shape stays
    // constant across phases.
    let claims = check_domain::run(token, claims, cfg)?;
    // M35: jti replay-cache enforcement (Phase 5 commit 5.1). Runs AFTER
    // domain checks so a structurally invalid token never reaches the
    // substrate. Skips silently when `cfg.replay = None` (port opt-in).
    check_replay::run(token, &claims, cfg).await?;
    // M36: session-row liveness (Phase 5 commit 5.2). Per-session axis,
    // distinct from sv-port (account-wide epoch). Skips when
    // `cfg.session = None` (port opt-in) OR `claims.sid = None` (token
    // not session-bound).
    check_session::run(&claims, cfg).await?;
    // sv-port (Phase 5 commit 5.5). Per-account epoch axis — distinct
    // from `check_session` (per-row). Skips when `cfg.epoch = None` OR
    // the token carries no `sv` claim (R6 legacy admit / AI-agent path).
    check_epoch::run(token, &claims, cfg).await?;
    Ok(claims)
}

/// Issue a signed Compact JWS for the given request + config + key.
///
/// Mirrors `verify` on the issuance side. Order of operations:
///
/// 1. **kid match** — fail-fast on a misconfigured pipeline before any
///    encoding work. The `KeyMismatch` audit signal carries both kids
///    so operators can diagnose without a debugger.
/// 2. **clock sanity** — refuse to emit if `now()` is before UNIX_EPOCH
///    (cannot happen on a correctly configured machine; the check
///    exists so the engine fails closed rather than emitting garbage
///    timestamps).
/// 3. **payload assembly** via `encode::IssuePayload::build`.
/// 4. **header construction** — pin `alg=EdDSA`, `typ=cfg.typ` (`at+jwt`
///    for access), `kid=cfg.kid`. Forbidden headers (`jku`/`x5u`/`jwk`/
///    `x5c`/`crit`/extras) are never set; the invariant test in
///    `tests/issue_invariants.rs::issue_emits_only_alg_typ_kid_in_header`
///    is the regression guard.
/// 5. **encode** via `jsonwebtoken::encode` — the only call site for
///    `jsonwebtoken::*` on the issue path; the M51 "no jsonwebtoken
///    outside engine/" lint accommodates this single use site.
///
/// `issue` stays sync (D-11): no I/O on the issuance path.
pub fn issue(
    req: &IssueRequest,
    cfg: &IssueConfig,
    key: &SigningKey,
) -> Result<String, IssueError> {
    if cfg.kid != key.kid() {
        return Err(IssueError::KeyMismatch {
            cfg_kid: cfg.kid.clone(),
            signer_kid: key.kid().to_string(),
        });
    }

    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    if now < 0 {
        return Err(IssueError::ClockBackwards);
    }

    let payload = encode::IssuePayload::build(req, cfg, now);

    let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
    header.typ = Some(cfg.typ.to_string());
    header.kid = Some(cfg.kid.clone());

    jsonwebtoken::encode(&header, &payload, key.encoding())
        .map_err(|e| IssueError::JsonEncode(e.to_string()))
}

/// Issue a signed Compact JWS for an OIDC id_token (Phase 10.10).
///
/// Profile-aware mirror of [`issue`] — same 5-step assembly (kid match
/// → clock sanity → payload build → header pin → encode), differs only
/// in (a) the payload is profile-narrowed via
/// `S: ScopeSet` and built by [`encode_id_token::IssuePayload::build`]
/// (which runs the β1 runtime allowlist guard before serialization);
/// (b) `cfg.typ` is `"JWT"` and `cfg.cat` is `"id"` (constructor-pinned
/// in [`crate::id_token::IssueConfig::id_token`]).
///
/// Returns `Err(IssueError::EmissionDisallowed(name))` if the
/// IssueRequest carries a populated PII field outside `S::names()` —
/// the runtime mirror of M72 prevents the engine from emitting a claim
/// it would refuse to accept on the verify side. This is the
/// defense-in-depth path for hostile struct-literal bypass; correct
/// builder use ensures the gate never fires.
///
/// `issue_id_token` stays sync (D-11): no I/O on the issuance path.
pub fn issue_id_token<S: ScopeSet>(
    req: &IdTokenIssueRequest<S>,
    cfg: &IdTokenIssueConfig,
    key: &SigningKey,
) -> Result<String, IdTokenIssueError> {
    if cfg.kid != key.kid() {
        return Err(IdTokenIssueError::KeyMismatch {
            cfg_kid: cfg.kid.clone(),
            signer_kid: key.kid().to_string(),
        });
    }

    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    if now < 0 {
        return Err(IdTokenIssueError::ClockBackwards);
    }

    let payload = encode_id_token::IssuePayload::build(req, cfg, now)?;

    let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::EdDSA);
    header.typ = Some(cfg.typ.to_string());
    header.kid = Some(cfg.kid.clone());

    jsonwebtoken::encode(&header, &payload, key.encoding())
        .map_err(|e| IdTokenIssueError::JsonEncode(e.to_string()))
}