pas-external 0.7.0

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

use super::types::NewSession;
use crate::session_liveness::EncryptedRefreshToken;
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
///     }
///
///     async fn get_refresh_ciphertext(
///         &self,
///         session_id: &SessionId,
///     ) -> Result<Option<EncryptedRefreshToken>, MyError> {
///         let Some(session) = self.db.find_session(session_id).await? else {
///             return Ok(None);
///         };
///         Ok(session.refresh_token_ciphertext.map(EncryptedRefreshToken::from_stored))
///     }
/// }
/// ```
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 [`SessionValidator`](super::SessionValidator)
    /// 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;

    /// Return the **encrypted** PAS `refresh_token` ciphertext for a live
    /// session, or `Ok(None)` if no refresh token is available (DEV_AUTH
    /// session, missing row, no `with_refresh_token_cipher` configured).
    ///
    /// The SDK owns decrypt for both S-L3 (consumer-side liveness) and
    /// S-L6 (sv-aware refresh) paths via
    /// [`pas_refresh`](crate::pas_port::pas_refresh); consumer code must
    /// **not** decrypt. Plaintext never crosses the SDK→consumer boundary
    /// on the read path, mirroring the S-L1 invariant on the write path
    /// (where the SDK hands `EncryptedRefreshToken` to
    /// [`SessionStore::create`] via [`NewSession`]).
    ///
    /// Typical implementation: `SELECT refresh_token_ciphertext FROM ...`
    /// then wrap the column value via
    /// [`EncryptedRefreshToken::from_stored`](crate::session_liveness::EncryptedRefreshToken::from_stored).
    ///
    /// Returning `Err` or `Ok(None)` causes the resolver to surface
    /// [`SessionResolution::Expired`](super::SessionResolution::Expired).
    fn get_refresh_ciphertext(
        &self,
        session_id: &SessionId,
    ) -> impl Future<Output = Result<Option<EncryptedRefreshToken>, Self::Error>> + Send;
}