pas-external 0.10.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! γ port — `BearerVerifier`, `AuthSession`, `Expectations`, `VerifyError`.
//!
//! The SDK's verification surface, format-blind by design. Consumers
//! receive an [`AuthSession`] that exposes typed accessors for the
//! values they need (`ppnum_id`, `ppnum`, `session_id`, `expires_at`)
//! without ever seeing the underlying JWT or the `jsonwebtoken` /
//! `ppoppo_token` types. Swapping the production [`PasJwtVerifier`]
//! adapter for the in-memory test adapter (`MemoryBearerVerifier`,
//! gated behind `test-support`) requires zero consumer changes — the
//! port is the contract.
//!
//! D-04 (locked γ, 2026-05-05): port-and-adapter SDK boundary; the
//! engine becomes the only place that knows JWT.

use async_trait::async_trait;
use time::OffsetDateTime;

use crate::types::{Ppnum, PpnumId, SessionId};

/// Verification port for incoming bearer tokens.
///
/// Implementations swap the cryptographic backend without altering the
/// caller's surface. The production [`super::PasJwtVerifier`] verifies
/// PAS-issued JWTs against a TTL-cached JWKS; the test-support
/// `MemoryBearerVerifier` returns canned [`AuthSession`] values keyed
/// by the bare token string.
///
/// `verify` is async because the production adapter performs
/// stale-on-failure JWKS refresh inside the verify path, and any
/// future 3rd-party adapter is free to make HTTP calls. Caller
/// middleware that needs synchronous semantics wraps the call in
/// `tokio::block_on`; the port itself stays uniformly async.
///
/// The single `bearer_token` parameter mirrors the M38 transport-blind
/// invariant: the engine never reaches into request framing, and
/// neither does the SDK port. Consumer middleware extracts the bare
/// token before calling.
#[async_trait]
pub trait BearerVerifier: Send + Sync {
    async fn verify(&self, bearer_token: &str) -> Result<AuthSession, VerifyError>;
}

/// Per-deployment expectations folded into the verifier at construction.
///
/// `issuer` is the PAS instance URL (`accounts.ppoppo.com` in
/// production); `audience` is the consumer's OAuth `client_id`. Both
/// are static per-deployment — multi-tenant consumers instantiate
/// multiple verifiers, never rotate `Expectations` on the per-call
/// hot path.
///
/// Held inside [`super::PasJwtVerifier`] (and optionally inside
/// `MemoryBearerVerifier`) so the [`BearerVerifier::verify`] signature
/// stays one-parameter — the port is as small as it can be while
/// still doing meaningful work.
#[derive(Debug, Clone)]
pub struct Expectations {
    pub issuer: String,
    pub audience: String,
}

impl Expectations {
    /// Construct from owned strings. Consumer wiring typically reads
    /// these from environment variables at startup.
    #[must_use]
    pub fn new(issuer: impl Into<String>, audience: impl Into<String>) -> Self {
        Self {
            issuer: issuer.into(),
            audience: audience.into(),
        }
    }
}

/// Verified bearer-token outcome, opaque to the underlying token format.
///
/// Internal storage is the engine's typed `Claims` payload, but no
/// consumer ever touches it — accessors return SDK-shaped types
/// (`Ppnum`, `PpnumId`, `SessionId`, `OffsetDateTime`) that are stable
/// across format migrations (PASETO → JWT just happened; future
/// formats re-implement `BearerVerifier` and ship a new `AuthSession`
/// constructor).
///
/// No `into_inner` escape hatch by design (Phase 6.1 audit Finding 4):
/// every claim consumer code might need is exposed as a typed accessor.
/// If a future field is needed, add an accessor here before the consumer
/// ships — never widen to raw claims.
#[derive(Debug, Clone)]
pub struct AuthSession {
    ppnum_id: PpnumId,
    ppnum: Option<Ppnum>,
    session_id: Option<SessionId>,
    expires_at: OffsetDateTime,
}

