DocsServer features
Authentication
Rahti provides two things and no more: a signed session cookie, and a policy that says which URLs require one. Users, passwords, registration and anything resembling a role belong to the application.
There is deliberately no OAuth provider, no user table, and no role system in the framework. rahti has no database dependency and never sees a password.
| Piece | Owner |
|---|---|
| Session cookie, encode/decode, expiry | rahti::auth |
| Route guard and redirects | rahti::auth::guard, installed by generated code |
#[rpc(auth)] | rahti-macros |
| Which routes are private | The application, in src/auth.rs |
| Users, hashing, sign-up rules | The application |
The session
rahti_session = <base64url(payload)>.<base64url(hmac-sha256(payload))>
payload = { "user": <whatever you signed in>, "exp": <unix seconds> }- Signed, not encrypted. Anyone holding the cookie can read the payload. Put an id and the handful of fields the UI needs in it — never a hash, a token, or anything you would not show the user.
- Stateless. Nothing is stored server-side, so nothing can be revoked. A cookie is valid until it expires; keep the lifetime short if that matters.
HttpOnly,SameSite=Lax,Path=/, andSecureoutside development. Unlike the CSRF cookie, nothing in the browser reads this one.- Expiry is checked on the server from the payload, not from
Max-Age.
Environment
| Variable | Required | Default | What it is |
|---|---|---|---|
| AUTH_SECRET | In production | A per-process random key | The HMAC signing key. |
| AUTH_COOKIE_NAME | No | rahti_session | The cookie's name. |
| SESSION_LIFETIME_HOURS | No | 1 | How long a session lasts. |
cargo rahti new generates the first two per project and writes them to .env, so no two Rahti applications share a signing key or a cookie name — whether or not the project has a database.
openssl rand -base64 32In development it falls back, inventing a key and warning, so a fresh clone runs with no setup — at the cost of everyone being signed out on restart. "Production" here means the absence of development mode, so a release-mode test run needs the variable set, exactly as a deployment does. And the refusal only applies to an application that asked for auth by configuring a policy: a project with no private routes is not killed over a key it was never going to use.
AUTH_COOKIE_NAME is validated as an RFC 6265 token, because both ways a bad name fails are silent: a Cookie header is split on ; and =, so a name carrying either is written but can never be read back — every sign-in appears to succeed and nobody stays signed in. It is not a credential; it is the project's identity under a shared parent domain, where two applications sharing a name would overwrite each other's sessions.
SESSION_LIFETIME_HOURS is the only one of the three that is ordinary configuration. A present but unreadable value — 24h — is refused rather than defaulted: falling back to one hour would sign people out all day for no visible reason.
The policy
// src/auth.rs — the policy is the application's, and this is all of it.
use rahti::auth::AuthSettings;
pub fn settings() -> AuthSettings {
AuthSettings {
private_routes: vec!["/account".to_string()],
auth_routes: vec!["/signin".to_string()],
after_signin: "/account".to_string(),
after_signout: "/".to_string(),
signin: "/signin".to_string(),
..AuthSettings::from_env()
}
}// src/main.rs — installed once, before the router is built.
rahti::auth::configure(auth::settings());| Field | Meaning |
|---|---|
| secret | HMAC key. AUTH_SECRET, or a per-process random one. |
| cookie | Cookie name. AUTH_COOKIE_NAME. |
| validity | Session lifetime. |
| sliding | Re-issue on every authenticated request, making validity an idle timeout. Off by default. |
| all_private | Protect everything except public_routes and auth_routes. |
| private_routes | The routes that need a session. Read only when all_private is off. |
| public_routes | The exceptions. Read only when all_private is on. |
| auth_routes | Sign-in and sign-up: public, and redirected away from once signed in. |
| signin | Where a signed-out visitor to a private route is sent, with ?next=. |
| after_signin / after_signout | Where each lands. |
Two ways round, and the choice is the application's: mostly public — leave all_private off and list the private routes — or mostly private, setting all_private and listing the exceptions. auth_routes is never private under either mode: a login you must be logged in to reach is a locked-out application.
How a route is matched
A listed route covers its subtree: /account protects /account/billing and everything below it. / is the exception — it covers only itself, or listing it as public would make the whole application public. A trailing slash is not a different route.
| Meaning | File-tree form | Axum form |
|---|---|---|
| One dynamic segment | /customers/[id] | /customers/{id} |
| One or more | /posts/[...id] | /posts/{*id} |
| Zero or more | /docs/[[...slug]] | — |
The guard
The guard is installed by generated code as the outermost layer of every page group, every route.rs group, and the rpc router. It is not installed over public/: a stylesheet needs no session, and an all-private application whose stylesheet redirected to the sign-in page would render that page unstyled.
Per request, in order:
- decode and verify the cookie; discard it if expired;
- RPC calls on a private route with no session get a 401 with a JSON error — never a redirect, because
fetchwould follow it and hand the sign-in page's HTML to a caller expecting JSON; - navigations to an auth route while signed in redirect to
?next=, or toafter_signin; - navigations to a private route with no session redirect to the sign-in page with a 303;
- otherwise the handler runs, inside a task-local session scope;
- on the way out, whatever
sign_inorsign_outasked for is written as aSet-Cookie.
Reading the session
Handlers take no framework state, so the session is reached by calling a function — the same shape as crate::db::db().
rahti::auth::is_authenticated() -> bool
rahti::auth::session() -> Option<Session>
rahti::auth::user::<T>() -> Option<T> // T: DeserializeOwned
rahti::auth::sign_in(&value) // value: Serialize
rahti::auth::sign_out()
rahti::auth::require() -> Result<Session, Response>sign_in and sign_out do not write headers. They record what the guard should do, and the guard does it as the response leaves — so they are callable from a page, an rpc, or a route.rs handler, and the caller returns whatever it was going to return.
rahti::auth::settings() returns the policy that was installed; the application's own auth::settings() builds one. Call the builder once, from main, and read the installed one everywhere else — the builder goes through AuthSettings::from_env, which can refuse to start a release build, and a request handler is no place to call something that can exit the process.
Reading the session costs nothing and hits no database, which also means it is only as fresh as the last sign_in. Load the row when the value has to be current, and call sign_in again after changing anything the payload carries.
Protecting an rpc
#[rpc(auth)]
pub async fn rename(name: String) -> Result<user::Public> {
// The identity comes from the session, never from the payload. An `id`
// parameter would let anyone rename anyone.
let signed_in = auth::current().expect("`#[rpc(auth)]` answered");
user::rename(signed_in.id, &name).await
}The check runs before the payload is read and answers 401 with a JSON error — the shape pp.rpc already throws on, so the client needs no special case. #[rpc(auth)] registers under the same name and is dispatched identically: the build's scan, the manifest and the generated router see no difference.
Redirect safety
?next= arrives in a URL anyone can write, so it is checked in two places and never echoed back as it arrived: rahti::auth normalises it before redirecting, and the sign-in page checks it again before answering with it, because the value it echoes is the one that reaches the browser. A scheme, a host, a protocol-relative //host, or a backslash falls back to the configured default.
The runtime owns the other half. A browser never sends the fragment, so an SPA click on /account#billing reaches Rust as /account — and PulsePoint re-attaches the fragment to the next parameter the server produced:
SPA click /account#billing
-> 303 Location: /signin?next=/account (rahti::auth::guard)
-> /signin?next=%2Faccount%23billing (the runtime adds the fragment)
-> sign in
-> /account#billingWhich is why a sign-in page's own next check must accept a # in the value: by the time it comes back, the client has put one there.
Passwords
Application-owned, in src/auth.rs. What a sound implementation looks like:
- argon2id with a fresh random salt per user, stored as a PHC string;
- a length floor and nothing else — composition rules make passwords harder to remember and barely harder to guess;
- one error message for "no such user" and "wrong password", and a hash verified against a constant even when there is no user. Both are the same defence: a sign-in that fails faster for an unknown address enumerates your users for whoever asks;
- emails normalised before they are stored or looked up, with a unique index in the migration — the application's duplicate check is for the readable error; the constraint is what makes it true under a race;
- a wire type with no field for the hash, so what may leave the server is answered by a type rather than by care.
Testing
// The application's test harness installs the same policy `main` does.
// Without it, a suite exercises private routes that nothing protects.
rahti::auth::configure(crate::auth::settings());A suite that signs in and then calls a protected rpc needs to carry both cookies — the session and the CSRF token — from one response to the next request, the way a browser would.
What is not here
- OAuth providers. No Google, no GitHub, no provider abstraction.
- Roles and permissions. A call is from a session or it is not. Anything finer is a question about your users; answer it in the rpc body, where the row is in hand.
- Server-side revocation. Sign-out clears the cookie; it does not invalidate one already copied elsewhere.
- Remember-me, refresh tokens, device lists, MFA.
