From a7012b94c230a357faf1721d08322581881cdd81 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Fri, 5 Jun 2026 09:30:13 +0200 Subject: [PATCH] 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 --- README.md | 4 +- docs/recipes/auth-session-csrf.md | 235 ++++++++++++++++++++++++++++++ examples/saas/README.md | 2 +- 3 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 docs/recipes/auth-session-csrf.md diff --git a/README.md b/README.md index 224ae77..bac123f 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,10 @@ and integrate at explicit boundaries. req: laws/002 req: auth/001 commands, not own the database layer. See `docs/recipes/sqlx-persistence.md`. - **Auth/session:** use Axum/Tower extractors and middleware. Handlers may accept typed auth/session context and return ordinary HTTP failures or generated UI - failures. req: auth/002 + failures. See `docs/recipes/auth-session-csrf.md`. req: auth/002 - **CSRF:** keep CSRF policy in middleware/extractors with hidden form fields, cookies, and normal SameSite/browser semantics. hemx preserves submitted form - fields and credentials semantics. req: auth/004 req: auth/005 + fields and credentials semantics. See `docs/recipes/auth-session-csrf.md`. req: auth/004 req: auth/005 - **Observability, feature flags, killswitches, deploy:** use explicit platform integrations around handlers, routes, and runtime assets. Core hemx must not vendor providers or add framework-specific magic. diff --git a/docs/recipes/auth-session-csrf.md b/docs/recipes/auth-session-csrf.md new file mode 100644 index 0000000..4461cbf --- /dev/null +++ b/docs/recipes/auth-session-csrf.md @@ -0,0 +1,235 @@ +# 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. + +## 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 diff --git a/examples/saas/README.md b/examples/saas/README.md index 225de8b..30b71a5 100644 --- a/examples/saas/README.md +++ b/examples/saas/README.md @@ -12,7 +12,7 @@ What it proves today: - page shell with plain CSS and one explicit metrics island script - compile-time surface generation plus interaction tests -For a provider-explicit persistence boundary, see `../../docs/recipes/sqlx-persistence.md`. +For provider-explicit boundaries, see `../../docs/recipes/sqlx-persistence.md` and `../../docs/recipes/auth-session-csrf.md`. What it deliberately does not claim yet: