ppoppo-sdk-core 0.6.2

Internal shared primitives for the Ppoppo SDK family (pas-external, pas-plims, pcs-external) — verifier port, audit trait, session liveness port, OIDC discovery, perimeter Bearer-auth Layer kit, identity types. Not a stable public API; do not depend on this crate directly. Consume the SDK crates that re-export from it (e.g. `pas-external`).
Documentation
//! JWT (RFC 7519) access-token expiry utilities for client-side use.
//!
//! Pure helpers that decode the `exp` claim from a JWT in JWS Compact
//! Serialization (RFC 7515 §3.1) without verifying the signature. Safe
//! for client-side use where the goal is to proactively refresh before
//! the server rejects — verification is the server's responsibility.
//!
//! Hoisted from `ppoppo-chat-client::token_expiry` (RFC_202607162022
//! T-23c): [`TokenSource`](super::TokenSource) implementations use
//! [`parse_expiry`] as the source-side expiry hint (the JWT `exp` claim is
//! authoritative over a response's declared `expires_in`), and
//! [`TokenCache::with_initial_token`](super::TokenCache::with_initial_token)
//! uses it to seed the cache from an already-held token.

use jiff::Timestamp;

/// Refresh skew: consider a token expired this many seconds early to avoid
/// edge-case failures during network transit.
///
/// Single skew SSOT for the cache family —
/// [`TokenCacheConfig::default`](super::TokenCacheConfig) derives its
/// `refresh_skew` from this constant.
pub const EXPIRY_SKEW_SECS: i64 = 60;

/// Check if a JWT's `exp` claim has passed.
///
/// Returns `true` if expired (with `EXPIRY_SKEW_SECS` buffer) or if decoding
/// fails — the safe fallback is to trigger refresh rather than attempt a
/// connection that would be rejected.
///
/// `now` is the caller-captured wall-clock instant. Per RFC_2026-05-10
/// §3 Row 5 (single-capture-per-method), pure helpers take the captured
/// time as an argument so the time source stays at the caller's clock port.
pub fn is_expired(token: &str, now: Timestamp) -> bool {
    parse_expiry(token)
        .map(|exp| now + jiff::SignedDuration::from_secs(EXPIRY_SKEW_SECS) >= exp)
        .unwrap_or(true)
}

/// Extract the `exp` claim from a JWT in JWS Compact Serialization without
/// verifying the signature.
///
/// JWT layout: `<header_b64>.<payload_b64>.<signature_b64>` — three parts,
/// each base64url-no-pad. This function decodes only the payload, parses it
/// as JSON, and reads the `exp` claim as a NumericDate (RFC 7519 §4.1.4 —
/// integer seconds since the Unix epoch).
///
/// Returns `None` if the token is not a 3-segment JWS Compact token, if
/// base64 or JSON decoding fails, or if `exp` is missing / non-numeric /
/// out of range.
pub fn parse_expiry(token: &str) -> Option<Timestamp> {
    let mut segments = token.split('.');
    let _header = segments.next()?;
    let payload = segments.next()?;
    let _signature = segments.next()?;
    if segments.next().is_some() {
        return None;
    }

    use base64::Engine as _;
    let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(payload)
        .ok()?;

    let payload_json: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
    let exp_secs = payload_json.get("exp")?.as_i64()?;

    Timestamp::from_second(exp_secs).ok()
}

#[cfg(test)]
pub(crate) mod tests {
    use super::*;

    /// Builds a synthetic JWS Compact token (no signature verification will
    /// happen against this) with an `exp` claim of the given Unix timestamp.
    /// Header + signature segments contain placeholder bytes — the decoder
    /// only inspects the payload.
    pub(crate) fn make_fake_token(exp: Timestamp) -> String {
        use base64::Engine as _;
        let payload_json = format!(r#"{{"sub":"test","exp":{}}}"#, exp.as_second());
        let header_b64 =
            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA"}"#);
        let payload_b64 =
            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
        let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 64]);
        format!("{header_b64}.{payload_b64}.{signature_b64}")
    }

    #[test]
    fn is_expired_returns_true_for_non_jwt() {
        let now = Timestamp::now();
        assert!(is_expired("not-a-jwt", now));
        assert!(is_expired("only.two", now));
        assert!(is_expired("a.b.c.d", now));
        assert!(is_expired("", now));
    }

    #[test]
    fn is_expired_returns_true_for_legacy_paseto_format() {
        // Regression guard: a v4.public.{...} string is NOT a JWT and the
        // parser must reject it (returning None → treated as expired) rather
        // than try to decode it as JWT.
        assert!(is_expired("v4.public.abc", Timestamp::now()));
    }

    #[test]
    fn is_expired_returns_false_for_future_exp() {
        let now = Timestamp::now();
        let token = make_fake_token(now + jiff::SignedDuration::from_hours(1));
        assert!(!is_expired(&token, now));
    }

    #[test]
    fn is_expired_returns_true_for_past_exp() {
        let now = Timestamp::now();
        let token = make_fake_token(now - jiff::SignedDuration::from_hours(1));
        assert!(is_expired(&token, now));
    }

    #[test]
    fn is_expired_accounts_for_60s_skew() {
        let now = Timestamp::now();
        let token = make_fake_token(now + jiff::SignedDuration::from_secs(30));
        assert!(is_expired(&token, now));
        let token = make_fake_token(now + jiff::SignedDuration::from_secs(120));
        assert!(!is_expired(&token, now));
    }

    #[test]
    fn parse_expiry_rejects_string_exp() {
        // RFC 7519 §4.1.4 says exp is a NumericDate (integer seconds), not
        // an RFC 3339 string. PASETO emitted a string; JWT does not.
        use base64::Engine as _;
        let payload_json = r#"{"sub":"test","exp":"2027-01-01T00:00:00Z"}"#;
        let payload_b64 =
            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
        let token = format!("aGVhZGVy.{payload_b64}.c2ln");
        assert_eq!(parse_expiry(&token), None);
    }

    #[test]
    fn parse_expiry_returns_none_when_exp_missing() {
        use base64::Engine as _;
        let payload_b64 =
            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"sub":"x"}"#);
        let token = format!("aGVhZGVy.{payload_b64}.c2ln");
        assert!(parse_expiry(&token).is_none());
    }
}