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:
@@ -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`.
|
commands, not own the database layer. See `docs/recipes/sqlx-persistence.md`.
|
||||||
- **Auth/session:** use Axum/Tower extractors and middleware. Handlers may accept
|
- **Auth/session:** use Axum/Tower extractors and middleware. Handlers may accept
|
||||||
typed auth/session context and return ordinary HTTP failures or generated UI
|
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,
|
- **CSRF:** keep CSRF policy in middleware/extractors with hidden form fields,
|
||||||
cookies, and normal SameSite/browser semantics. hemx preserves submitted form
|
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
|
- **Observability, feature flags, killswitches, deploy:** use explicit platform
|
||||||
integrations around handlers, routes, and runtime assets. Core hemx must not
|
integrations around handlers, routes, and runtime assets. Core hemx must not
|
||||||
vendor providers or add framework-specific magic.
|
vendor providers or add framework-specific magic.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -12,7 +12,7 @@ What it proves today:
|
|||||||
- page shell with plain CSS and one explicit metrics island script
|
- page shell with plain CSS and one explicit metrics island script
|
||||||
- compile-time surface generation plus interaction tests
|
- 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:
|
What it deliberately does not claim yet:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user