Authentication
This module owns the integration seams, not your identity schema. There is no
framework User table, no roles table, no permissions system. You write the
user type; Arcature gives it sessions, hashing, extractors, and an
authorization seam.
The user contract
use arcature::AuthUser;
pub struct User {
pub id: uuid::Uuid,
pub email: String,
}
impl AuthUser for User {
type Id = uuid::Uuid;
const SESSION_KEY: &'static str = "user_id";
fn id(&self) -> &uuid::Uuid { &self.id }
}
Id is what goes in the session — Uuid, i64, String, anything
serializable. SESSION_KEY defaults to "user_id".
Then say how to load one back:
impl UserLoader<AppState> for User {
type Error = sea_orm::DbErr;
async fn load_user(id: &uuid::Uuid, state: &AppState) -> Result<Option<User>, DbErr> {
// query state.db
Ok(None)
}
}
Ok(None) means the session is stale and the extractor answers 401. Err
means the database failed, which is a different thing.
absolute_max_age() is the maximum session lifetime measured from the login
timestamp, regardless of activity. It defaults to 30 days. This is separate
from the session layer’s sliding inactivity timeout — one bounds how long a
session can live, the other how long it can idle.
Extractors
| Extractor | Behaviour |
|---|---|
Auth<U> | the signed-in user, or a 401 rejection |
OptionalAuth<U> | Option<U>, never rejects |
AuthManager<U> | login, logout, session rotation |
Session | the session store |
Flash | flash messages |
CsrfToken | the current CSRF token |
pub async fn dashboard(auth: Auth<User>) -> Result<Response> {
Ok(text(StatusCode::OK, format!("hello {}", auth.user().email)))
}
Logging in and out
pub async fn store(auth: AuthManager<User>, /* ... */) -> Result<Response> {
let user = /* look the user up and verify the password */;
auth.login(&user).await?;
Ok(redirect().to("/dashboard").into_response())
}
pub async fn destroy(auth: AuthManager<User>) -> Result<Response> {
auth.logout().await?;
Ok(redirect().to("/").into_response())
}
login returns a LoginBuilder; awaiting it does the work.
.remember(true) extends the session’s max age.
Awaiting the builder rotates the session ID by calling cycle_id before
binding the user. This is mandatory and not opt-in: the
anonymous-to-authenticated transition is exactly where a session-fixation
attack would persist, so the ID always changes. You do not need to call
regenerate() after login(). It is there for rotating outside login.
Awaiting also stamps the authentication time into the session, which is what
absolute_max_age() is measured against.
logout flushes the whole session rather than removing the user key.
Passwords
Argon2id, from the argon2 crate. No Arcature-written cryptography.
use arcature::auth::{PasswordConfig, PasswordHasher, PasswordHashString, PasswordSecret,
RehashOutcome, verify_password};
let hasher = PasswordHasher::new(PasswordConfig::default())?;
let stored = hasher.hash(b"correct horse battery staple")?;
let parsed = PasswordHashString::new(&row.password_hash)?;
verify_password(b"attempt", &parsed)?;
if matches!(hasher.needs_rehash(&parsed), RehashOutcome::Rehash) {
// parameters changed since this hash was written; rehash on next login
}
PasswordSecret wraps a plaintext password, PasswordHashString a
PHC-formatted stored hash. Both are secrecy-backed: Debug and Display
never expose the secret and the buffer zeroizes on drop. No plaintext
password, signing key, or token appears in logs, error output, or a Debug
line anywhere in the framework.
Sessions
Sessions are tower-sessions. Arcature owns the cookie attributes and the signed jar:
use std::time::Duration;
use arcature::auth::{SameSite, SessionConfig, SessionKey};
let key = SessionKey::generate()?; // or ::from_bytes(&secret)
let config = SessionConfig::new(key.as_bytes())?
.with_cookie_name("acme_session")
.with_same_site(SameSite::Lax)
.with_max_age(Duration::from_secs(60 * 60 * 2))
.with_absolute_max_age(Duration::from_secs(60 * 60 * 24 * 7));
let layer = config.into_layer(store)?;
SessionConfig::dev(key) relaxes Secure for plain HTTP in development.
arc key:generate produces a signing key.
The store is yours to choose: any tower_sessions::SessionStore. Behind
session-store-db, DbSessionStore keeps sessions in the application’s own
database, which is what stops a deploy from signing everybody out; the
scaffold wires it. Swap it for MemoryStore in a test, or for anything else
implementing the trait.
From a handler:
pub async fn handler(session: Session) -> Result<Response> {
session.put("last_seen", 1_700_000_000i64).await?;
let value: Option<i64> = session.get("last_seen").await?;
let taken: Option<i64> = session.forget("last_seen").await?;
session.regenerate().await?;
session.flush().await?;
Ok(no_content())
}
session.raw() borrows the underlying tower-sessions Session.
Flash writes one-shot messages read and cleared on the next request:
flash.success(..), .error(..), .warning(..), .info(..), and
flash.messages() to read them.
Sign-in flows
Everything above is a seam: hash a password, bind a user to a session,
authorize an action. A sign-in screen is those seams plus a handful of small
decisions where the obvious implementation is wrong in a way nothing tells
you about. auth::flows, behind auth-flows and off by default, owns that
handful and nothing else.
| Type | What the naive version leaks |
|---|---|
CredentialChecker | Skipping the Argon2 verification when the address is unknown turns response time into a working list of who has an account. |
EmailVerification | A link bound to the account rather than to the address verifies whichever address the account holds when it is clicked. |
LoginThrottle | Counting failures per account misses the actual attack, which is one guess each against ten thousand accounts; counting them per account only also hands anybody a way to lock anybody else out. |
PasswordConfirmation | A boolean in the session says a password was proved at some point, never says when, and is inherited by whoever holds the session next. |
PasswordResets (auth-reset) | Checking “is this token valid?” and then deleting it is two statements with a gap, and two requests carrying the same link both pass the check. |
RememberTokens (auth-remember) | A cookie that does not rotate cannot tell a returning user from a stolen one. |
Each type documents its own attack in full. This is the half of a sign-in form that is the same in every application, and the half where being wrong is silent — which together is the whole reason it is here rather than in yours.
Scaffolding the other half
The account table, the handlers, the routes and the HTML are the
application’s. arc make:auth user writes all but the last:
| File | Holds |
|---|---|
app/auth/user.rs | the account: the model, plus AuthUser and UserLoader |
app/auth/user_registration_controller.rs | sign-up |
app/auth/user_session_controller.rs | sign-in and sign-out |
app/auth/user_password_controller.rs | forgotten-password and reset |
app/auth/user_routes.rs | user_auth_routes() |
database/migrations/m<stamp>_create_users.rs | the table |
Every file is declared as it is written, so no pub mod line is left to add
by hand. Four notes are, and they are the whole of what is not automatic:
auth-flows and auth-reset are not in the feature list arc new writes;
the migration is not in Migrator::migrations(); the reset table is not in
that migration, because PasswordResets::new(pool).migrate() owns it; and
the route collection is not merged into bootstrap/app.rs.
Headless throughout — six files and no screens. Reach for arc make:page or
arc make:view for what the user actually looks at.
Authorization
Authorization is never automatic and never implied. Validation proves a
request is well-formed; Bound<T> proves a row exists; neither says the user
may act on it.
pub struct LinkPolicy;
impl arcature::Policy<Link> for LinkPolicy {
type User = User;
fn check(user: &User, action: &str, link: &Link) -> bool {
match action {
"view" => true,
"update" => user.id == link.user_id,
_ => false,
}
}
}
Call it through Auth::authorize:
pub async fn update(auth: Auth<User>, link: Bound<Link>) -> Result<Response> {
let link = link.into_inner();
auth.authorize::<Link, LinkPolicy>("update", &link)?;
Ok(no_content())
}
Both type parameters are required. authorize is generic over the model M
and the policy P, and Rust has no partial turbofish, so
authorize::<LinkPolicy>(..) does not compile — the model comes first. (The
doc comments on Auth::authorize and on the Policy trait show the
one-parameter form; they are wrong.)
false becomes AuthzError::Forbidden.
CSRF
CsrfLayer enforces a naive double-submit token. Not signed, not
session-bound: the server issues a random nonce in a cookie, the client echoes
it in a header, and the server compares the two. The strength is in the
cookie attributes, not in a signature.
What is exempt, by design:
- Safe methods:
GET,HEAD,OPTIONS,TRACE. These get a fresh cookie if the request did not carry one. - Bearer-token requests. An unsafe request carrying
Authorization: Bearer …is forwarded without the check and without a CSRF cookie. A bearer token is not sent automatically by the browser, so there is nothing to forge.
Unsafe non-bearer methods — POST, PUT, PATCH, DELETE — must present a
matching cookie and header, or the request is rejected with 403.
Three presets
| Preset | Cookie | Header | Secure | SameSite |
|---|---|---|---|---|
CsrfConfig::new() | __Host-csrf | x-csrf-token | yes | Strict |
CsrfConfig::dev() | arcature-csrf | x-csrf-token | no | Strict |
CsrfConfig::inertia() | XSRF-TOKEN | x-xsrf-token | yes | Lax |
new() is the strongest. The __Host- prefix mandates Secure, forbids
Domain, and pins the path to / (RFC 6265bis), so a sibling subdomain
cannot overwrite the cookie. SameSite=Strict keeps it off every cross-site
request. HttpOnly is false on purpose: JavaScript has to read the cookie to
put it in the header, and the header is the proof the page is same-origin.
Why an Inertia application uses inertia()
Inertia’s client is axios. Axios reads a cookie named XSRF-TOKEN and echoes
it in X-XSRF-TOKEN. Both are hard-coded and neither is configurable without
writing application JavaScript.
Against CsrfConfig::new(), an Inertia form is rejected with 403 until the
application ships a shim that reads the token and reconfigures axios. That
shim is exactly the framework-owned client package Arcature does not publish
(see ADR 0001), so the server moves to meet the client
instead.
Two attributes weaken, deliberately:
- No
__Host-prefix, because axios will not look for one. A sibling subdomain able to set cookies on the parent domain can then overwriteXSRF-TOKEN. That is a session-fixation-shaped attack on the nonce, not a way to read it, and it requires the attacker to already control a subdomain of your site. SameSite=Laxrather thanStrict. Strict withholds the cookie on any cross-site navigation — an OAuth callback, a link from an email — so the first page load after one arrives with no token at all. Lax sends it on top-level GET navigations, which is the case Strict breaks and not one CSRF exploits: a forged unsafe request is still cookie-less.
The full reasoning, including the cost, is ADR 0002.
Keeping new() and configuring axios yourself
If you would rather not weaken those two attributes, keep CsrfConfig::new()
and tell axios where to look. This is application JavaScript, in your own
codebase, not a framework package:
import axios from "axios";
axios.defaults.xsrfCookieName = "__Host-csrf";
axios.defaults.xsrfHeaderName = "X-CSRF-Token";
Both defaults are writable, so no interceptor is needed. The trade is one file you maintain against two cookie attributes you keep.
Overriding individual attributes
let config = CsrfConfig::new()
.with_cookie_name("__Host-app-csrf")
.with_header_name("x-app-csrf")
.with_same_site(SameSite::Lax)
.with_secure(true)?;
with_cookie_name auto-enables Secure when the name starts with __Host-,
and with_secure(false) returns an error if the cookie name carries that
prefix. The invalid combination is not representable.
What it does not defend against
Not XSS: same-origin script can read the cookie and send the header. Not anything the reverse proxy owns — TLS termination, rate limiting, request-size limits. It defends against forged cross-site unsafe requests from an authenticated browser, which is the attack it is named after.
What this module does not own
Your user model, roles, permissions, or account schema. Cryptography — Argon2,
HMAC, SHA-2 and TLS come from RustCrypto, cookie, and the certified rustls
plus aws-lc-rs path. The screens: auth::flows owns the decisions behind a
sign-in form and arc make:auth writes the handlers, but nothing in this
module renders a page.
Clients that have no cookie
Everything above assumes a browser holding a session. For a CLI, a CI job or
another service, the api-tokens feature issues an opaque bearer credential
instead: the plaintext is shown once, the database holds only a SHA-256
digest, and lookup is constant-time. Tokens carry abilities and an expiry.
It is deliberately independent of auth – an API with no passwords and no
sessions may still hand out a token, and should not be made to compile a
password hasher to do it. CSRF also steps aside for a request carrying
Authorization: Bearer, because a bearer request is not a browser-driven one.