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
//! Claims attack-surface checks — M17-M30 (RFC 8725 §3.10-§3.11, RFC 7519,
//! RFC 9068).
//!
//! Strategy: parse the payload segment as raw JSON (mirrors
//! `check_header.rs`). Each missing/value-violation maps to a specific
//! `AuthError` variant — no generic "bad claim" rejection. The engine
//! orders checks specificity-first so audit logs carry the precise signal
//! even when multiple violations co-occur.
//!
//! Signature verification is NOT done here. Sub-cycle C (commits 2.16-2.20)
//! wires `jsonwebtoken::decode` to consume the same payload after this
//! validation pass, folding signature verification into the deserialize
//! step the public `verify` entry will own.

use crate::access_token::{AuthError, Claims, VerifyConfig};
use crate::engine::raw::parse_payload_json;

/// Wire-shape representation of the `aud` claim — RFC 7519 §4.1.3 allows
/// either a single string or an array of strings. Hidden inside this
/// module: the public `Claims` surface never exposes `aud` (Phase 2
/// design memory Decision 1), so callers don't pay the cost of matching
/// on the variation.
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum AudOnWire {
    One(String),
    Many(Vec<String>),
}

impl AudOnWire {
    fn matches(&self, expected: &str) -> bool {
        match self {
            Self::One(s) => s == expected,
            Self::Many(v) => v.iter().any(|s| s == expected),
        }
    }
}

/// Extract a required numeric claim. Missing → `missing` (per-claim
/// variant). Present-but-not-integer → `InvalidNumericType` (M30).
fn require_numeric(
    payload: &serde_json::Value,
    key: &str,
    missing: AuthError,
) -> Result<i64, AuthError> {
    let value = payload.get(key).ok_or(missing)?;
    value.as_i64().ok_or(AuthError::InvalidNumericType)
}

/// Extract an optional numeric claim. Absent → `Ok(None)`. Present and
/// integer → `Ok(Some(_))`. Present but not integer → `InvalidNumericType`
/// (M30 — non-integer numerics are a substitution vector even when the
/// claim itself is optional).
fn optional_numeric(payload: &serde_json::Value, key: &str) -> Result<Option<i64>, AuthError> {
    match payload.get(key) {
        None => Ok(None),
        Some(v) => v.as_i64().map(Some).ok_or(AuthError::InvalidNumericType),
    }
}

pub(crate) fn run(token: &str, cfg: &VerifyConfig) -> Result<Claims, AuthError> {
    let payload = parse_payload_json(token)?;

    // M17 + M30: `exp` required AND must be a JSON integer.
    let exp = require_numeric(&payload, "exp", AuthError::ExpMissing)?;

    // M18: `exp` validated, leeway = 0.
    let now = time::OffsetDateTime::now_utc().unix_timestamp();
    if exp < now {
        return Err(AuthError::Expired);
    }

    // M24 first clause + M30: `iat` required AND must be a JSON integer.
    // Extracted early so M19's exp/iat bound can reuse the value.
    let iat = require_numeric(&payload, "iat", AuthError::IatMissing)?;

    // M19: per-profile `exp` upper bound (24h for access; refresh's 200d
    // ships Phase 4).
    const ACCESS_EXP_MAX_SECS: i64 = 24 * 3600;
    if exp.saturating_sub(iat) > ACCESS_EXP_MAX_SECS {
        return Err(AuthError::ExpUpperBound);
    }

    // M20: `aud` required.
    let aud_value = payload.get("aud").ok_or(AuthError::AudMissing)?;

    // M21 + M22: `aud` matches `cfg.shared.audience`. Wire-shape variation
    // hidden inside `AudOnWire` — callers never see `OneOrMany`.
    let aud: AudOnWire =
        serde_json::from_value(aud_value.clone()).map_err(|_| AuthError::AudMismatch)?;
    if !aud.matches(&cfg.shared.audience) {
        return Err(AuthError::AudMismatch);
    }

    // M23: `iss` MUST equal the pinned issuer.
    let iss = payload
        .get("iss")
        .and_then(|v| v.as_str())
        .ok_or(AuthError::IssMismatch)?;
    if iss != cfg.shared.issuer {
        return Err(AuthError::IssMismatch);
    }

    // M24 second clause + M25: `iat` ≤ now + 60s leeway.
    const IAT_LEEWAY_SECS: i64 = 60;
    if iat > now + IAT_LEEWAY_SECS {
        return Err(AuthError::IatFuture);
    }

    // M26 + M30: `nbf` optional but typed.
    let nbf = optional_numeric(&payload, "nbf")?;
    if let Some(n) = nbf
        && n > now
    {
        return Err(AuthError::NotYetValid);
    }

    // M27: `jti` required.
    let jti = payload
        .get("jti")
        .and_then(|v| v.as_str())
        .ok_or(AuthError::JtiMissing)?;

    // M28: `sub` required.
    let sub = payload
        .get("sub")
        .and_then(|v| v.as_str())
        .ok_or(AuthError::SubMissing)?;

    // M28a: `client_id` required (RFC 9068 §2.2 access-JWT mandate).
    let client_id = payload
        .get("client_id")
        .and_then(|v| v.as_str())
        .ok_or(AuthError::ClientIdMissing)?;

    // M29: `cat` token-type discriminator. Phase 2 is access-only.
    let cat = payload.get("cat").and_then(|v| v.as_str()).unwrap_or("");
    if cat != "access" {
        return Err(AuthError::TokenTypeMismatch);
    }

    Ok(Claims {
        iss: iss.to_string(),
        sub: sub.to_string(),
        exp,
        iat,
        nbf,
        jti: jti.to_string(),
        client_id: client_id.to_string(),
        // Domain claims (Phase 4 — M40+) are populated by `check_domain`
        // after this function returns. Defaulting to None / empty keeps
        // a single construction site for `Claims` while letting the next
        // checker overwrite without revisiting registered-claim plumbing.
        account_type: None,
        caps: Vec::new(),
        scopes: Vec::new(),
        admin: false,
        active_ppnum: None,
        delegator: None,
        cid: None,
        sid: None,
    })
}