Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

ExtractorBehaviour
Auth<U>the signed-in user, or a 401 rejection
OptionalAuth<U>Option<U>, never rejects
AuthManager<U>login, logout, session rotation
Sessionthe session store
Flashflash messages
CsrfTokenthe 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.

TypeWhat the naive version leaks
CredentialCheckerSkipping the Argon2 verification when the address is unknown turns response time into a working list of who has an account.
EmailVerificationA link bound to the account rather than to the address verifies whichever address the account holds when it is clicked.
LoginThrottleCounting 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.
PasswordConfirmationA 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:

FileHolds
app/auth/user.rsthe account: the model, plus AuthUser and UserLoader
app/auth/user_registration_controller.rssign-up
app/auth/user_session_controller.rssign-in and sign-out
app/auth/user_password_controller.rsforgotten-password and reset
app/auth/user_routes.rsuser_auth_routes()
database/migrations/m<stamp>_create_users.rsthe 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

PresetCookieHeaderSecureSameSite
CsrfConfig::new()__Host-csrfx-csrf-tokenyesStrict
CsrfConfig::dev()arcature-csrfx-csrf-tokennoStrict
CsrfConfig::inertia()XSRF-TOKENx-xsrf-tokenyesLax

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 overwrite XSRF-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=Lax rather than Strict. 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.

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.