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
//! OIDC id_token verification entry-point.
//!
//! Mirrors `access_token::verify` but narrows the result type to
//! `Claims<S>` per scope marker. JOSE wire-format checks (M01-M16a,
//! M31-M34, M15, structural) run via the shared engine pipeline;
//! id-token-specific checks (M66 nonce here, M67-M73 in subsequent
//! sessions) follow.
//!
//! ── 10.1.D scope ────────────────────────────────────────────────────────
//!
//! This skeleton wires the JOSE pipeline + M66 only. Registered-claim
//! validation (id_token's analogue of `check_claims` — exp/iat/iss/aud
//! enforcement, plus the OIDC-specific azp/auth_time/acr axes) lands in
//! Sessions C-D (10.5-10.7). Per-scope PII deserialization narrowing
//! (M72) lands Session E (10.8). Until then, PII fields populate
//! unconditionally from the wire and the type system narrows visibility
//! at the *accessor* layer (`Claims<scopes::Openid>::email()` does not
//! compile).

use std::marker::PhantomData;

use super::scopes::ScopeSet;
use super::{AuthError, AddressClaim, Claims, VerifyConfig};
use crate::engine::shared_error::SharedAuthError;
use crate::engine::{
    check_acr, check_algorithm, check_at_hash, check_auth_time, check_azp, check_c_hash,
    check_header, check_id_token_cat, check_id_token_pii, check_nonce, raw,
};
use crate::KeySet;

/// Verify a JWS Compact-serialized id_token against the configured policy.
///
/// `S: ScopeSet` is the type-level scope witness. The engine constructs
/// `Claims<S>` regardless of the token's actual `scope` claim — narrowing
/// landed in M72 (Phase 10.8) will refuse a `Claims<EmailProfile>`
/// construction when the token's scope is just `openid email`. For 10.1
/// the type bound is the sole guard, enforced syntactically by the
/// scope-bounded accessor `impl` blocks in `claims.rs`.
pub async fn verify<S: ScopeSet>(
    token: &str,
    cfg: &VerifyConfig,
    key_set: &KeySet,
    now: i64,
) -> Result<Claims<S>, AuthError> {
    // M34: total token size cap. Mirrors `access_token::verify`.
    if token.len() > cfg.shared.max_token_size {
        return Err(AuthError::Jose(SharedAuthError::OversizedToken));
    }

    // M31: reject JWS JSON serialization.
    if token.starts_with('{') {
        return Err(AuthError::Jose(SharedAuthError::JwsJsonRejected));
    }

    // M15: structural — JWE compact has 5 segments, JWS has 3.
    if token.split('.').count() == 5 {
        return Err(AuthError::Jose(SharedAuthError::JwePayload));
    }

    // M01-M06 algorithm + M07-M16a header (typ pinned to `JWT` via
    // SharedVerifyConfig). The shared engine pipeline reads only
    // `cfg.shared` — the id-token-specific axes (`expected_nonce`,
    // `max_age`, `acr_values`) never reach the wire-format layer.
    check_algorithm::run(token, &cfg.shared)?;
    check_header::run(token, &cfg.shared, key_set)?;

    // M32 + M33 fire inside `parse_payload_json`. From here on, claim
    // semantics are id_token-specific.
    let payload = raw::parse_payload_json(token)?;

    // M29-mirror (Phase 10.10) — id_token `cat` profile-routing assertion.
    // Fires before any typed gate so a wrong-profile token short-circuits
    // here rather than spending cycles on M66/M67/etc. Symmetric to
    // access-token's M29 in `engine::check_claims::run` lines 137-140.
    // See `engine::check_id_token_cat` for the full rationale.
    check_id_token_cat::run(&payload)?;

    // M66 — nonce binding (OIDC Core §3.1.3.7 step 11).
    check_nonce::run(&payload, &cfg.expected_nonce)?;

    // M67 — at_hash binding (OIDC Core §3.1.3.8). Opt-in: skipped when
    // `cfg.expected_access_token` is None. Hybrid + implicit flows
    // populate this; pure code flow leaves it unset.
    check_at_hash::run(&payload, cfg)?;

    // M68 — c_hash binding (OIDC Core §3.3.2.11). Opt-in: skipped when
    // `cfg.expected_authorization_code` is None. Hybrid flow populates
    // this. Order: at_hash before c_hash matches the OIDC Core §3.3.2.11
    // narrative (token-then-code) and gives audit logs a stable
    // first-failure axis when both happen to be wrong.
    check_c_hash::run(&payload, cfg)?;

    // M69 — azp (authorized party) binding (OIDC Core §2). Always-on:
    // multi-aud requires azp; azp present must equal client_id
    // regardless of aud cardinality. Sources expected client_id from
    // `cfg.shared.audience` (no separate field — single-tenant model).
    check_azp::run(&payload, cfg)?;

    // M70 — auth_time freshness (OIDC Core §3.1.3.7). Opt-in: skipped
    // when `cfg.max_age` is None. Once opted in, missing `auth_time`
    // is a hard refusal (no implicit fallback to `now`).
    check_auth_time::run(&payload, cfg, now)?;

    // M71 — acr step-up assertion (OIDC Core §3.1.3.7, §5.5.1.1).
    // Opt-in: skipped when `cfg.acr_values` is None. Case-sensitive `==`
    // (URN values are case-sensitive; case-folding would silently admit
    // step-up downgrades).
    check_acr::run(&payload, cfg)?;

    // M72 — per-scope PII allowlist (OIDC Core §5.4 wire-side narrowing).
    // Always-on: the entire `Claims<S>` shape exists to enforce per-scope
    // narrowing, so unlike M70/M71 there is no "skip-if-unset" path.
    // Fires AFTER every typed gate so specific-variant rejects (M66-M71)
    // get the precise audit signal first; the allowlist is the catch-net.
    check_id_token_pii::run(&payload, S::names())?;

    Ok(deserialize_claims::<S>(&payload))
}

