ppoppo-token 0.49.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
//! Registered-claim checks for the id_token profile — the access profile's
//! M17, M18, M20-M25 and M28 (with M30 numeric typing), as OIDC Core
//! §3.1.3.7 steps 2, 3, 9 and 10 require of every relying party.
//!
//! ── Why not `check_claims` ──────────────────────────────────────────────
//!
//! `check_claims::run` is the access profile's pipeline: it returns
//! `access_token::{Claims, AuthError}` and bakes in RFC 9068's `jti` and
//! `client_id` mandates and the 24h `exp` bound (M19), none of which an id
//! token carries. The rules the two profiles share are a comparison or two
//! each; this module states them over the id_token's own error type so the
//! access path stays untouched.
//!
//! ── What is not here ────────────────────────────────────────────────────
//!
//! `jti` (M27): PAS mints id tokens without one, and OIDC Core §2 does not
//! require it. `nbf` (M26): outside every scope's M72 allowlist, so a token
//! carrying it is refused later as `UnknownClaim("nbf")`.
//!
//! ── Order ───────────────────────────────────────────────────────────────
//!
//! Runs after M74 (`cat`) and before M66 (`nonce`): every later gate — M69
//! in particular, which reasons over `aud` cardinality — sees a token
//! already known to be current, issued by this OP and addressed to this RP.
//! Within the step, the access profile's order: exp, iat, aud, iss, iat
//! skew, sub.

use crate::id_token::{AuthError, VerifyConfig};

/// Clock-skew leeway for an `iat` in the future (M25), as on the access path.
const IAT_LEEWAY_SECS: i64 = 60;

/// The registered claims, validated — the only source `id_token::verify`
/// builds `Claims<S>`'s registered fields from.
pub(crate) struct RegisteredClaims {
    pub(crate) iss: String,
    pub(crate) sub: String,
    pub(crate) aud: Vec<String>,
    pub(crate) exp: i64,
    pub(crate) iat: i64,
}

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

pub(crate) fn run(
    payload: &serde_json::Value,
    cfg: &VerifyConfig,
    now: i64,
) -> Result<RegisteredClaims, AuthError> {
    // M17 + M30: `exp` required AND a JSON integer.
    let exp = require_numeric(payload, "exp", AuthError::ExpMissing)?;

    // M18: the current time MUST be before `exp` (§3.1.3.7 step 9), so a
    // token at `exp == now` is expired; leeway = 0.
    if exp <= now {
        return Err(AuthError::Expired);
    }

    // M24 first clause + M30: `iat` required AND a JSON integer.
    let iat = require_numeric(payload, "iat", AuthError::IatMissing)?;

    // M20: `aud` required. RFC 7519 §4.1.3 allows a string or an array of
    // strings; any other shape cannot name this RP.
    let aud = match payload.get("aud").ok_or(AuthError::AudMissing)? {
        serde_json::Value::String(one) => vec![one.clone()],
        serde_json::Value::Array(many) => many
            .iter()
            .map(|v| v.as_str().map(str::to_owned))
            .collect::<Option<Vec<_>>>()
            .ok_or(AuthError::AudMismatch)?,
        _ => return Err(AuthError::AudMismatch),
    };

    // M21 + M22: `aud` contains this RP's client_id (§3.1.3.7 step 3).
    if !aud.contains(&cfg.shared.audience) {
        return Err(AuthError::AudMismatch);
    }

    // M23: `iss` MUST equal the pinned issuer (§3.1.3.7 step 2).
    let iss = payload
        .get("iss")
        .and_then(|v| v.as_str())
        .filter(|iss| *iss == cfg.shared.issuer)
        .ok_or(AuthError::IssMismatch)?;

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

    // M28: `sub` required and non-empty — the End-User the token is about.
    let sub = payload
        .get("sub")
        .and_then(|v| v.as_str())
        .filter(|sub| !sub.is_empty())
        .ok_or(AuthError::SubMissing)?;

    Ok(RegisteredClaims {
        iss: iss.to_owned(),
        sub: sub.to_owned(),
        aud,
        exp,
        iat,
    })
}