impl AuthSession {
    /// Build from typed components. SDK-internal — `PasJwtVerifier`
    /// constructs after engine `verify` returns; `MemoryBearerVerifier`
    /// constructs in test setup. Marked `pub(crate)` so external
    /// adapters cannot fabricate sessions outside the SDK's
    /// verification path.
    ///
    /// `dead_code` allowed because under just `feature = "token"`
    /// (no `well-known-fetch`, no `test-support`) the constructor has
    /// no caller — yet the type itself is still part of the
    /// `BearerVerifier` trait surface that consumers may implement
    /// directly. Removing the constructor would break the symmetry
    /// with `for_test`.
    #[allow(dead_code)]
    pub(crate) fn new(
        ppnum_id: PpnumId,
        ppnum: Option<Ppnum>,
        session_id: Option<SessionId>,
        expires_at: OffsetDateTime,
    ) -> Self {
        Self {
            ppnum_id,
            ppnum,
            session_id,
            expires_at,
        }
    }

    /// Test-support constructor — same shape as [`Self::new`] but
    /// available outside the SDK when the `test-support` feature is
    /// enabled. Consumers writing integration tests wire in
    /// pre-built sessions through `MemoryBearerVerifier::insert`.
    #[cfg(any(test, feature = "test-support"))]
    #[must_use]
    pub fn for_test(
        ppnum_id: PpnumId,
        ppnum: Option<Ppnum>,
        session_id: Option<SessionId>,
        expires_at: OffsetDateTime,
    ) -> Self {
        Self::new(ppnum_id, ppnum, session_id, expires_at)
    }

    /// Stable subject identifier (ULID, `sub` claim).
    #[must_use]
    pub fn ppnum_id(&self) -> &PpnumId {
        &self.ppnum_id
    }

    /// Digit-form ppnum carried in the `active_ppnum` claim. `None`
    /// for AI-agent / machine tokens that have no human ppnum, and
    /// for any token where the issuer omitted the claim. Consumer
    /// code defaults to display-only use; trust decisions key off
    /// `ppnum_id` (immutable ULID).
    #[must_use]
    pub fn ppnum(&self) -> Option<&Ppnum> {
        self.ppnum.as_ref()
    }

    /// Session row identifier (`sid` claim) when the issuer bound the
    /// token to a stored session. `None` for non-session-bound tokens
    /// (machine credentials, AI-agent flows, R6 legacy admit). When
    /// present, consumer middleware uses it for per-row liveness
    /// checks via `SessionStore::find`.
    #[must_use]
    pub fn session_id(&self) -> Option<&SessionId> {
        self.session_id.as_ref()
    }

    /// Expiry (`exp` claim) as a wall-clock instant. Caller code can
    /// compare against `OffsetDateTime::now_utc()` for soft-refresh
    /// logic; the engine has already enforced expiry in
    /// [`BearerVerifier::verify`] so this value is informational by
    /// the time it reaches consumer code.
    #[must_use]
    pub fn expires_at(&self) -> OffsetDateTime {
        self.expires_at
    }
}

