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
//! Shared raw-JSON header/payload parsing used by every `check_*` module.
//!
//! The library's `Header` struct (`jsonwebtoken::Header`) is a closed set of
//! fields — no `extras`/`crit`/`b64`. Any mitigation that inspects header
//! shape (M11/M16/M16a) needs the raw object; rather than parse twice, the
//! engine parses once here and shares the `serde_json::Value`.
//!
//! Errors are JOSE-shared: emitted as `SharedAuthError`. Profile-specific
//! callers wrap via their own `Jose(...)` carrier.

use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64};

use crate::engine::shared_error::SharedAuthError;

/// Pre-decode strict-base64url scan (M33). RFC 8725 §2.4 requires
/// URL_SAFE_NO_PAD only — standard base64's `+`, `/`, `=` would already
/// fail the engine's decoder (alphabet mismatch), but the decoder error
/// is the same generic `Unparseable` for any reason. Returning
/// `LaxBase64` here lets audit logs distinguish "intentional non-URL-safe
/// injection" from accidentally garbled tokens.
fn check_strict_base64url(s: &str) -> Result<(), SharedAuthError> {
    if s.bytes().any(|b| matches!(b, b'+' | b'/' | b'=')) {
        return Err(SharedAuthError::LaxBase64);
    }
    Ok(())
}

/// Detect duplicate top-level JSON keys (M32). serde_json's default
/// `Map` deserializer silently keeps the last occurrence, which would
/// hide the smuggling case where a forger duplicates a claim hoping the
/// verifier reads one value while a downstream consumer reads another.
/// This Visitor walks the keys with `IgnoredAny` for values, errors as
/// soon as it sees a repeat.
fn check_no_duplicate_top_keys(bytes: &[u8]) -> Result<(), SharedAuthError> {
    struct UniqueKeysVisitor;

    impl<'de> serde::de::Visitor<'de> for UniqueKeysVisitor {
        type Value = ();

        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
            f.write_str("a JSON object with unique keys")
        }

        fn visit_map<M: serde::de::MapAccess<'de>>(self, mut access: M) -> Result<(), M::Error> {
            let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
            while let Some(key) = access.next_key::<String>()? {
                if !seen.insert(key) {
                    return Err(serde::de::Error::custom("duplicate key"));
                }
                let _: serde::de::IgnoredAny = access.next_value()?;
            }
            Ok(())
        }
    }

    let mut deser = serde_json::Deserializer::from_slice(bytes);
    use serde::de::Deserializer;
    deser
        .deserialize_map(UniqueKeysVisitor)
        .map_err(|_| SharedAuthError::DuplicateJsonKeys)
}

pub(crate) fn parse_header_json(token: &str) -> Result<serde_json::Value, SharedAuthError> {
    let header_b64 = token
        .split('.')
        .next()
        .ok_or(SharedAuthError::NotJwsCompact)?;
    check_strict_base64url(header_b64)?;
    let bytes = B64
        .decode(header_b64)
        .map_err(|_| SharedAuthError::HeaderUnparseable)?;
    check_no_duplicate_top_keys(&bytes)?;
    serde_json::from_slice(&bytes).map_err(|_| SharedAuthError::HeaderUnparseable)
}

/// Parse the payload (second) segment as a JSON `Value`. Mirrors
/// `parse_header_json`'s pattern; shared because every claims-level
/// check needs the parsed object and we don't want to decode twice.
pub(crate) fn parse_payload_json(token: &str) -> Result<serde_json::Value, SharedAuthError> {
    let payload_b64 = token
        .split('.')
        .nth(1)
        .ok_or(SharedAuthError::NotJwsCompact)?;
    check_strict_base64url(payload_b64)?;
    let bytes = B64
        .decode(payload_b64)
        .map_err(|_| SharedAuthError::PayloadUnparseable)?;
    check_no_duplicate_top_keys(&bytes)?;
    serde_json::from_slice(&bytes).map_err(|_| SharedAuthError::PayloadUnparseable)
}