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 — sv-port per-account epoch revocation
//! (Phase 5 commit 5.5).
//!
//! Verifies the `engine::verify` ↔ `cfg.epoch` wiring at the public crate
//! boundary. Standalone port behaviour (substrate-only) is in
//! `tests/ports_smoke.rs`. This file covers the `IssueRequest::with_session_version`
//! → verify-side `EpochRevocation::current` round-trip and the four
//! short-circuit / reject / fail-closed paths plus the M37 axis-purity
//! distinction (sv kicks every prior token; session kicks one row).

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

mod common;

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

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

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

fn mint_token_without_sv(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 epoch_admits_when_cfg_epoch_is_none() {
    // Port not wired = gate disabled. Even tokens with stale sv admit
    // because the engine has nothing to compare against. Sibling-test /
    // legacy VerifyConfigs land here.
    let (signer, key_set) = SigningKey::test_pair();
    let token = mint_token_with_sv(&signer, 1);
    let cfg = VerifyConfig::access_token(ISSUER, AUDIENCE);

    verify(&token, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp())
        .await
        .expect("None port must admit");
}

#[tokio::test]
async fn epoch_admits_when_token_has_no_sv() {
    // R6 legacy admit: AI-agent / `client_credentials` / pre-Phase-5
    // tokens carry no `sv`. Engine short-circuits without consulting
    // the substrate — those paths have no break-glass mechanism, so
    // there's nothing to compare against.
    let (signer, key_set) = SigningKey::test_pair();
    let token = mint_token_without_sv(&signer);
    let port = Arc::new(MemoryEpochRevocation::new());
    port.bump(TEST_SUB, 99); // even with a high current value
    let cfg = VerifyConfig::access_token(ISSUER, AUDIENCE).with_epoch_revocation(port);

    verify(&token, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp())
        .await
        .expect("token without sv must admit (R6)");
}

#[tokio::test]
async fn epoch_admits_when_token_sv_meets_current() {
    // Happy path. Substrate genesis = 0; token at sv=0 admits because
    // `token.sv >= current` (== 0). Same for sv=5 against current=5.
    let (signer, key_set) = SigningKey::test_pair();
    let token = mint_token_with_sv(&signer, 5);
    let port = Arc::new(MemoryEpochRevocation::new());
    port.bump(TEST_SUB, 5);
    let cfg = VerifyConfig::access_token(ISSUER, AUDIENCE).with_epoch_revocation(port);

    verify(&token, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp())
        .await
        .expect("token sv >= current must admit");
}

#[tokio::test]
async fn epoch_admits_when_token_sv_exceeds_current() {
    // Re-issuance race: substrate is at v3, token was just minted at v3
    // and the substrate is about to bump. Token must admit until the
    // bump propagates. `>=` semantics protect against this race.
    let (signer, key_set) = SigningKey::test_pair();
    let token = mint_token_with_sv(&signer, 10);
    let port = Arc::new(MemoryEpochRevocation::new()); // genesis 0
    let cfg = VerifyConfig::access_token(ISSUER, AUDIENCE).with_epoch_revocation(port);

    verify(&token, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp())
        .await
        .expect("token sv > current must admit");
}

#[tokio::test]
async fn epoch_rejects_stale_token_with_session_version_stale() {
    // The TDD red-signal test: bump substrate to a higher value, then
    // verify a token minted before the bump. Engine must reject with
    // `SessionVersionStale` — break-glass / LogoutAll just kicked it.
    let (signer, key_set) = SigningKey::test_pair();
    let token = mint_token_with_sv(&signer, 3);
    let port = Arc::new(MemoryEpochRevocation::new());
    port.bump(TEST_SUB, 7); // break-glass: old tokens (sv < 7) are dead
    let cfg = VerifyConfig::access_token(ISSUER, AUDIENCE).with_epoch_revocation(port);

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

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

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

#[tokio::test]
async fn epoch_axis_distinct_from_session() {
    // Axis-purity guard: bumping sv kicks ALL prior tokens for the sub
    // (account-wide), where `check_session` kicks ONE (sub, sid) row.
    // This test mints two tokens with different sv values and proves
    // the bump-cutoff is per-account, not per-row.
    let (signer, key_set) = SigningKey::test_pair();
    let token_old = mint_token_with_sv(&signer, 2);
    let token_new = mint_token_with_sv(&signer, 5);

    let port = Arc::new(MemoryEpochRevocation::new());
    port.bump(TEST_SUB, 5); // current = 5
    let cfg = VerifyConfig::access_token(ISSUER, AUDIENCE).with_epoch_revocation(port);

    assert_eq!(
        verify(&token_old, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp()).await,
        Err(AuthError::SessionVersionStale),
        "sv=2 < current=5 must reject",
    );
    verify(&token_new, &cfg, &key_set, time::OffsetDateTime::now_utc().unix_timestamp())
        .await
        .expect("sv=5 >= current=5 must admit");
}

#[tokio::test]
async fn epoch_dyn_compatibility_via_arc_in_verify_config() {
    // Type-system regression guard for VerifyConfig's slot type.
    // `Arc<dyn EpochRevocation>` must be the accepted shape so chat-auth
    // and pas-external can inject their composed adapters.
    let port: Arc<dyn EpochRevocation> = Arc::new(MemoryEpochRevocation::new());
    let _cfg = VerifyConfig::access_token(ISSUER, AUDIENCE).with_epoch_revocation(port);
}