think
16px
820px

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/methods publishes exactly one client_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 with invalid_redirect_uri before 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 an ASWebAuthenticationSession (iOS) / Custom Tab (Android) rather than a
    top-level navigation, and the exchange call carries a device object — 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=false disables every local password path. Recovery from an IdP
    outage then requires flipping AUTH_MODE at 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/register403 auth.register.disabled regardless of the DB toggle.
  • Hash-less "demo convention" logins — rejected outright.
  • LDAP — contradictory (LDAP_ENABLED=true + AUTH_MODE=oidc is a boot error; LDAP
    rides the password form, use local+oidc instead).
  • 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
    localhost for 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 with oidc mode).
  • docs/LDAP.md — directory password auth (a local-form method; LDAP_MODE=only
    has the same break-glass-admins exception this page describes for OIDC).
  • LICENSING.md — module entitlements (orthogonal to auth).