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
//! Engine integration — M36 session-row liveness (Phase 5 commit 5.2).
//!
//! Verifies the `engine::verify` ↔ `cfg.session` wiring at the public
//! crate boundary. Standalone port behaviour (substrate-only) is in
//! `tests/ports_smoke.rs`; this file covers the issue-side `with_sid` →
//! verify-side `is_active` round-trip and the four short-circuit /
//! reject / fail-closed paths.

#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

mod common;

use std::sync::Arc;
use std::time::Duration;

use common::MemorySessionRevocation;
use ppoppo_token::access_token::{AuthError, IssueConfig, IssueRequest, SessionRevocation, VerifyConfig, issue, verify};
use ppoppo_token::{SigningKey};
const TEST_SUB: &str = "01HSAB00000000000000000000";
const TEST_CLIENT_ID: &str = "ppoppo-internal";
const TEST_SID: &str = "01HSESSION0000000000000000";
const TTL_15M: Duration = Duration::from_secs(900);
const ISSUER: &str = "https://accounts.ppoppo.com";
const AUDIENCE: &str = "ppoppo";

fn mint_token_with_sid(signer: &SigningKey, sid: &str) -> String {
    let issue_cfg = IssueConfig::access_token(ISSUER, AUDIENCE, signer.kid());
    let req = IssueRequest::new(TEST_SUB, TEST_CLIENT_ID, TTL_15M).with_sid(sid);
    issue(&req, &issue_cfg, signer, time::OffsetDateTime::now_utc().unix_timestamp()).expect("issue should succeed")
}

fn mint_token_without_sid(signer: &SigningKey) -> String {
    let issue_cfg = IssueConfig::access_token(ISSUER, AUDIENCE, signer.kid());
    let req = IssueRequest::new(TEST_SUB, TEST_CLIENT_ID, TTL_15M);
    issue(&req, &issue_cfg, signer, time::OffsetDateTime::now_utc().unix_timestamp()).expect("issue should succeed")
}

#[tokio::test]
async fn session_admits_when_cfg_session_is_none() {
    // Port not wired = gate disabled. Even tokens with `sid` admit
    // (legacy / sibling-test config — VerifyConfig has explicitly opted
    // out of the M36 gate).
    let (signer, key_set) = SigningKey::test_pair();
    let token = mint_token_with_sid(&signer, TEST_SID);
    let cfg = VerifyConfig::access_token(ISSUER, AUDIENCE);

    let claims = verify(&token, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp()).await.expect("verify must admit");
    assert_eq!(claims.sid.as_deref(), Some(TEST_SID), "sid must surface on Claims");
}

#[tokio::test]
async fn session_admits_when_token_has_no_sid() {
    // Token without `sid` is non-session-bound (AI-agent / machine
    // flow) — gate short-circuits even when port is wired. R6 admit
    // also covers pre-Phase-5 tokens that haven't yet been re-issued.
    let (signer, key_set) = SigningKey::test_pair();
    let token = mint_token_without_sid(&signer);
    let port = Arc::new(MemorySessionRevocation::new());
    let cfg = VerifyConfig::access_token(ISSUER, AUDIENCE).with_session_revocation(port);

    let claims = verify(&token, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp()).await.expect("verify must admit");
    assert!(claims.sid.is_none(), "sid must be None on Claims for non-session-bound token");
}

#[tokio::test]
async fn session_admits_active_sid() {
    // Happy path — port wired, token's sid is active in substrate, verify admits.
    let (signer, key_set) = SigningKey::test_pair();
    let token = mint_token_with_sid(&signer, TEST_SID);
    let port = Arc::new(MemorySessionRevocation::new());
    let cfg = VerifyConfig::access_token(ISSUER, AUDIENCE).with_session_revocation(port);

    let claims = verify(&token, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp()).await.expect("active sid must admit");
    assert_eq!(claims.sub, TEST_SUB);
    assert_eq!(claims.sid.as_deref(), Some(TEST_SID));
}

