pas-external 0.6.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! Production [`BearerVerifier`] adapter — verifies PAS-issued JWTs.
//!
//! [`PasJwtVerifier`] is the only place inside the SDK that knows the
//! token format. It calls [`ppoppo_token::verify`] under a TTL-cached
//! JWKS ([`super::keyset::JwksCache`]), maps the engine's [`Claims`]
//! payload to an SDK-shaped [`AuthSession`], and routes any
//! [`ppoppo_token::AuthError`] to the boundary's [`VerifyError`]
//! enum so audit logs retain the M-code via Display fallback.
//!
//! Single textbook constructor [`Self::from_jwks_url`] — Findings 2/3
//! collapsed `KeySet` and `with_config` out of the public surface.

use std::str::FromStr;

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

use ppoppo_token::{AuthError, Claims, VerifyConfig};

use super::keyset::JwksCache;
use super::port::{AuthSession, BearerVerifier, Expectations, VerifyError};
use crate::types::{Ppnum, PpnumId, SessionId};

/// PAS JWT verifier (RFC 9068, EdDSA).
///
/// Constructed once per consumer deployment with the JWKS URL and the
/// per-deployment [`Expectations`]. Cheap to clone — internal state is
/// `Arc`-shared, so you can store the verifier behind
/// `Arc<dyn BearerVerifier>` in a request extension or per-route layer
/// without measurable overhead.
///
/// `Debug` is intentionally derived but only surfaces the
/// [`Expectations`]; the underlying `JwksCache` carries an HTTP client
/// and a parsed keyset that have no useful Debug representation.
#[derive(Clone, Debug)]
pub struct PasJwtVerifier {
    jwks: JwksCache,
    expectations: Expectations,
}

impl PasJwtVerifier {
    /// Single textbook constructor — collapses {JWKS construction,
    /// Expectations, VerifyConfig} into one boundary call.
    ///
    /// Consumer never sees the underlying `JwksCache`. The default
    /// engine `VerifyConfig::access_token(issuer, audience)` covers
    /// every concrete RCW/CTW/third-party scenario; if a future
    /// consumer needs revocation-port wiring (`with_replay_defense`,
    /// `with_session_revocation`, `with_epoch_revocation`), add a
    /// third parameter then — YAGNI for 0.6.
    ///
    /// # Errors
    ///
    /// Returns [`VerifyError::KeysetUnavailable`] if the initial JWKS
    /// fetch fails. The verifier cannot serve verifications without
    /// at least one usable key snapshot.
    pub async fn from_jwks_url(
        jwks_url: impl Into<String>,
        expectations: Expectations,
    ) -> Result<Self, VerifyError> {
        let jwks = JwksCache::fetch(jwks_url).await?;
        Ok(Self { jwks, expectations })
    }
}

#[async_trait]
impl BearerVerifier for PasJwtVerifier {
    async fn verify(&self, bearer_token: &str) -> Result<AuthSession, VerifyError> {
        // Adapter-side reject before engine entry — JWS Compact has
        // exactly three segments separated by `.`. The engine also
        // checks structurally, but rejecting upstream keeps the audit
        // log signal cleaner ("malformed at SDK boundary" vs "engine
        // M07/M11/M15").
        if bearer_token.is_empty() || !looks_like_jws_compact(bearer_token) {
            return Err(VerifyError::InvalidFormat);
        }

        let cfg = VerifyConfig::access_token(
            self.expectations.issuer.clone(),
            self.expectations.audience.clone(),
        );
        let keyset = self.jwks.snapshot().await;
        let claims = ppoppo_token::verify(bearer_token, &cfg, &keyset)
            .await
            .map_err(|e| map_auth_error(e, &self.expectations))?;
        claims_to_auth_session(claims)
    }
}

fn looks_like_jws_compact(token: &str) -> bool {
    token.split('.').count() == 3
}

/// Map engine [`AuthError`] to the SDK boundary [`VerifyError`].
///
/// Algorithm + header rejections collapse to `SignatureInvalid` /
/// `InvalidFormat` because their semantic at the SDK boundary is "the
/// token cannot be trusted." Audit logs that need the precise M-code
/// route through the [`VerifyError::Other`] Display fallback (engine
/// `AuthError` Display retains the row identifier).
fn map_auth_error(err: AuthError, _expectations: &Expectations) -> VerifyError {
    use AuthError as E;
    match err {
        // Algorithm / header / structural rejections — token can't be
        // parsed safely. Collapse to SignatureInvalid (audit log
        // disambiguates via Display fallback when needed).
        E::AlgNone
        | E::AlgNotWhitelisted
        | E::AlgHmacRejected
        | E::AlgRsaRejected
        | E::AlgEcdsaRejected
        | E::HeaderJku
        | E::HeaderX5u
        | E::HeaderJwk
        | E::HeaderX5c
        | E::HeaderCrit
        | E::HeaderExtraParam
        | E::KidUnknown
        | E::TypMismatch
        | E::NestedJws => VerifyError::SignatureInvalid,
        E::OversizedToken | E::JwsJsonRejected | E::JwePayload | E::LaxBase64 => {
            VerifyError::InvalidFormat
        }
        // Time-bound rejections — caller treats these as "refresh".
        E::Expired | E::ExpUpperBound | E::IatFuture | E::NotYetValid => VerifyError::Expired,
        // Issuer / audience — typed for audit pivot.
        E::IssMismatch => VerifyError::IssuerInvalid,
        E::AudMismatch => VerifyError::AudienceInvalid,
        // Missing claims — surface the canonical claim name.
        E::ExpMissing => VerifyError::MissingClaim("exp"),
        E::AudMissing => VerifyError::MissingClaim("aud"),
        E::IatMissing => VerifyError::MissingClaim("iat"),
        E::JtiMissing => VerifyError::MissingClaim("jti"),
        E::SubMissing => VerifyError::MissingClaim("sub"),
        E::ClientIdMissing => VerifyError::MissingClaim("client_id"),
        // Catch-all keeps the engine row identifier in the Display so
        // the audit log can pivot on the M-code without the SDK
        // pre-translating every variant — engine evolution doesn't
        // require lockstep enum updates here.
        other => VerifyError::Other(other.to_string()),
    }
}

fn claims_to_auth_session(claims: Claims) -> Result<AuthSession, VerifyError> {
    // `sub` is a ULID per the engine's Phase 4 domain rules. A failure
    // here would mean the issuer drifted from the contract — fail
    // closed with a typed error.
    let ppnum_id = ulid::Ulid::from_str(&claims.sub)
        .map(PpnumId)
        .map_err(|_| VerifyError::MissingClaim("sub"))?;

    let ppnum = match claims.active_ppnum {
        Some(p) => Some(Ppnum::try_from(p).map_err(|_| VerifyError::MissingClaim("active_ppnum"))?),
        None => None,
    };

    let session_id = claims.sid.map(SessionId::from);

    let expires_at = OffsetDateTime::from_unix_timestamp(claims.exp)
        .map_err(|_| VerifyError::MissingClaim("exp"))?;

    Ok(AuthSession::new(ppnum_id, ppnum, session_id, expires_at))
}