arcature 2026.2.1

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! `Auth<U>`, `OptionalAuth<U>`, and `AuthManager<U>` — the auth extractors (A9).
//!
//! These are genuine Axum `FromRequestParts` extractors — Axum remains the
//! handler runtime. The session is `tower_sessions::Session` (re-exported
//! through `arcature_auth::tower_sessions`), accessed via the `auth` feature.
//!
//! # Extractors
//!
//! - `Auth<U>` — the authenticated user. 401 if not logged in.
//! - `OptionalAuth<U>` — `Option<U>`. `None` if not logged in (no rejection).
//! - `AuthManager<U>` — login/logout. Holds the session handle.
//!
//! # Binding does NOT imply authorization
//!
//! `Auth<U>` proves the user is authenticated. It does NOT authorize access
//! to any specific resource. Authorization is a separate, explicit step via
//! `Auth::authorize` and the `Policy<M>` trait.

use std::convert::Infallible;
use std::marker::PhantomData;

use axum::extract::FromRequestParts;
use axum::response::{IntoResponse, Response};

use crate::dx::auth_user::AuthUser;
use crate::dx::user_loader::UserLoader;

/// The authenticated user. Extracts from the session, loading the user
/// from application state via `UserLoader<S>`.
///
/// Returns `401 Unauthorized` if no user is logged in or the session is
/// stale. Authorization is a separate, explicit step (`authorize`).
///
/// # Example
///
/// ```ignore
/// async fn show(auth: Auth<User>, link: Bound<Link>) -> Result<Page<ShowLinkPage>> {
///     auth.authorize::<LinkPolicy>("view", &link)?;
///     // ...
/// }
/// ```
pub struct Auth<U: AuthUser>(pub U);

impl<U: AuthUser> Auth<U> {
    /// Extract the user value.
    pub fn into_inner(self) -> U {
        self.0
    }

    /// Get a reference to the user.
    pub fn user(&self) -> &U {
        &self.0
    }

    /// Authorize an action on a resource via a `Policy<M>` impl.
    ///
    /// Returns `Ok(())` if the policy allows, `Err(AuthzError::Forbidden)`
    /// if denied. This is the explicit authorization step — it is never
    /// automatic.
    ///
    /// # Example
    ///
    /// ```ignore
    /// auth.authorize::<LinkPolicy>("update", &link)?;
    /// ```
    pub fn authorize<M, P: super::policy::Policy<M, User = U>>(
        &self,
        action: &str,
        resource: &M,
    ) -> Result<(), super::policy::AuthzError> {
        if P::check(&self.0, action, resource) {
            Ok(())
        } else {
            Err(super::policy::AuthzError::Forbidden)
        }
    }
}

impl<U, S> FromRequestParts<S> for Auth<U>
where
    U: UserLoader<S>,
    S: Send + Sync,
{
    type Rejection = Response;

    async fn from_request_parts(
        parts: &mut axum::http::request::Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        let user = load_user::<U, S>(parts, state).await?;
        user.map(Auth).ok_or_else(|| {
            (
                axum::http::StatusCode::UNAUTHORIZED,
                "Authentication required",
            )
                .into_response()
        })
    }
}

/// The optional authenticated user. `None` if not logged in (no rejection).
///
/// Use this for routes that behave differently for authenticated vs
/// anonymous users (e.g. a landing page that shows a dashboard link if
/// logged in).
pub struct OptionalAuth<U: AuthUser>(pub Option<U>);

impl<U: AuthUser> OptionalAuth<U> {
    /// Get the user if authenticated.
    pub fn user(&self) -> Option<&U> {
        self.0.as_ref()
    }

    /// True if a user is authenticated.
    pub fn is_authenticated(&self) -> bool {
        self.0.is_some()
    }
}

impl<U, S> FromRequestParts<S> for OptionalAuth<U>
where
    U: UserLoader<S>,
    S: Send + Sync,
{
    type Rejection = Infallible;

    async fn from_request_parts(
        parts: &mut axum::http::request::Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        let user = load_user::<U, S>(parts, state).await.unwrap_or(None);
        Ok(OptionalAuth(user))
    }
}

