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:
@@ -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
|
||||
Reference in New Issue
Block a user