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
//! M71 — id_token `acr` (Authentication Context Class Reference) gate
//! (OIDC Core 1.0 §3.1.3.7, §5.5.1.1).
//!
//! When the RP requested specific `acr_values` in its auth request,
//! the returned id_token's `acr` claim MUST be one of those values.
//! This is the step-up authentication mechanism: an RP that requires
//! MFA for sensitive operations declares e.g. `acr_values=urn:mace:incommon:iap:silver`
//! and the engine refuses tokens issued at a weaker level.
//!
//! ── Opt-in semantics ────────────────────────────────────────────────────
//!
//! Engine inspects `acr` only when `cfg.acr_values` is `Some(allowlist)`.
//! RPs that did not request a specific level leave it unset and the
//! engine returns `Ok(())` without touching the claim. Symmetric to
//! M70: step-up is conditional on the verifier asking for it.
//!
//! ── Case sensitivity ────────────────────────────────────────────────────
//!
//! `==` comparison only. OIDC Core §5.5.1.1 specifies `acr` as "a string"
//! whose values are by convention case-sensitive URNs (e.g.
//! `urn:mace:incommon:iap:silver`, `urn:mace:incommon:iap:bronze`,
//! `phr`, `phrh`). Case-folding via `eq_ignore_ascii_case` would
//! silently admit tokens whose acr was intended as a *weaker* level —
//! a substitution-style step-up bypass. No `eq_ignore_ascii_case` here;
//! that is enforced by the negative test
//! `tests/id_token_negative.rs::acr_not_in_values_returns_acr_not_allowed`
//! plus the grep-based pre-flight check.
//!
//! ── Strictness when opted in ────────────────────────────────────────────
//!
//! Once `acr_values` is set, missing `acr` is a hard refusal
//! (`AcrMissing`). The §3.1.3.7 wording is "the Client SHOULD check
//! the `acr` Claim value" but the engine elevates SHOULD → MUST per
//! Phase 7 strict-by-default — same philosophy as M69 azp single-aud.

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

pub(crate) fn run(payload: &serde_json::Value, cfg: &VerifyConfig) -> Result<(), AuthError> {
    let Some(values) = cfg.acr_values.as_deref() else {
        return Ok(());
    };
    let acr = payload
        .get("acr")
        .and_then(|v| v.as_str())
        .ok_or(AuthError::AcrMissing)?;
    if !values.iter().any(|v| v == acr) {
        return Err(AuthError::AcrNotAllowed);
    }
    Ok(())
}