/// Best-effort claim extraction. Phase 10.5-10.7 will replace this with
/// validated extraction (exp/iat/iss/aud enforcement). PII fields are
/// populated unconditionally from the wire — visibility narrowing is at
/// the accessor layer (`Claims<S>::email()` requires `S: HasEmail`).
fn deserialize_claims<S: ScopeSet>(payload: &serde_json::Value) -> Claims<S> {
    let s = |key: &str| payload.get(key).and_then(|v| v.as_str()).map(str::to_owned);
    let i = |key: &str| payload.get(key).and_then(|v| v.as_i64());
    let b = |key: &str| payload.get(key).and_then(|v| v.as_bool());

    let aud = match payload.get("aud") {
        Some(serde_json::Value::String(s)) => vec![s.clone()],
        Some(serde_json::Value::Array(a)) => a
            .iter()
            .filter_map(|v| v.as_str().map(str::to_owned))
            .collect(),
        _ => Vec::new(),
    };

    let amr = payload.get("amr").and_then(|v| v.as_array()).map(|a| {
        a.iter()
            .filter_map(|v| v.as_str().map(str::to_owned))
            .collect()
    });

    let address = payload
        .get("address")
        .and_then(|v| serde_json::from_value::<AddressClaim>(v.clone()).ok());

    Claims {
        iss: s("iss").unwrap_or_default(),
        sub: s("sub").unwrap_or_default(),
        aud,
        exp: i("exp").unwrap_or(0),
        iat: i("iat").unwrap_or(0),
        nonce: s("nonce").unwrap_or_default(),
        azp: s("azp"),
        auth_time: i("auth_time"),
        acr: s("acr"),
        amr,
        email: s("email"),
        email_verified: b("email_verified"),
        name: s("name"),
        given_name: s("given_name"),
        family_name: s("family_name"),
        middle_name: s("middle_name"),
        nickname: s("nickname"),
        preferred_username: s("preferred_username"),
        profile: s("profile"),
        picture: s("picture"),
        website: s("website"),
        gender: s("gender"),
        birthdate: s("birthdate"),
        zoneinfo: s("zoneinfo"),
        locale: s("locale"),
        updated_at: i("updated_at"),
        phone_number: s("phone_number"),
        phone_number_verified: b("phone_number_verified"),
        address,
        _scope: PhantomData,
    }
}