/// The current authenticated user — the zero-plumbing golden-path name for
/// [`Auth<U>`](Auth) (AP2.1-2).
///
/// `Current<User>` extracts exactly the same value as `Auth<User>` (the
/// authenticated user from the session, 401 if none) and is a type alias so
/// the two are fully interchangeable. The name `Current<User>` reads as "the
/// current user" on the golden path; `Auth<User>` remains available for
/// callers that prefer the explicit auth vocabulary. Authentication still
/// does **not** imply authorization — call `authorize` for that (see
/// [`Auth::authorize`]).
///
/// `Auth<User>` and `Current<User>` are the same type, so either may be used
/// in the same handler signature and either name may name the bound user.
///
/// # Example
///
/// ```ignore
/// async fn show(user: Current<User>, link: Bound<Link>) -> Result<Page<ShowLinkPage>> {
///     user.authorize::<LinkPolicy>("view", &link)?;
///     // ...
/// }
/// ```
pub type Current<U> = Auth<U>;

/// The optional current user — the zero-plumbing golden-path name for
/// [`OptionalAuth<U>`](OptionalAuth) (AP2.1-2).
///
/// `OptionalCurrent<User>` is `Option<User>` (no 401 if unauthenticated) and
/// is a type alias for `OptionalAuth<U>`. Use it on routes that render for
/// both authenticated and anonymous visitors.
pub type OptionalCurrent<U> = OptionalAuth<U>;

/// The auth manager — login, logout, and session control.
///
/// Extracted from the request as a genuine Axum extractor. Holds the
/// `tower_sessions::Session` handle. The handler calls `login`, `logout`,
/// etc.
///
/// `login()` automatically rotates the session ID before binding the user
/// (session-fixation defense — audit finding #4); applications do not need to
/// call `regenerate()` after `login()`.
///
/// # Example
///
/// ```ignore
/// async fn store(
///     input: Validated<LoginRequest>,
///     auth: AuthManager<User>,
/// ) -> Result<Redirect> {
///     let user = authenticate(&input).await?;
///     auth.login(&user).remember(input.remember).await?;
///     redirect!(route::dashboard())
/// }
/// ```
pub struct AuthManager<U: AuthUser> {
    session: crate::auth::tower_sessions::Session,
    _marker: PhantomData<U>,
}

impl<U: AuthUser> AuthManager<U> {
    /// Begin a login. Returns a [`LoginBuilder`] that stores the user ID
    /// in the session on `.await`.
    ///
    /// # Session fixation defense (audit finding #4)
    ///
    /// Awaiting the builder **automatically rotates the session ID** before
    /// the user is bound, by calling `tower_sessions::Session::cycle_id`. The
    /// anonymous → authenticated transition must rotate the ID so a
    /// session-fixation attack (an attacker pre-sets the victim's session ID)
    /// cannot persist past login. This is mandatory and not opt-in: developers
    /// do not have to remember to call [`Self::regenerate`] on the login path.
    /// The session data is preserved across the rotation (the user ID is
    /// stored in the *new* session), only the ID changes.
    ///
    /// ```ignore
    /// auth.login(&user).remember(true).await?;
    /// ```
    pub fn login(&self, user: &U) -> LoginBuilder<'_, U> {
        LoginBuilder {
            session: &self.session,
            user_id: user.id().clone(),
            remember: false,
        }
    }

    /// Log out: flush the session, clearing all data (including the user ID).
    /// The session cookie is invalidated.
    pub async fn logout(&self) -> Result<(), AuthError> {
        self.session
            .flush()
            .await
            .map_err(|e| AuthError::Session(e.to_string()))?;
        Ok(())
    }

    /// Regenerate the session ID. Calls `tower_sessions::Session::cycle_id`.
    ///
    /// This is the manual escape hatch for rotating a session ID outside
    /// login. The login path ([`Self::login`]) already rotates the ID
    /// automatically before binding the user (audit finding #4); applications
    /// do not need to call `regenerate()` after `login()`. Use this only for
    /// a deliberate privilege/role change mid-session that warrants a fresh
    /// ID.
    pub async fn regenerate(&self) -> Result<(), AuthError> {
        self.session
            .cycle_id()
            .await
            .map_err(|e| AuthError::Session(e.to_string()))?;
        Ok(())
    }
}

