Sign-in methods (AUTH_MODE) — Operator Guide
Which sign-in methods a deployment offers is a deliberate deployment decision. Outside
development the server refuses to boot until you choose:
AUTH_MODE = local | oidc | local+oidc # REQUIRED when OBSCURA_ENV != development
| Mode | What the login page offers | Who it's for |
|---|---|---|
local |
Email + password (plus LDAP when LDAP_ENABLED=true) |
Demos, small teams, air-gapped installs. Not recommended for enterprise — prefer your IdP. |
oidc |
SSO only. Password endpoints are disabled for everyone except break-glass admins (below). Self-registration is off regardless of the admin toggle. | Enterprise SSO deployments (Casdoor, Keycloak, any compliant OIDC IdP). |
local+oidc |
Both, side by side | Migration periods; mixed workforces. |
In development an unset AUTH_MODE falls back to the legacy behavior (local, plus SSO
when the deprecated OIDC_ENABLED=true is set) so dev and CI workflows are unchanged.
OIDC_ENABLED is superseded by AUTH_MODE everywhere else; setting both inconsistently
(AUTH_MODE=local + OIDC_ENABLED=true) is a boot error.
OIDC configuration
AUTH_MODE=oidc # or local+oidc
OIDC_KIND=casdoor # casdoor | keycloak | generic (log label only)
OIDC_ISSUER=https://sso.your-org.example
OIDC_CLIENT_ID=obscura
OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret # optional, see below
- The issuer must serve OIDC discovery (
<issuer>/.well-known/openid-configuration) and
be reachable from the obscura container at boot (discovery + token exchange) and
its authorize URL from users' browsers (the redirect). One stable hostname for both
is simplest. - Register the SPA callback in your IdP:
https://<your-obscura-host>/auth/callback. - Register the mobile callback too, on the same client:
obscura://auth/callback.
GET /auth/methodspublishes exactly oneclient_id, so the iOS/Android app reuses the
web application entry in your IdP and only needs its custom-scheme redirect URI added to
the allow-list. (Casdoor: Application → Redirect URLs. Keycloak: Valid redirect URIs.)
Without it the IdP rejects the sign-in withinvalid_redirect_uribefore any token is
issued, and the app can only offer the password form. - The flow is authorization-code + PKCE with a backend exchange (BFF): the browser
only ever hits the IdP's authorize URL; the code→token exchange happens server-side
(POST /api/v1/auth/oidc/exchange), so the token endpoint is never called from the
browser and the client secret (when configured) never leaves the server. A secret is
optional — PKCE alone is fine for most IdPs; add the secret if your IdP requires a
confidential client. - The login page discovers all of this at runtime via the public
GET /api/v1/auth/methods
(modes + authorize URL + client id — no secrets). The mobile app runs the same discovery
and renders the same three affordances (SSO button, password form, break-glass), so an
SSO-only deployment gets an SSO-only app screen with no dead password fields. - Mobile runs the identical flow, with two platform differences: the authorize page
opens in anASWebAuthenticationSession(iOS) / Custom Tab (Android) rather than a
top-level navigation, and the exchange call carries adeviceobject — so the backend
answers with an access/refresh token pair instead of a session cookie. The client secret
stays server-side exactly as on web, which is what makes shipping SSO in a binary anyone
can unzip safe.
Users signing in via OIDC are JIT-provisioned (provider='oidc') with no roles; an
admin assigns roles in-app, or you run SCIM provisioning from the IdP for automatic
user + group→role sync (see docs/SCIM.md — an OIDC first-login links to the matching
SCIM-provisioned account by email instead of duplicating it).
First administrator (bootstrap)
A fresh SSO-only deployment has no admin to assign roles. Set:
BOOTSTRAP_ADMIN_EMAIL=you@your-org.example
When an OIDC sign-in's email matches (case-insensitive), that account is granted the
wildcard admin role — idempotent, audited (auth.bootstrap_admin). Remove the env var
once real role assignments exist; the role itself persists.
Break-glass (IdP-outage escape hatch)
With AUTH_MODE=oidc, local password sign-in stays possible only for accounts
holding the wildcard admin role, so an IdP outage or misconfiguration can't lock you
out of Admin → Security:
AUTH_BREAK_GLASS=true(default). The login page shows a small "Administrator
sign-in" affordance (also reachable via/login?local=1); everyone else's password
logins are rejected server-side with the same generic error as a wrong password.- Break-glass accounts are forced to enroll TOTP regardless of the admin-tunable
require-TOTP setting — the escape hatch is never single-factor. - The demo convention (seeded accounts with password == email) is dead in this mode; a
break-glass account must have a real password (create it via Admin → Users). AUTH_BREAK_GLASS=falsedisables every local password path. Recovery from an IdP
outage then requires flippingAUTH_MODEat the deployment level and restarting.
Recommended enterprise posture: AUTH_MODE=oidc, break-glass on, exactly one or two
local admin accounts with strong passwords + TOTP enrolled ahead of time, stored in your
password vault.
What AUTH_MODE=oidc enforces server-side
POST /auth/login— rejected except break-glass admins (generic error, no oracle).POST /auth/register—403 auth.register.disabledregardless of the DB toggle.- Hash-less "demo convention" logins — rejected outright.
- LDAP — contradictory (
LDAP_ENABLED=true+AUTH_MODE=oidcis a boot error; LDAP
rides the password form, uselocal+oidcinstead). - Hiding the password form in the SPA is cosmetic; the checks above are the enforcement.
Trying it locally (bundled Casdoor)
docker compose -f deploy/docker-compose.yml --profile casdoor up -d casdoor
AUTH_MODE=local+oidc OIDC_KIND=casdoor OIDC_ISSUER=http://casdoor:8000 \
OIDC_CLIENT_ID=obscura-local docker compose -f deploy/docker-compose.yml up -d obscura
deploy/casdoor/init_data.json seeds an org + the obscura-local application (secret
obscura-local-secret, callbacks for localhost:8091/5173) and two users:
sso.user@obscura.local / obscura-sso-pw and boss@obscura.local / obscura-boss-pw
(pair the latter with BOOTSTRAP_ADMIN_EMAIL=boss@obscura.local to see bootstrap).
The issuer is pinned to http://casdoor:8000 (see deploy/casdoor/app.conf), so add
127.0.0.1 casdoor to your workstation's /etc/hosts for the browser leg. Dev/e2e
only — never ship these fixtures.
Passkeys (WebAuthn)
Passkeys ride the local credential paths, so they work in local and local+oidc
mode, and for break-glass admins in oidc mode. They are enabled automatically when the
relying party can be resolved:
# Derived from APP_BASE_URL unless overridden:
WEBAUTHN_RP_ID=obscura.your-org.example # default: APP_BASE_URL hostname
WEBAUTHN_ORIGINS=https://obscura.your-org.example # default: APP_BASE_URL, comma-separated for many
- Sign-in is usernameless — discoverable credentials with user verification required,
so a passkey is a possession factor and user verification: a complete two-factor
sign-in with no password and no separate TOTP step. The login page shows "Sign in with
a passkey" wherever a local form appears. - Enrollment is self-service from Profile → Security → Passkeys (add/name/remove).
Registering a passkey also satisfies the forced second-factor gate, so a break-glass
admin can choose a passkey instead of TOTP. - Browser + origin. WebAuthn requires a secure context: production must be HTTPS (or
localhostfor local testing). The RP ID must be the registrable domain of every
origin the app is served from. - The exchange is a standard begin→finish ceremony behind an opaque one-time token; the
server verifies attestation/assertion and tracks the authenticator sign count (a clone
warning rejects the login).
2FA recovery matrix
A lost authenticator must never mean a permanently locked account. The paths, by mode:
| Situation | Recovery |
|---|---|
| User lost TOTP device, has a passkey | Sign in with the passkey (it's a full second factor). |
| User lost TOTP device, no passkey | An admin runs Reset two-factor (Admin → Users → the user); they re-enroll on next sign-in. |
Admin (local mode) lost their only factor |
Another admin resets it; keep ≥2 admins. |
Break-glass admin (oidc mode) lost their factor |
Another admin resets it; if none remain, an operator flips AUTH_MODE at the deployment and restarts. Keep ≥2 break-glass admins with factors pre-enrolled. |
Whole IdP is down (oidc mode) |
Break-glass password (+ TOTP or passkey) admin login — the escape hatch this doc's break-glass section describes. |
Admin reset 2FA removes only the TOTP enrollment (DELETE /admin/users/{id}/totp,
audited auth.totp.admin_reset). It does not touch passkeys — a user manages those
themselves — and is rejected for directory-managed accounts (their MFA lives with the IdP).
Other hardening (applies to every mode with local sign-in)
- Passwords: default minimum 12 characters (hard floor 8, tunable in Admin →
Security), an embedded common-password denylist (a floor, not a toggle), and a rule that
a password may not contain the account's email name. Existing stored passwords are
untouched; the policy applies when a password is set or changed. - TOTP replay guard: an accepted code is burned for ~2 minutes (RFC 6238 §5.2), so a
shoulder-surfed or phished code can't be replayed inside its validity window. - Login timing: an unknown email costs the same as a wrong password (a dummy hash
verify on the miss path), closing the response-time user-enumeration oracle. - Password change revokes other sessions: changing your password from Profile keeps
your current session and kills every other one — changing it on suspicion of compromise
actually evicts a hijacker. - Break-glass logins are audited distinctly (
auth.login.break_glass) so a SOC can
alert on any use of the escape hatch.
Mobile
The mobile app authenticates with email+password today (device sessions). In an
SSO-only deployment that flow is subject to the same break-glass rule — i.e. regular
users can't password-login on mobile either. Mobile OIDC (system browser + PKCE against
the same /auth/oidc/exchange, which already accepts a device object) is the planned
Phase-3 item in docs/MOBILE_APP_PLAN.md; until it lands, SSO-only deployments should
treat the mobile app as not-yet-enabled for non-admin users.
Related
docs/SCIM.md— IdP-driven provisioning + group→role sync (pairs withoidcmode).docs/LDAP.md— directory password auth (alocal-form method;LDAP_MODE=only
has the same break-glass-admins exception this page describes for OIDC).LICENSING.md— module entitlements (orthogonal to auth).