Parts · v0.7.0
auth-core
Accounts and sessions every auth sample shares: users, database sessions with rotation and revocation, an account security page (devices, activity), re-auth for sensitive actions, email change, security emails with a 'this wasn't me' link, an audit trail and progressive throttling
- Add it
cargo offrails add auth-core- Requires
- mailer, dev-mailbox (added first)
- Needed by
- auth-magic-link, auth-password, auth-totp
- Crates
hmac- Files
37 files at app paths, 32 wiring lines
migration/src/m20261016_001000_create_users.rsmigration/src/m20261016_001100_create_sessions.rsmigration/src/m20261016_001200_create_user_tokens.rsmigration/src/m20261016_001300_create_auth_events_and_throttles.rssrc/controllers/account.rssrc/controllers/auth.rssrc/controllers/security.rssrc/controllers/sessions.rssrc/kernel/auth_token.rssrc/kernel/background.rssrc/kernel/return_to.rssrc/kernel/session.rssrc/kernel/user_agent.rssrc/mailers/auth_mailer.rssrc/models/auth_event.rssrc/models/auth_throttle.rssrc/models/session.rssrc/models/user.rssrc/models/user_token.rssrc/tasks/auth_cleanup.rssrc/tasks/auth_demo.rssrc/tasks/auth_invite.rssrc/views/auth.rsstatic/auth.jstemplates/account/confirm.htmltemplates/account/email_confirm.htmltemplates/account/email_edit.htmltemplates/account/show.htmltemplates/auth/check_email.htmltemplates/auth/invalid_link.htmltemplates/mailers/auth_mailer/message.htmltemplates/mailers/auth_mailer/message.txttemplates/security/not_me.htmltemplates/sessions/new.htmltemplates/shared/_auth_nav.htmltemplates/shared/_dev_mailbox_hint.htmltests/app/auth_core_test.rs
.offrails/template/parts/auth-core/README.md
auth-core
The accounts layer every auth sample shares: users, database sessions, the account security page and the safety machinery around them. It has no sign-in form of its own: install it together with auth-password, auth-magic-link or both, and optionally auth-totp on top. tools/install.sh installs it automatically as their dependency.
What you get (the Rails 8 generator, phx.gen.auth and Devise each cover part of this; none covers all of it):
- Database sessions (Rails 8 style): a random 256-bit cookie token, only its SHA-256 stored; 30 days idle and 90 days absolute expiry; the token rotates on every sign-in and every re-authentication;
__Host-prefixed,Secure,HttpOnly,SameSite=Laxcookies in production. - An account security page: the email address (verified or not), a card per installed method (password, two-factor), where you're signed in (device, browser, IP, sign-in method, "active 5 minutes ago"), revoke one device or sign out everywhere else, and recent security activity from the audit trail.
- Re-authentication ("sudo mode") before sensitive actions:
RequireRecentAuthsends people to "Confirm it's you" when their last proof is older than 10 minutes. With two-factor on, only a code can confirm. - Email change that needs re-auth, a confirmation link to the new address, and a warning to the old one.
- Security emails (new sign-in from a new device, password changed, email changed, two-factor on/off, recovery code used, sign-in paused), each with a "This wasn't me" link that signs the account out everywhere and forgets trusted devices.
- An audit trail (
auth_events): sign-ins, failures, changes; never a secret. - Progressive throttling (
auth_throttles): database-backed, counted up front under a row lock, refused attempts never extend the pause, keyed by an HMAC so unknown and known accounts behave identically. - Open or invite-only sign-up, one switch for every method:
Signup::OpenorSignup::Invite(DEFAULT_SIGNUPinsrc/config.rs, overridden byAPP_AUTH_SIGNUP=open|invite). Invite-only answers 404 on the sign-up pages (auth-password) and sends sign-in links only to existing accounts (auth-magic-link); no route edits, no test deletions. People get in withapp task auth_invite <email>, which creates the account unverified and emails the invitation (with auth-password: a link to choose a password, which also verifies the address). - Return-to that only ever redirects to a local path, a dev-mailbox hint on auth screens in development, and a demo task with obviously fake credentials.
Extracted from Rust off Rails apps and improved: the originals had sessions and return-to, but no rotation on re-auth, no absolute expiry, no device list, no audit trail, no security emails and no shared throttle.
Guides: .offrails/guides/auth.md, .offrails/guides/security.md. Depends on: mailer (the security emails) and dev-mailbox (the "check your mailbox" hint in development).
Screens
Every screen in light and dark, on desktop and phone: the samples repository.
Install
By hand, in the app root, after dev-mailbox:
cargo add hmac(never with a version; read the resolved one inCargo.lock).- Copy
files/into the app, keeping the paths. Replaceoffrails_hostwith your crate name in the test file. - Wiring, each at its
samples: <anchor>comment (the exact text is insample.toml):src/kernel/mod.rs:auth_token,background,return_to,session,user_agentmodules.src/models/mod.rs:auth_event,auth_throttle,session,user,user_token, andpub type User = user::Model;.src/controllers/mod.rs:account,auth,security,sessions.src/views/mod.rs:pub mod auth;and fourCtxfields,current_user,dev_mailbox,auth_js(the digested URL ofstatic/auth.js) andsignup_open(sign-up links show only then);src/controllers/application.rsfills them inCtx's extractor.templates/layouts/application.html:<script src="{{ ctx.auth_js }}" defer></script>atsamples: app head, so every app page loads it. Never in a page's{% block head %}: links and forms are boosted, and a boosted click runs nothing in the next page's<head>(the Show button, Caps Lock hint and code auto-submit would be dead until a reload).src/mailers/mod.rs:pub mod auth_mailer;;src/mailers/previews.rs: two previews.src/state.rs: abackground: Backgroundfield, built withBackground::default().src/config.rs: anauth: AuthConfigfield (APP_SECRET_KEY,APP_AUTH_SIGNUP) with its section (theSignupenum andDEFAULT_SIGNUPincluded), parse, test andDebuglines.src/routes.rs: the routes below, theirpaths::helpers andROUTESrows.templates/shared/_nav.html:{% include "shared/_auth_nav.html" %}.src/tasks/mod.rs: theauth_cleanup,auth_demoandauth_invitetasks.migration/src/lib.rs: the four migrations..env.example,compose.yaml:APP_SECRET_KEY,APP_AUTH_SIGNUP.src/kernel/redact.rs: the two token routes (/account/email/confirm/{token},/security/not-me/{token}) inTOKEN_ROUTES, so logs show[redacted]for them.tests/app/main.rs:mod auth_core_test;;tests/app/factories.rs:user();tests/app/support.rs:settle,sign_in_as,emails_to,link_in.
cargo fmt --all && cargo run -- db migrate && cargo run -q -- routes, then the Definition of Done (.offrails/workflow.md).
tools/install.sh auth-core <app> does steps 1 to 3 mechanically.
After installing
- Install at least one sign-in method: auth-password, auth-magic-link, or both.
- Set
APP_SECRET_KEY(at least 32 characters:openssl rand -base64 48) in the deploy configuration. Production refuses to boot without it. - Schedule
app task auth_cleanupdaily (cron, or the jobs sample's scheduler). - Set
ACCOUNTS:inAGENTS.mdand log the install indocs/decisions.md. The header names the sign-in methods, joined by+, theninvitewhen sign-up is closed:ACCOUNTS: password,ACCOUNTS: magic-link,ACCOUNTS: password+magic-link invite,ACCOUNTS: password+totp invite(the older single words map onto it:signup=password,invite=password invite,magic-link-invite=magic-link invite).invitemeansDEFAULT_SIGNUP = Signup::Invite. - Invite-only? Set
DEFAULT_SIGNUPtoSignup::Inviteinsrc/config.rs(// APP:), not onlyAPP_AUTH_SIGNUPin one deploy, so a forgotten variable can't open sign-up. Create the first accounts withapp task auth_invite <email>(it works in production: shell access is the admin). - Point
paths::after_sign_in()(// APP:insrc/routes.rs) at your app's home for signed-in people. The auth tests assert sign-in redirects withpaths::after_sign_in()and account-page redirects withpaths::account(), so they follow the change (tools/ci.sh allreruns them with it moved). - Protect your own handlers:
RequireUser(user)on every private page,RequireRecentAuthon anything that changes credentials or money, and an owner-scoped query (find_for(db, &user, id)) for every record.
Adapting it
- Longer or shorter sessions:
IDLE_DAYSandABSOLUTE_DAYSinsrc/kernel/session.rs(// APP:). Keep idle ≤ absolute. - A stricter or looser "sudo mode":
REAUTH_WINDOWinsrc/controllers/auth.rs(// APP:). - Your own sensitive action (deleting the account, changing payout details): take
RequireRecentAuthinstead ofRequireUserin its handler, record anauth_event(add aKind,// APP:), andauth::notifythe owner with a newNoticevariant insrc/mailers/auth_mailer.rs. - Invite people from an admin screen instead of the task: call
crate::tasks::auth_invite::invite(&state, &email)insidestate.background.spawn(…)from a handler that takes your admin extractor, so the request never holds the invitation token. Rename the task (create_author,invite_member) insrc/tasks/mod.rsif your domain has a word for it; its wording is the// APP:block inauth_invite.rs. - Rename
UsertoMember: renamesrc/models/user.rsand its table in a new migration; the aliaspub type User = user::Model;inmodels/mod.rskeeps call sites short while you do it. - Add OAuth later ("Sign in with …"): a new controller that verifies the provider's response, then calls
auth::sign_in(state, headers, meta, user, "oauth"). That one call gives it rotation, return-to, the audit trail, new-device emails and two-factor (with auth-totp). Store the provider identity in its own table, likepassword_credentials. - Throttle wording and pace:
FREE_FAILURES,FIRST_PAUSE_SECS,MAX_PAUSE_SECSinsrc/models/auth_throttle.rs(// APP:). - Security email wording:
notice()insrc/mailers/auth_mailer.rs(// APP:).
Do not change
static/auth.jsloads from the app layout on every page, once, and only listens ondocument, so it works after boosted links and htmx swaps (auth_js_loads_from_the_layout_on_every_page,app_pages_load_no_scripts_or_stylesheets_in_their_head_block, and in a browsertools/e2e.sh).- Tokens are stored as digests only and compared by digest or in constant time (
only_token_digests_are_stored,constant_time_eq_compares_content). - Every sign-in goes through
auth::sign_in→finish_sign_in, which rotates the session (sign_in_rotates_the_session_and_kills_the_old_token,a_session_planted_before_sign_in_is_worthless_after_it). - Production reads only
__Host-cookies (production_ignores_a_planted_unprefixed_session_cookie,production_auth_cookies_are_host_prefixed_and_secure). return_to::is_safe(return_to_rejects_open_redirects).- Scoped revocation (
revoking_another_users_session_is_404). - Sensitive routes take
RequireRecentAuth(sensitive_actions_require_recent_auth). - Throttling counts before checking and never extends a pause (
parallel_guesses_are_counted_up_front,refused_attempts_do_not_extend_the_pause). - Token pages are
no-store+no-referrerand never echo the token into canonical URLs (token_pages_are_not_cached_and_send_no_referrer). - Tokens are minted inside the background task that sends the email, and no secret is logged (
session_tokens_never_reach_the_logs). - Every handler that creates an account from a form takes
SignupOpen, and every sign-in method readsconfig.auth.signup(invite_only_has_no_public_sign_up,invite_only_magic_links_go_only_to_existing_accounts); an invitation never touches a verified account (inviting_a_verified_account_changes_nothing). Tests that need open sign-up ask for it (auth_core_test::app_with_signup(Signup::Open)), so changingDEFAULT_SIGNUPbreaks no test.
How it works
any sign-in method ──► auth::sign_in ──[samples: before sign in]──► finish_sign_in
(auth-totp may answer "/two-factor") │
├ delete this browser's old session (rotation)
├ insert the new session (digest only)
├ audit "signed_in"; new device? → email with "this wasn't me"
└ cookies + redirect to return-to (local paths only)Sign-in methods plug in through anchors in this sample's own files: {# samples: sign in methods #} / sign in alternatives in templates/sessions/new.html, confirm second factor / confirm methods / confirm alternatives in templates/account/confirm.html, account fields / account load / account init and account sections for the account page, before sign in, step-up second factor, revoke everything, revoke other access and claim unverified account (delete the method's credential when the address's owner claims an unverified account: pre-hijacking) in controllers/auth.rs, auth invite in tasks/auth_invite.rs (the invitation's button: auth-password puts its set-password link there), and auth events in models/auth_event.rs (record: every audit event, where dev-dashboard's optional wire feeds its brute-force and credential-stuffing detectors). That is how the samples combine without editing each other.
Emails go out through state.background (no job queue needed): the request returns immediately, the task mints its token and sends. app.settle() waits for them in tests.
| Route | Handler | Access |
|---|---|---|
GET /session/new | the sign-in page (one form per installed method) | public |
POST /session/delete | sign out (deletes the session row) | user |
GET /account | account security page | user |
GET /account/confirm | "Confirm it's you" | user |
POST /account/sessions/{id}/delete | sign out one device | user |
POST /account/sessions/delete-others | sign out everywhere else | user |
GET /account/email/edit, POST /account/email | change email (re-auth) | user |
GET/POST /account/email/confirm/{token} | confirm the new address | public (token) |
GET/POST /security/not-me/{token} | "This wasn't me" | public (token) |
Files
| File | What |
|---|---|
src/kernel/auth_token.rs | random tokens, SHA-256 and keyed digests, constant-time compare, numeric codes |
src/kernel/session.rs | auth cookie names and flags (__Host- in production), lifetimes |
src/kernel/return_to.rs | the return-to cookie, local paths only |
src/kernel/background.rs | fire-and-forget email tasks, awaitable in tests |
src/kernel/user_agent.rs | "Firefox on macOS" labels |
src/models/user.rs | accounts, email normalization and validation |
src/models/session.rs | sessions: start, find, touch, rotate, revoke |
src/models/user_token.rs | single-use emailed tokens (verification, reset, email change, not-me) |
src/models/auth_event.rs | the audit trail |
src/models/auth_throttle.rs | progressive delays |
src/controllers/auth.rs | extractors (SignupOpen included), sign_in, grant_step_up, revoke_everything, notify |
src/controllers/sessions.rs | sign-in page, sign out |
src/controllers/account.rs | account page, sessions, re-auth page, email change |
src/controllers/security.rs | "This wasn't me" |
src/views/auth.rs | page structs, time_ago |
src/mailers/auth_mailer.rs | one template pair for every auth email; security notices |
src/tasks/auth_cleanup.rs, src/tasks/auth_demo.rs, src/tasks/auth_invite.rs | cleanup; development demo accounts; invitations (app task auth_invite <email>, or invite from an admin screen) |
templates/… | sign-in, account, confirm, email change, not-me, check-email, invalid-link, nav, dev hint, mail |
static/auth.js | show/hide password, Caps Lock hint, one-time-code auto-submit, copy, print: delegated document listeners, loaded once by the layout |
migration/src/m20261016_0010…_0013… | users, sessions, user_tokens, auth_events + auth_throttles |
tests/app/auth_core_test.rs | the tests named in CHECKS.md, plus LogCapture |
screenshots.mjs (not installed) | the screenshots for all four auth samples |