impl<U, S> FromRequestParts<S> for AuthManager<U>
where
    U: AuthUser,
    S: Send + Sync,
{
    type Rejection = Infallible;

    async fn from_request_parts(
        parts: &mut axum::http::request::Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        let session = crate::auth::tower_sessions::Session::from_request_parts(parts, state)
            .await
            .map_err(|_| unreachable!("Session extraction is infallible"))?;
        Ok(AuthManager {
            session,
            _marker: PhantomData,
        })
    }
}

/// A builder for the login operation. Stores the user ID in the session
/// on `.await`.
pub struct LoginBuilder<'a, U: AuthUser> {
    session: &'a crate::auth::tower_sessions::Session,
    user_id: U::Id,
    remember: bool,
}

impl<'a, U: AuthUser> LoginBuilder<'a, U> {
    /// Set the "remember me" flag. When true, the session's max-age is
    /// extended (if the session architecture permits it). When false
    /// (default), the session uses the configured inactivity-based expiry.
    pub fn remember(mut self, remember: bool) -> Self {
        self.remember = remember;
        self
    }
}

impl<'a, U: AuthUser> std::future::IntoFuture for LoginBuilder<'a, U> {
    type Output = Result<(), AuthError>;
    type IntoFuture = std::pin::Pin<
        std::boxed::Box<dyn std::future::Future<Output = Result<(), AuthError>> + Send + 'a>,
    >;

    fn into_future(self) -> Self::IntoFuture {
        Box::pin(async move {
            // Auto-rotate the session ID before binding the user (audit
            // finding #4: OWASP — the anonymous → authenticated transition
            // must rotate the ID to prevent session fixation). `cycle_id`
            // preserves existing session data and issues a fresh ID, so any
            // attacker-pre-set session ID is discarded. The user ID is then
            // stored in the *new* session. This is mandatory and not opt-in;
            // applications do not call `regenerate()` after `login()`.
            self.session
                .cycle_id()
                .await
                .map_err(|e| AuthError::Session(e.to_string()))?;
            self.session
                .insert(U::SESSION_KEY, &self.user_id)
                .await
                .map_err(|e| AuthError::Session(e.to_string()))?;
            if self.remember {
                self.session
                    .insert("remember_me", true)
                    .await
                    .map_err(|e| AuthError::Session(e.to_string()))?;
            }
            Ok(())
        })
    }
}

/// A typed error from auth operations.
#[derive(Debug)]
pub enum AuthError {
    /// A session operation failed (read/write/cycle).
    Session(String),
}

impl std::fmt::Display for AuthError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Session(msg) => write!(f, "session error: {msg}"),
        }
    }
}

impl std::error::Error for AuthError {}

/// Load the user from the session + state. Shared by `Auth<U>` and
/// `OptionalAuth<U>`.
async fn load_user<U, S>(
    parts: &mut axum::http::request::Parts,
    state: &S,
) -> Result<Option<U>, Response>
where
    U: UserLoader<S>,
    S: Send + Sync,
{
    // Extract the session.
    let session = crate::auth::tower_sessions::Session::from_request_parts(parts, state)
        .await
        .map_err(|_| {
            (
                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
                "session extraction failed",
            )
                .into_response()
        })?;

    // Read the user ID from the session. On error, return a generic 500
    // without leaking session internals — the raw error is logged server-
    // side via the caller, not reflected to the client.
    let user_id: Option<U::Id> = session.get(U::SESSION_KEY).await.map_err(|_err| {
        (
            axum::http::StatusCode::INTERNAL_SERVER_ERROR,
            "session read failed",
        )
            .into_response()
    })?;

    let user_id = match user_id {
        Some(id) => id,
        None => return Ok(None),
    };

    // Load the user from state. On error, return a generic 500 without
    // leaking database internals.
    let user = U::load_user(&user_id, state).await.map_err(|_err| {
        (
            axum::http::StatusCode::INTERNAL_SERVER_ERROR,
            "user load failed",
        )
            .into_response()
    })?;

    Ok(user)
}