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
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