ppoppo-token 0.4.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 RP-Initiated Logout 1.0 §2 — `id_token_hint` validation.
//!
//! A logout hint is an id_token the RP previously received, replayed at the
//! OP's `end_session_endpoint` to identify the End-User's session. This is a
//! sibling of [`super::verify`] but a *distinct* entry-point because the two
//! deliberately diverge:
//!
//! * **`exp` is ignored.** Access/id tokens are short-lived (≈1h), so a
//!   logged-in user's most recent id_token is routinely expired by logout
//!   time. OIDC RP-Initiated Logout §2 treats the id_token as a *hint*;
//!   refusing an expired one would make logout fail exactly when needed.
//! * **`nonce` is ignored.** The RP-minted nonce (M66) is an auth-request
//!   artifact not available at logout. [`super::verify`] *requires*
//!   `expected_nonce`, so it cannot serve this path.
//!
//! What it DOES enforce — the security floor: the EdDSA signature (the token
//! was issued by THIS OP), `iss`, the id_token profile marker (`cat="id"`,
//! so an access token cannot be replayed as a logout hint), and presence of
//! `sub`. `aud` is *returned, not pinned* — the signature already proves the
//! OP issued the token for that audience, and the OP uses `aud` to select
//! which RP's `post_logout_redirect_uri` allowlist applies.
//!
//! Keeping this inside the engine preserves the M51/M52 invariant: every
//! path that verifies an id_token's JWS goes through the engine, never
//! through a consumer-side decode.

use super::AuthError;
use crate::algorithm::Algorithm;
use crate::engine::shared_config::SharedVerifyConfig;
use crate::engine::{check_algorithm, check_header, check_id_token_cat, raw};
use crate::{KeySet, SharedAuthError};

/// Subject identity extracted from a validated `id_token_hint`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogoutHint {
    /// OIDC `sub` — the End-User the RP is logging out (PAS `ppnum_id`).
    pub sub: String,
    /// OIDC `aud` — the RP `client_id`(s) the id_token was issued for. The
    /// OP uses this to select the `post_logout_redirect_uri` allowlist.
    pub aud: Vec<String>,
}

/// Errors from `id_token_hint` validation (OIDC RP-Initiated Logout §2).
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum LogoutHintError {
    /// `iss` claim absent or not equal to the OP's issuer. A hint signed by
    /// THIS OP must name THIS OP; mismatch = foreign token or forgery.
    #[error("id_token_hint: issuer mismatch")]
    IssuerMismatch,

    /// `sub` claim absent or empty — the hint cannot identify a session.
    #[error("id_token_hint: subject claim absent")]
    SubjectMissing,

    /// id_token profile assertion failed (`cat != "id"`) — e.g. an access
    /// token replayed as a logout hint. Carried from
    /// [`check_id_token_cat`](crate::engine::check_id_token_cat).
    #[error(transparent)]
    Profile(#[from] AuthError),

    /// JOSE wire-format / signature error from the shared engine pipeline
    /// (oversize, JWE/JSON shape, algorithm whitelist, header, `kid`
    /// resolution, signature). `exp` leniency is NOT signature leniency.
    #[error(transparent)]
    Jose(#[from] SharedAuthError),
}

/// Verify an `id_token_hint` for RP-Initiated Logout. See the module docs
/// for the deliberate `exp` / `nonce` exemptions.
///
/// `expected_issuer` is the OP's pinned issuer; `key_set` is the OP's active
/// verifying keys (the same set `/.well-known/jwks.json` advertises).
pub fn verify_logout_hint(
    token: &str,
    key_set: &KeySet,
    expected_issuer: &str,
) -> Result<LogoutHint, LogoutHintError> {
    // Canonical id_token JOSE params, mirroring `VerifyConfig::id_token`:
    // typ pinned to `JWT`, 8 KB size cap, EdDSA whitelist. The `audience`
    // field is unused by the JOSE pipeline (`check_algorithm` /
    // `check_header` read only typ / size / alg / kid), so a sentinel
    // satisfies the shared constructor — `aud` is extracted, not pinned.
    let shared = SharedVerifyConfig::new(
        expected_issuer.to_owned(),
        String::new(),
        "JWT",
        8 * 1024,
        Algorithm::ALL.to_vec(),
    );

    // M34 size cap, M31 JWS-JSON reject, M15 JWE reject — mirror `verify`.
    if token.len() > shared.max_token_size {
        return Err(SharedAuthError::OversizedToken.into());
    }
    if token.starts_with('{') {
        return Err(SharedAuthError::JwsJsonRejected.into());
    }
    if token.split('.').count() == 5 {
        return Err(SharedAuthError::JwePayload.into());
    }

    // M01-M16a algorithm + header. `check_header` resolves the `kid`
    // against `key_set` and verifies the EdDSA signature.
    check_algorithm::run(token, &shared)?;
    check_header::run(token, &shared, key_set)?;

    let payload = raw::parse_payload_json(token)?;

    // The hint must be an id_token (`cat="id"`), not an access token.
    check_id_token_cat::run(&payload)?;

    // `iss` must equal the OP issuer. (exp/nonce intentionally unchecked.)
    if payload.get("iss").and_then(|v| v.as_str()) != Some(expected_issuer) {
        return Err(LogoutHintError::IssuerMismatch);
    }

    let sub = payload
        .get("sub")
        .and_then(|v| v.as_str())
        .filter(|s| !s.is_empty())
        .ok_or(LogoutHintError::SubjectMissing)?
        .to_owned();

    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(),
    };

    Ok(LogoutHint { sub, aud })
}