pas-external 4.0.2

Ppoppo Accounts System (PAS) external SDK -- OAuth2 PKCE, PASETO verification, Axum middleware, session liveness
Documentation
use std::future::Future;

use super::types::NewSession;
use crate::types::{Ppnum, PpnumId, SessionId, UserId};

/// Auth contexts produced by [`SessionStore::find`] expose the bound
/// session's `ppnum_id` and PASETO `sv` claim so the SDK middleware can
/// enforce break-glass / lifecycle-driven invalidation
/// ([STANDARDS_AUTH_INVALIDATION §5](https://github.com/hakchin/ppoppo)).
///
/// `sv()` returns `None` for tokens that carry no `sv` claim — currently
/// AI agent / client-credentials tokens (PAS spec §4.2.1). Cookie-session
/// consumers (RCW, CTW) only ever see Human-entity sessions through the
/// OAuth flow, so for them `sv()` is effectively always `Some`.
///
/// # Example
///
/// ```rust,ignore
/// use pas_external::middleware::SvAware;
///
/// pub struct AuthSession {
///     pub user_id: UserId,
///     pub ppnum_id: String,
///     pub sv: Option<i64>,
///     // ... other consumer fields
/// }
///
/// impl SvAware for AuthSession {
///     fn ppnum_id(&self) -> &str { &self.ppnum_id }
///     fn sv(&self) -> Option<i64> { self.sv }
/// }
/// ```
pub trait SvAware {
    /// PAS ppnum identifier (ULID format, matches the OAuth `sub` claim).
    fn ppnum_id(&self) -> &str;

    /// PASETO `sv` claim snapshotted when the session was created or
    /// last refreshed. `None` for tokens without an `sv` claim.
    fn sv(&self) -> Option<i64>;
}

/// Consumer-provided account resolution.
///
/// Called during OAuth callback to resolve the PAS identity to a local user account.
/// The returned [`UserId`] is stored in the session.
///
/// # Example
///
/// ```rust,ignore
/// impl AccountResolver for MyAdapter {
///     type Error = MyError;
///
///     async fn resolve(
///         &self,
///         ppnum_id: &PpnumId,
///         ppnum: &Ppnum,
///     ) -> Result<UserId, MyError> {
///         let user = self.repo.find_by_ppnum_id(ppnum_id).await?
///             .unwrap_or(self.repo.create(ppnum_id).await?);
///         Ok(UserId(user.id.to_string()))
///     }
/// }
/// ```
pub trait AccountResolver: Send + Sync + 'static {
    type Error: std::error::Error + Send + Sync + 'static;

    /// Resolve a PAS identity to a consumer user account (find or create).
    ///
    /// - `ppnum_id`: PAS ppnum identifier (OAuth `sub` claim, ULID format)
    /// - `ppnum`: Validated Ppoppo Number (≥11 digits, all ASCII digits per `^[0-9]{11,}$`; prefix band-allocated, no semantic meaning)
    fn resolve(
        &self,
        ppnum_id: &PpnumId,
        ppnum: &Ppnum,
    ) -> impl Future<Output = Result<UserId, Self::Error>> + Send;
}

/// Consumer-provided session persistence.
///
/// Sessions are identified by [`SessionId`] (opaque string wrapper).
/// The consumer chooses the ID format (ULID, UUID, etc.).
///
/// The `AuthContext` associated type lets consumers return their own auth type
/// from `find()`, eliminating the need for parallel auth middleware.
///
/// # Example
///
/// ```rust,ignore
/// impl SessionStore for MyAdapter {
///     type Error = MyError;
///     type AuthContext = MyAuthUser; // your handler's auth type; must impl SvAware
///
///     async fn create(&self, session: NewSession) -> Result<SessionId, MyError> {
///         let id = Ulid::new().to_string();
///         self.db.insert_session(&id, &session).await?;
///         Ok(SessionId(id))
///     }
///
///     async fn find(&self, session_id: &SessionId) -> Result<Option<MyAuthUser>, MyError> {
///         // Return your full auth context directly
///         self.db.find_session_with_context(session_id).await
///     }
///
///     async fn delete(&self, session_id: &SessionId) -> Result<(), MyError> {
///         self.db.delete_session(session_id).await
///     }
///
///     async fn update_sv(
///         &self,
///         session_id: &SessionId,
///         new_sv: i64,
///     ) -> Result<(), MyError> {
///         self.db.update_session_sv(session_id, new_sv).await
///     }
/// }
/// ```
pub trait SessionStore: Send + Sync + 'static {
    type Error: std::error::Error + Send + Sync + 'static;
    type AuthContext: Clone + Send + Sync + 'static + SvAware;

    /// Create a new session. Returns the session ID.
    fn create(
        &self,
        session: NewSession,
    ) -> impl Future<Output = Result<SessionId, Self::Error>> + Send;

    /// Look up a session by ID. Returns the consumer's auth context if valid.
    fn find(
        &self,
        session_id: &SessionId,
    ) -> impl Future<Output = Result<Option<Self::AuthContext>, Self::Error>> + Send;

    /// Delete a session (logout).
    fn delete(
        &self,
        session_id: &SessionId,
    ) -> impl Future<Output = Result<(), Self::Error>> + Send;

    /// Persist a refreshed `sv` value for a live session.
    ///
    /// Called by [`SvAwareSessionResolver`](super::SvAwareSessionResolver)
    /// after a cache miss / stale refresh produces a newer `sv`. Returning
    /// `Ok(())` for a non-existent session is acceptable — the next
    /// resolve will surface `Expired` via the existing `find` path.
    fn update_sv(
        &self,
        session_id: &SessionId,
        new_sv: i64,
    ) -> impl Future<Output = Result<(), Self::Error>> + Send;
}

/// Consumer-provided lookup that returns the **plaintext** PAS
/// `refresh_token` for a live session.
///
/// Called by [`SvAwareSessionResolver`](super::SvAwareSessionResolver)
/// during the refresh-and-recheck path. The consumer typically:
/// 1. Looks up the session row by `SessionId`.
/// 2. Reads the encrypted `refresh_token_ciphertext` column.
/// 3. Decrypts with the same [`TokenCipher`](crate::TokenCipher) it
///    passed to [`PasAuthConfig::with_refresh_token_cipher`](super::PasAuthConfig::with_refresh_token_cipher).
/// 4. Returns the plaintext (or `Ok(None)` if no refresh-token is
///    available — DEV_AUTH sessions, missing rows).
///
/// Returning `Err` or `Ok(None)` causes the resolver to surface
/// [`SessionResolution::Expired`](super::SessionResolution::Expired) —
/// the caller will be redirected to login.
///
/// # Example
///
/// ```rust,ignore
/// impl RefreshTokenResolver for MyAdapter {
///     type Error = MyError;
///
///     async fn resolve_refresh_token(
///         &self,
///         session_id: &SessionId,
///     ) -> Result<Option<String>, MyError> {
///         let Some(session) = self.session_repo.find_by_id(session_id).await? else {
///             return Ok(None);
///         };
///         let Some(ct) = &session.refresh_token_ciphertext else {
///             return Ok(None);
///         };
///         let plaintext = self.cipher.decrypt(ct)
///             .map_err(|e| MyError::infrastructure(format!("decrypt: {e}")))?;
///         Ok(Some(plaintext))
///     }
/// }
/// ```
pub trait RefreshTokenResolver: Send + Sync + 'static {
    type Error: std::error::Error + Send + Sync + 'static;

    fn resolve_refresh_token(
        &self,
        session_id: &SessionId,
    ) -> impl Future<Output = Result<Option<String>, Self::Error>> + Send;
}