/// Verification failure surface.
///
/// One variant per logical failure class. The PAS-engine variants
/// (`SignatureInvalid`, `Expired`, `IssuerMismatch`, `AudienceMismatch`,
/// `MissingClaim`, `KeysetUnavailable`) reflect the boundary contract:
/// audit logs map them 1:1 to engine `AuthError` rows. Adapter-side
/// variants (`InvalidFormat`) cover failures upstream of engine entry.
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum VerifyError {
    /// Bearer string did not parse as a JWS Compact serialization.
    /// Adapter-side reject before engine entry.
    #[error("invalid bearer token format")]
    InvalidFormat,

    /// Cryptographic signature verification failed (engine M16).
    #[error("signature verification failed")]
    SignatureInvalid,

    /// `exp` claim is in the past (engine M19).
    #[error("token expired")]
    Expired,

    /// `iss` did not match [`Expectations::issuer`] (engine M23). The
    /// engine does not expose the *actual* value because the failed
    /// match means we cannot trust any payload field — the SDK
    /// surfaces just "issuer invalid" and the audit log carries the
    /// caller's expected value alongside this variant.
    #[error("issuer invalid (M23)")]
    IssuerInvalid,

    /// `aud` did not match [`Expectations::audience`] (engine M21/M22).
    #[error("audience invalid (M21/M22)")]
    AudienceInvalid,

    /// A required claim was absent or malformed.
    #[error("missing required claim: {0}")]
    MissingClaim(&'static str),

    /// JWKS fetch failed and there is no usable cached snapshot
    /// (initial bootstrap failure or `with_initial` constructed with
    /// an empty key set). Distinct from `SignatureInvalid` so audit
    /// logs distinguish "we couldn't even attempt verification" from
    /// "verification failed."
    #[error("keyset unavailable")]
    KeysetUnavailable,

    /// Engine `check_epoch` rejected: token's `sv` claim is below the
    /// authoritative substrate's current value. Break-glass /
    /// LogoutAll just kicked this token (RFC §3 Row 3 + STANDARDS_AUTH_INVALIDATION
    /// §2.3). Distinct from `Expired` (which is `exp`-bound) — the
    /// caller's UX response is the same (re-authenticate) but audit
    /// logs distinguish revocation from natural expiry.
    ///
    /// Surfaces only when the consumer wired
    /// [`crate::PasJwtVerifier::with_epoch_revocation`] at boot. With
    /// no port wired, the engine's epoch gate short-circuits and this
    /// variant is unreachable.
    #[error("session_version stale (engine epoch port reject)")]
    SessionVersionStale,

    /// Engine `check_epoch` could not reach its substrate (cache miss
    /// fell through to fetcher; fetcher returned transient error).
    /// Fail-closed per STANDARDS_AUTH_INVALIDATION §3 — admit-on-
    /// failure would let stale tokens slip during outage windows.
    /// Caller's HTTP response should be `503 Service Unavailable`.
    ///
    /// Surfaces only when the consumer wired
    /// [`crate::PasJwtVerifier::with_epoch_revocation`] at boot.
    #[error("session_version lookup substrate unavailable")]
    SessionVersionLookupUnavailable,

    /// Phase 11.Z 0.10.0 (RFC_2026-05-08 §4.2 lock) — L2 session
    /// liveness reject. The token's `sid` claim resolved to a row that
    /// is absent OR `revoked_at` is set. Distinct from
    /// `SessionVersionStale` (L1 sv-axis): L2 is consumer-DB row
    /// revocation; L1 is cross-service break-glass propagation.
    /// Caller's HTTP response is `401` and the browser cookie clears
    /// (the `LogoutAll`/per-session-revoke flow's actionable signal).
    ///
    /// Surfaces only when the consumer wired
    /// [`crate::PasJwtVerifier::with_session_liveness`] at boot AND
    /// the token carries a `sid` claim. Tokens without `sid` (machine
    /// credentials, AI-agent flows, R6 legacy admit per
    /// [`AuthSession::session_id`]) admit without consulting the L2
    /// port (lenient — RFC_2026-05-08 §4.2 lock).
    #[error("session revoked")]
    SessionRevoked,

    /// Phase 11.Z 0.10.0 — L2 session liveness substrate could not
    /// answer (DB connection lost, schema unavailable, query timeout).
    /// Fail-closed per STANDARDS_AUTH_INVALIDATION §3 — admit-on-
    /// failure would let post-revoke tokens slip through during outage
    /// windows. Caller's HTTP response should be `503 Service
    /// Unavailable`. Distinct from `SessionVersionLookupUnavailable`
    /// (L1) so audit dashboards pivot L1 substrate health vs L2
    /// substrate health independently.
    ///
    /// Surfaces only when the consumer wired
    /// [`crate::PasJwtVerifier::with_session_liveness`] at boot.
    #[error("session liveness lookup substrate unavailable")]
    SessionLivenessLookupUnavailable,

    /// M73 — id_token presented as a Bearer token. RFC 9068 §1 (negative)
    /// + OIDC Core §1.2 intent: id_tokens authenticate the user *to the
    /// RP*; access_tokens authorize the RP *to the resource server*.
    /// The two are not interchangeable. Many 3rd-party RPs misuse
    /// id_token for API access — pas-external is the BearerVerifier
    /// surface for resource servers, so an id_token-shaped JWT here is
    /// always wrong.
    ///
    /// Distinct from `SignatureInvalid` (which is the engine's catch-all
    /// for "token cannot be trusted") so audit logs distinguish a
    /// developer-misuse signal ("you're sending the wrong token class")
    /// from a forgery signal ("the signature didn't verify"). Rejected
    /// BEFORE engine entry so the audit log does not get the same
    /// signal masked by `TypMismatch → SignatureInvalid` collapsing.
    #[error("M73: id_token presented as Bearer — use access_token for resource access")]
    IdTokenAsBearer,

    /// Catch-all for engine variants that don't map to a structural
    /// SDK rejection. Carries the engine's `AuthError` Display so the
    /// audit log retains the M-code.
    #[error("verification failed: {0}")]
    Other(String),
}