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
This commit is contained in:
slhx agent
2026-06-05 09:30:13 +02:00
parent 2c043d2f96
commit a7012b94c2
3 changed files with 238 additions and 3 deletions
+235
View File
@@ -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<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.
```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<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
```heml
<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>
```
```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<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.
```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<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