#[tokio::test]
async fn session_rejects_revoked_sid_with_session_revoked() {
    // The TDD red-signal test: revoke the (sub, sid) pair, then verify
    // the token. Engine must reject with `SessionRevoked` — the M36
    // axis distinct from M35 (replay) and sv (epoch).
    let (signer, key_set) = SigningKey::test_pair();
    let token = mint_token_with_sid(&signer, TEST_SID);
    let port = Arc::new(MemorySessionRevocation::new());
    port.revoke(TEST_SUB, TEST_SID);
    let cfg = VerifyConfig::access_token(ISSUER, AUDIENCE).with_session_revocation(port);

    let result = verify(&token, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(
        result,
        Err(AuthError::SessionRevoked),
        "revoked sid must reject as SessionRevoked",
    );
}

#[tokio::test]
async fn session_revoke_does_not_affect_sibling_sessions() {
    // M36 axis-purity guard: revoking sid-A must not affect sid-B
    // belonging to the same sub. This is the textbook distinction from
    // sv (account-wide epoch) — single-device kick keeps siblings alive.
    let (signer, key_set) = SigningKey::test_pair();
    let other_sid = "01HSESSION9999999999999999";
    let token_a = mint_token_with_sid(&signer, TEST_SID);
    let token_b = mint_token_with_sid(&signer, other_sid);

    let port = Arc::new(MemorySessionRevocation::new());
    port.revoke(TEST_SUB, TEST_SID); // kick only A
    let cfg = VerifyConfig::access_token(ISSUER, AUDIENCE).with_session_revocation(port);

    assert_eq!(
        verify(&token_a, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp()).await,
        Err(AuthError::SessionRevoked),
        "token A's sid was revoked",
    );
    verify(&token_b, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp()).await.expect("token B's sid still active");
}

#[tokio::test]
async fn session_substrate_transient_maps_to_session_lookup_unavailable() {
    // Fail-closed: substrate failure → engine refuses with the typed
    // ops-signal variant (NOT admit-on-failure). Distinct from
    // SessionRevoked so audit logs route differently.
    let (signer, key_set) = SigningKey::test_pair();
    let token = mint_token_with_sid(&signer, TEST_SID);
    let port = Arc::new(MemorySessionRevocation::failing());
    let cfg = VerifyConfig::access_token(ISSUER, AUDIENCE).with_session_revocation(port);

    let result = verify(&token, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp()).await;
    assert_eq!(
        result,
        Err(AuthError::SessionLookupUnavailable),
        "failing substrate must map to SessionLookupUnavailable, not admit",
    );
}

#[tokio::test]
async fn session_dyn_compatibility_via_arc_in_verify_config() {
    // Type-system regression guard for VerifyConfig's slot type.
    let port: Arc<dyn SessionRevocation> = Arc::new(MemorySessionRevocation::new());
    let _cfg = VerifyConfig::access_token(ISSUER, AUDIENCE).with_session_revocation(port);
}

#[tokio::test]
async fn session_with_sid_round_trips_to_claims_sid() {
    // Issue → verify round-trip: `IssueRequest::with_sid` value lands
    // unchanged on `Claims.sid` after verification. Regression guard
    // for the wire-format (encode.rs serde) + parse (check_domain.rs)
    // pair — drift between the two would silently lose the field.
    let (signer, key_set) = SigningKey::test_pair();
    let pinned_sid = "01H_ROUND_TRIP_SESSION_ID_X";
    let token = mint_token_with_sid(&signer, pinned_sid);
    let cfg = VerifyConfig::access_token(ISSUER, AUDIENCE);

    let claims = verify(&token, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp()).await.expect("verify");
    assert_eq!(
        claims.sid.as_deref(),
        Some(pinned_sid),
        "sid must round-trip from issue to verify unchanged",
    );
}