arcature 2026.1.0

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 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.
///
/// # 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`.
    ///
    /// ```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. Use after login to prevent session
    /// fixation attacks. Calls `tower_sessions::Session::cycle_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 {
            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)
}