Files
hemx/docs/recipes/auth-session-csrf.md
T
slhx agent a7012b94c2 docs(recipes): add auth csrf boundary
Document the provider-explicit auth/session and CSRF boundary for the SaaS tutorial. The recipe keeps cookies, sessions, CSRF policy, and rejection behavior in Axum/Tower/app code while hemx handlers continue to receive typed context/forms and return generated effects.

req: laws/002

req: auth/001

req: auth/002

req: auth/003

req: auth/004

req: auth/005

req: failure/004

req: examples/001
2026-06-05 09:30:13 +02:00

7.3 KiB

Recipe: auth/session and CSRF boundary for the SaaS tutorial

This recipe turns the examples/saas demo session into a production-shaped application boundary without adding authentication, authorization, session, or CSRF policy to hemx core. hemx receives a typed context and generated form values; Axum/Tower middleware and extractors own cookies, credentials, and rejection policy. req: laws/002 req: auth/001

Use this alongside docs/recipes/sqlx-persistence.md: authenticate the request, verify CSRF for mutations, then call the application store and return generated UI effects. req: auth/002 req: auth/004

Boundary rule

Keep these concerns outside hemx crates:

  • password or OAuth provider selection
  • session cookie format, signing, storage, rotation, and expiration
  • CSRF token minting, binding, and verification
  • redirect vs HTTP error policy for non-enhanced requests
  • role/permission checks

Keep these concerns inside normal app code:

  • typed extractors such as CurrentSession
  • app state such as AppContext { session, store }
  • generated hemx form fields such as hidden csrf
  • Result<impl IntoEffect, AppError> mapping for enhanced failures

The handler should read like ordinary Rust domain code, not framework magic.

Axum state and session extractor

A real app would use a provider crate such as tower-sessions, async-session, axum-login, or a custom signed-cookie middleware. The hemx boundary is the same either way: produce a typed session before the handler runs.

use axum::extract::{FromRequestParts, State};
use axum::http::request::Parts;
use axum::response::{IntoResponse, Redirect, Response};
use std::sync::Arc;

#[derive(Clone)]
pub struct SecurityState {
    sessions: Arc<dyn SessionStore>,
    csrf: Arc<CsrfService>,
}

#[derive(Clone, Debug)]
pub struct CurrentSession {
    pub user_id: UserId,
    pub email: String,
    pub csrf: CsrfToken,
}

pub struct AuthRequired;

impl IntoResponse for AuthRequired {
    fn into_response(self) -> Response {
        Redirect::to("/login").into_response()
    }
}

#[axum::async_trait]
impl FromRequestParts<AppState> for CurrentSession {
    type Rejection = AuthRequired;

    async fn from_request_parts(
        parts: &mut Parts,
        state: &AppState,
    ) -> Result<Self, Self::Rejection> {
        let cookie = parts
            .headers
            .get(axum::http::header::COOKIE)
            .and_then(|value| value.to_str().ok())
            .ok_or(AuthRequired)?;

        state
            .security
            .sessions
            .load(cookie)
            .await
            .ok_or(AuthRequired)
    }
}

CurrentSession is an app extractor. It can be used in normal Axum routes, in middleware, or copied into AppContext before dispatching hemx interactions. hemx does not need to know how the session was loaded. req: auth/002

CSRF token in the template

The template stays ordinary HTML: a hidden field plus normal cookie semantics. The token value is a Rust field rendered by hemplate and parsed by the generated form type. req: auth/003 req: auth/004 req: auth/005

<form data-hemx-handle="create_project" data-hemx-form="new_project">
  <input type="hidden" name="csrf" +value="self.csrf">
  <input name="name" required="required">
  <button type="submit">Create project</button>
  <p data-hemx-error-for="name"></p>
</form>
#[derive(Clone, Debug)]
#[hemx::form("new_project")]
pub struct NewProject {
    csrf: CsrfToken,
    name: ProjectName,
}

The browser submits the same form with or without the hemx runtime. Cookies, SameSite behavior, and credential inclusion remain browser/framework concerns.

Mutation handler

Verify the session and CSRF token before persistence. Expected validation returns a generated form effect; auth/CSRF failures return an application error that maps to a generated UI effect or an HTTP response depending on the route. req: form/001 req: failure/004

#[hemx::handler]
async fn create_project(
    State(ctx): State<AppContext>,
    Form(form): Form<NewProject>,
) -> Result<impl IntoEffect, AppError> {
    let session = ctx.session().ok_or(AppError::MissingSession)?;
    ctx.csrf.verify(&session, &form.csrf)?;

    if form.name.as_str().is_empty() {
        return Err(AppError::Validation("Project name required"));
    }

    let project = ctx.store.insert(form.name, &session).await?;
    let total = ctx.store.list().await?.len();

    Ok((
        dashboard::project_row.append(ProjectRow::from(project)),
        dashboard::summary.set(project_summary(total)),
        dashboard::new_project.clear(),
        dashboard::flash.set("Project created"),
    ))
}

Failure mapping

Keep policy in the app error type. Enhanced requests can render generated UI; non-enhanced routes can redirect or return an HTTP status before hemx dispatch.

pub enum AppError {
    MissingSession,
    CsrfRejected,
    Validation(&'static str),
    StoreUnavailable,
}

impl IntoHandlerFailure for AppError {
    fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
        match self {
            Self::MissingSession => HandlerFailure::response(
                axum::http::StatusCode::UNAUTHORIZED,
                "Sign in to continue",
            ),
            Self::CsrfRejected => HandlerFailure::effects(
                dashboard::flash.set("Refresh the page before trying again"),
                context,
            ),
            Self::Validation(message) => HandlerFailure::effects(
                (
                    dashboard::new_project.error("name", message),
                    dashboard::new_project.focus("name"),
                ),
                context,
            ),
            Self::StoreUnavailable => HandlerFailure::effects(
                dashboard::flash.set("Project storage is temporarily unavailable"),
                context,
            ),
        }
    }
}

This keeps error policy explicit while preserving the same handler shape as the local tutorial skeleton.

Route wiring

For full-page routes, extract the session before rendering. For enhanced interaction routes, build the app context from the extracted session and shared application state, then dispatch the generated registry.

async fn home(
    State(app): State<AppState>,
    session: CurrentSession,
) -> impl IntoResponse {
    Html(home_page(&AppContext::new(session, app.store.clone())).into_string())
}

async fn interact(
    State(app): State<AppState>,
    session: CurrentSession,
    request: InteractionRequest,
) -> Result<EffectResponse, impl IntoResponse> {
    let ctx = AppContext::new(session, app.store.clone());
    request.dispatch_async(registry(ctx)).await
}

The same AppContext can contain a SQLx-backed store, an in-memory test store, or a fake store for unit tests. hemx only observes the typed handler inputs and the generated effects returned by the handler.

Tests

Keep provider checks at the application boundary:

  • request without a valid session is rejected before mutation
  • stale CSRF token does not call the store
  • valid session + CSRF stores the project and returns generated row/summary/form effects
  • validation failures target generated form errors, not selectors

examples/saas already has the local-store version of these checks; a provider app should run the same interaction assertions with its real session/CSRF middleware and store adapter. req: examples/001 req: test/001