# 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` 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. ```rust 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, csrf: Arc, } #[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 for CurrentSession { type Rejection = AuthRequired; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { 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 ```heml

``` ```rust #[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. `hemx_axum::InteractionRequest` accepts only URL-encoded and multipart forms; apply Axum's `DefaultBodyLimit` (or a compatible host limit) to every mutation route. Media-type and size checks run before dispatch, while CSRF remains the explicit application or middleware check shown below. req: security/003 ## 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 ```rust #[hemx::handler] async fn create_project( State(ctx): State, Form(form): Form, ) -> Result { 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. ```rust 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. ```rust async fn home( State(app): State, session: CurrentSession, ) -> impl IntoResponse { Html(home_page(&AppContext::new(session, app.store.clone())).into_string()) } async fn interact( State(app): State, session: CurrentSession, request: InteractionRequest, ) -> Result { 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