ppoppo-token 0.3.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
//! M70 — id_token `auth_time` freshness gate (OIDC Core 1.0 §3.1.3.7).
//!
//! When the RP's auth request specified a `max_age` parameter, the
//! returned id_token MUST carry an `auth_time` claim and the
//! verification MUST refuse the token if too much time has elapsed
//! since end-user authentication.
//!
//! ── Opt-in semantics ────────────────────────────────────────────────────
//!
//! Engine inspects `auth_time` only when `cfg.max_age` is `Some(n)`.
//! RPs that did not request a max_age window leave it unset and the
//! engine returns `Ok(())` without touching the claim. Symmetric to
//! M67/M68 (`with_access_token_binding` / `with_authorization_code_binding`):
//! freshness is conditional on the verifier asking for it.
//!
//! ── Strictness when opted in ────────────────────────────────────────────
//!
//! Once `max_age` is set, missing `auth_time` is a hard refusal
//! (`AuthTimeMissing`). The OIDC Core §3.1.3.7 wording is
//! "auth_time Claim is REQUIRED when max_age request was made"; the
//! engine rejects rather than defaulting to `now`. Stale tokens
//! (`now - auth_time > max_age`) yield `AuthTimeStale` — distinct
//! variant from missing because the operator response differs:
//! Stale = re-authenticate the user; Missing = IdP misconfiguration.
//!
//! ── Clock source ────────────────────────────────────────────────────────
//!
//! `now: i64` is injected from the verify pipeline (clock-port RFC Slice 7).
//! The caller (`id_token::verify`) receives `now` from its own caller,
//! propagating the single clock read all the way from the infrastructure layer.

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

pub(crate) fn run(
    payload: &serde_json::Value,
    cfg: &VerifyConfig,
    now: i64,
) -> Result<(), AuthError> {
    let Some(max_age) = cfg.max_age else {
        return Ok(());
    };
    let auth_time = payload
        .get("auth_time")
        .and_then(|v| v.as_i64())
        .ok_or(AuthError::AuthTimeMissing)?;
    if now - auth_time > max_age {
        return Err(AuthError::AuthTimeStale);
    }
    Ok(())
}