Authentication

Email OTP and passkeys out of the box, FerrisKey OIDC when you need SSO

Overview

Authentication comes from oxidt-auth and runs in one of two modes, chosen by AUTH_MODE:

Mode What signs a user in What it needs
local (default) A one-time code emailed to the address, which also creates the account on first use, and passkeys (WebAuthn) stored in this app's own database. Postgres and SMTP, both in docker compose.
ferriskey FerrisKey OIDC behind the same custom login UI: passkeys and passwords held by FerrisKey, email OTP for new accounts. Apps sharing a realm get single sign-on. A FerrisKey instance with a realm and a confidential client. just ferriskey provides one locally.

Both modes end in the same place: a tower-sessions cookie backed by PostgreSQL, read by the UserSession extractor on server functions. Switching modes changes the login page and the routes under /auth/*; nothing else in the app knows the difference.

Local mode

1

User enters email

The login page POSTs to /auth/session/start. If the account has passkeys the browser is challenged at once; otherwise a 6-digit code goes out via SMTP.

2

Code or passkey is verified

/auth/session/otp/verify (or /auth/session/passkey/verify) opens the session. A verified code for an unknown address creates the account, subject to the registration policy.

3

Terms, then a passkey offer

When TOS_VERSION is set and the user has not accepted that version, the page asks first. Accounts without a passkey are then offered one; enrollment runs against this app as the WebAuthn Relying Party, whose ID is BASE_URL's host.

4

Client receives auth state

The login page bumps UserDataRefreshTrigger, which re-runs /api/me. UserAuthState becomes Authenticated and the user lands on the redirect URL without a full reload.

FerrisKey mode

The same page drives FerrisKey's REST API instead of local stores: /auth/session/start looks the address up in FerrisKey and branches on its credentials (passkey challenge, password prompt, or email OTP for a new account). On success the server exchanges the OIDC code for tokens with PKCE, validates the id_token against FerrisKey's JWKS, and opens the session. New accounts are created in FerrisKey through the client's service account, which is why the client needs the client_credentials grant.

Running FerrisKey locally

bash
just ferriskey

This starts FerrisKey 0.7 from the ferriskey compose profile (console at http://localhost:8090, admin / admin) and runs scripts/ferriskey-bootstrap.sh, which creates the realm and client the app expects, adds the redirect URI, gives the service account a user-management role, and prints the FERRISKEY_* values. If .env has an empty FERRISKEY_CLIENT_SECRET=, the script fills it in. Set AUTH_MODE=ferriskey and restart the app.

The script is idempotent and works against any FerrisKey, not only the local one: point FERRISKEY_URL and FERRISKEY_ADMIN_USERNAME / _PASSWORD at it.

Key Components

Server Side

The auth crate reaches storage through traits the app implements in src/server/:

rust
// AuthUserStore — find or create users, TOS acceptance, post-login redirect
#[async_trait]
pub trait AuthUserStore: Send + Sync {
    async fn get_user_by_sub(&self, sub: &str) -> AuthResult<Option<AuthUser>>;
    async fn get_user_by_email(&self, email: &str) -> AuthResult<Option<AuthUser>>;
    async fn create_user(&self, user: NewAuthUser) -> AuthResult<AuthUser>;
    async fn update_tos_acceptance(&self, user_id: &str, tos: AuthTosAcceptance) -> AuthResult<()>;
    // ... and a few more
}

// AuthPasskeyStore — WebAuthn credentials for local mode (user_passkeys table)
// AuthEmailSender — deliver OTP codes via SMTP
// AuthRateLimitStore — shared per-IP counters in PostgreSQL

src/server/router.rs builds one AuthState from these and mounts either local_auth_router or auth_router depending on AUTH_MODE.

UserSession Extractor

Server functions can require authentication by adding a session parameter:

rust
#[post("/api/me", session: auth::UserSession)]
async fn get_login_data() -> Result<Option<LoggedInData>, ServerFnError> {
    Ok(session.data().ok().map(LoggedInData::from))
}

The UserSession extractor reads the session from the cookie. If the session is missing or invalid, the server function returns an error.

Client Side

The App component provides auth state via context:

rust
#[derive(Clone, Debug, PartialEq)]
pub enum UserAuthState {
    Loading,
    Authenticated(LoggedInData),
    NotAuthenticated,
}

A use_server_future fetches /api/me on load. The UserDataRefreshTrigger signal allows any component to trigger a re-fetch (e.g., after login or profile update). The login route reads SiteFlags (fetched once by App) to render LocalLoginPage or LoginPage for the configured mode.

Protected Routes

The DashboardShell layout checks UserAuthState and redirects unauthenticated users to /login:

rust
#[component]
pub fn DashboardShell() -> Element {
    let user_auth = use_context::<Signal<UserAuthState>>();
    let nav = use_navigator();

    use_effect(move || {
        if let UserAuthState::NotAuthenticated = &*user_auth.read() {
            nav.push(Route::LoginPage {
                redirect_url: "/dashboard".to_string(),
            });
        }
    });

    // ... render dashboard layout
}

Configuration

Variable Description
AUTH_MODE local (default) or ferriskey.
TOS_VERSION Terms version users must have accepted, shown as an acceptance step on login. Unset means no step. Bump it after changing /legal/terms so everyone accepts again.
TRUST_PROXY_HEADERS Set to true when running behind a reverse proxy so auth rate limiting trusts X-Forwarded-For.

FerrisKey mode adds:

Variable Description
FERRISKEY_URL FerrisKey API URL (the local instance serves it at http://localhost:8090/api)
FERRISKEY_ISSUER_URL (Optional) Public OIDC issuer base URL. Falls back to FERRISKEY_URL with /api stripped.
FERRISKEY_REALM Realm name (e.g. myapp)
FERRISKEY_CLIENT_ID OIDC client ID registered in the realm
FERRISKEY_CLIENT_SECRET Client secret. Used for the authorization-code exchange and the client_credentials grant that manages users.

Registration policy

New accounts are created by the email-OTP flow in both modes, and who may create one is a deployment setting:

Variable Description
OPEN_REGISTRATION true lets any address sign up, the setting for a public product.
ALLOWED_REGISTRATION_EMAILS Comma-separated addresses that may register when registration is not open.
ALLOWED_REGISTRATION_DOMAINS Comma-separated email domains that may register when registration is not open.
CAPTCHA_URL / CAPTCHA_SITE_KEY / CAPTCHA_SECRET_KEY A Bollwark captcha in front of registration and the waitlist form. The login page mounts the widget and the server verifies its token; all three must be set.

With OPEN_REGISTRATION unset and both allowlists empty, only the very first account may register (first-run bootstrap) and sign-up closes afterwards.

Terms of service

/legal/terms and /legal/privacy are placeholder pages in src/pages/legal.rs, and the paths the acceptance step links to. Replace the text, then set or bump TOS_VERSION: the version a user accepted is stored on their row, and a mismatch on the next login brings the step back.

Security middleware

All routes mounted by the auth router are wrapped with:

  • Rate limiting — 20 requests/minute per client IP, counted in PostgreSQL so the quota holds across replicas.
  • CSRF Origin check — POST requests must carry Origin (or Referer) matching BASE_URL.
  • Body limit — 64 KiB, since every auth body is small.
Navigation