Files
Oxicloud/docs/config/authentication.md
T
Edouard Vanbelle 5ebe2d3bae feat(oidc): add auto-redirect for OIDC
add `auto_redirect_if_standalone_oidc` in `OXICLOUD_AUTH_POLICIES`
let admin decide to redirect immediately to IdP if OIDC is the only auth method enabled
2026-08-03 01:01:00 +02:00

14 KiB

Authentication

OxiCloud ships with JWT-based authentication and Argon2id password hashing for local accounts. It also exposes status and OIDC-related auth endpoints under the same /api/auth namespace, plus a magic-link (email link) sign-in flow for accounts that don't use a password.

Core Endpoints

Method Endpoint Description
POST /api/auth/register Create a local user account. email is required; username and password are both optional.
POST /api/auth/login Exchange an identifier (username or email — dispatches on @) and password for access and refresh tokens
POST /api/auth/magic-link/send Send a one-click sign-in link to the account's email. Accepts either a username or an email in the request body
GET /magic/v1/{token} Redeem a magic-link — creates a session and stamps email_verified_at on the account
POST /api/auth/refresh Refresh the session tokens
GET /api/auth/me Return the current authenticated user
PUT /api/auth/change-password Change the current user's password (requires the current password)
POST /api/auth/logout Invalidate the current session
GET /api/auth/status Return auth system state, including OIDC availability

OIDC Endpoints Under Auth

Method Endpoint Description
GET /api/auth/oidc/providers Report which self-service auth methods this deployment offers (see fields below)
GET /api/auth/oidc/authorize Build the authorization redirect URL
GET /api/auth/oidc/callback Handle provider redirect callback
POST /api/auth/oidc/exchange Exchange the auth code for OxiCloud session tokens

GET /api/auth/oidc/providers fields:

Field Meaning
enabled OIDC is configured on this deployment
provider_name Display name for the IdP (shown on the SSO button)
authorize_endpoint Where the SPA should start the OIDC round-trip
password_login_enabled POST /api/auth/login will accept credentials
magic_link_login_enabled POST /api/auth/magic-link/send will mint tokens (SMTP wired + allowlist + no OIDC — see rules below)
require_verified_email OXICLOUD_REQUIRE_VERIFIED_EMAIL is set — the SPA uses this hint to explain EmailNotVerified responses

Configuring which methods are offered

Two environment variables control the auth surface. OIDC is a first-class allowlist token alongside password and magic_link.

OXICLOUD_AUTH_METHODS

Comma-separated allowlist of password, magic_link, and/or oidc. Default (when unset): password,magic_link.

Configuration Effect
Unset Password + magic-link (OIDC gated separately by OXICLOUD_OIDC_ENABLED)
password,magic_link Same as unset — both self-service methods
password Password login OK. Magic-link send / redeem → 403 MagicLinkLoginDisabled
magic_link Password login → 403 PasswordLoginDisabled. Password-based register → 403 PasswordRegistrationDisabled. Email-only signup still works
oidc SSO-only posture. Requires OXICLOUD_OIDC_ENABLED=true + a full OIDC config bucket; local password + magic-link both disabled
password,oidc Hybrid: local password + SSO, no magic-link
password,magic_link,oidc Everything on

Fail-fast. Misconfiguration panics at boot instead of degrading silently:

  • Unknown token (e.g. password,sso2) → boot panic with expected: password, magic_link, oidc
  • Empty allowlist (e.g. OXICLOUD_AUTH_METHODS=) → boot panic (would lock everyone out otherwise)
  • oidc listed but OXICLOUD_OIDC_ENABLED != true → boot panic (advertising a method the server can't serve)

Loose semantic (documented). The symmetric case is NOT fatal yet: when OXICLOUD_AUTH_METHODS is explicitly set WITHOUT oidc but OXICLOUD_OIDC_ENABLED=true, OIDC is served in addition to the listed methods — the enabled flag wins. A warning is logged at boot to make the mismatch visible. Planned for the next major release: this will escalate to a fail-fast panic so AUTH_METHODS becomes the authoritative allowlist for OIDC too. Align configs now (either add oidc to the list or set OXICLOUD_OIDC_ENABLED=false) to avoid the breaking change.

Startup gate. If magic_link is the only working method (no password, no oidc) AND no SMTP transport is configured (OXICLOUD_SMTP_HOST empty), the server refuses to start with a fatal message. A magic-link-only policy without a working mailer silently locks every user out.

OIDC master rule. When OIDC is enabled, magic-link login is hard-disabled regardless of this list. The IdP is the identity boundary; magic-link would bypass any 2FA / step-up policy the IdP enforces. The startup gate above does not trigger in this case — OIDC provides the login path.

Legacy alias: OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true still removes password from the effective allowlist.

OXICLOUD_REQUIRE_VERIFIED_EMAIL

Default false. When true, POST /api/auth/login returns 403 EmailNotVerified for any account whose email_verified_at IS NULL.

Order matters: the verified-email check runs after password validation. An attacker without the password sees only the generic Invalid credentials shape — they can't probe whether an account's email is verified.

Verification piggyback. When the branch fires (password OK, email unverified), the server auto-sends a verification magic-link to the account's registered address using the same login request. The user sees EmailNotVerified in the response and a "check your inbox" hint on the login page; resubmitting the form re-sends the link. This is why there is no separate "resend verification" endpoint — offering an unauthenticated one would leak has_password state.

Admin exemption. Admin accounts (role admin) are exempt from this gate at login, regardless of email_verified_at. Rationale: an operator who flips the flag on an existing deployment must not lock the admin(s) out of their own instance. Fresh admin accounts created via POST /api/setup or POST /api/admin/users are stamped verified at creation; the exemption covers pre-existing accounts that predate the flag.

Auto-verified on creation: OIDC-JIT users, admin-created users (POST /api/admin/users), and the first-run setup admin (POST /api/setup). Verification is only ever missing on regular users who signed up before the flag was turned on.

Login identifier dispatch

POST /api/auth/login accepts either a username (no @) or an email (contains @) in the username field. The two namespaces are provably disjoint — usernames forbid @ — so the dispatch is unambiguous and both paths return the same session shape.

POST /api/auth/magic-link/send mirrors this convention. The email field can be either an email or a username; the server resolves username → registered email before rate-limiting so both shapes share one budget (no bypass).

Registration flow

Since PR 18, both username and password are optional on POST /api/auth/register. The only required field is email.

Combination Result
email + password Classic signup — account gets a password hash; user can log in immediately
email + password + username Same, plus the username is claimed at creation
email only Email-only signup — no password stored; server sends a welcome magic-link. Clicking it creates a session and stamps email_verified_at. The user can later claim a handle via PATCH /api/auth/me/profile and set a password via PUT /api/auth/change-password

The response body is uniform across success, email collision, and username collision — the SPA does not learn whether an address is already taken. The real reason lands in the audit log.

OXICLOUD_DISABLE_REGISTRATION

Turns the endpoint off entirely (returns 403 RegistrationDisabled).

OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS

Comma-separated allowlist. Rejected registrations return 403 RegistrationDomainNotAllowed. Distinct from OXICLOUD_EXTERNAL_EMAIL_DOMAINS, which gates external-user invitations; self-registration and invitations have independent policies.

POST /api/auth/magic-link/send looks up the resolved email → user, then applies the eligibility ladder:

  1. OIDC-linked user → refused with reason="oidc_user". Unconditional; the IdP is the security boundary and may enforce MFA that magic-link would sidestep.
  2. Has a password configured → refused with reason="has_password" (default). Set OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users to allow — this weakens the password to mailbox-strength for affected accounts; opt-in only.
  3. No credential (typical external user or fresh email-only signup) → allow.

The verification-piggyback flow above deliberately bypasses the has_password gate — that path is only reachable after the user has already proven identity via password on the same login request, so mailbox-only trust is not being extended beyond what the password already established.

Auth policy vector

OXICLOUD_AUTH_POLICIES is a comma-separated list of additive policy switches. Distinct from OXICLOUD_AUTH_METHODS (which enables/disables a method wholesale), each entry here grants a specific exception or restriction to default auth behaviour. Vector shape so future policies can be added by appending a token instead of introducing a new env var per behaviour. Variant names carry their own polarity (Permit..., future Require... / Deny...).

Token Effect
permit_magic_link_for_password_users Allow magic-link login for accounts that also have a password. OIDC-linked users are still refused.
auto_redirect_if_standalone_oidc When OIDC is the ONLY working login method (no password, no magic-link — via allowlist or the OIDC-master rule), the login SPA auto-redirects to the IdP on page load instead of showing a click-to-continue SSO button. Off by default to avoid redirect loops on IdP failure and to preserve logout UX (logging out then visiting /login would otherwise bounce the user right back in). Silent no-op when other methods are also live. Frontend reads this via auto_redirect_to_oidc on GET /api/auth/oidc/providers.

Unknown tokens are logged-and-skipped at startup so a typo doesn't silently zero the vector.

Example Flows

Register — classic

{ "username": "testuser", "email": "test@example.com", "password": "SecurePassword123" }

Register — email-only

{ "email": "test@example.com" }

Login

{ "username": "testuser", "password": "SecurePassword123" }

Or equivalently:

{ "username": "test@example.com", "password": "SecurePassword123" }

Typical successful login response:

{ "accessToken": "...", "refreshToken": "...", "expiresIn": 3600 }
{ "email": "testuser" }

Uniform response regardless of whether the account exists / is eligible:

{ "message": "If an account exists for that email, a sign-in link will be sent." }

Current User

GET /api/auth/me returns the authenticated user's identity, role, email_verified_at, and storage information.

Distinguished error codes

The error_type field on 4xx responses lets frontends render specific UX. Codes surfaced by this subsystem:

error_type HTTP Meaning
PasswordLoginDisabled 403 OXICLOUD_AUTH_METHODS doesn't include password
PasswordRegistrationDisabled 403 Same, on register with a password field
MagicLinkLoginDisabled 403 OXICLOUD_AUTH_METHODS doesn't include magic_link, OIDC is enabled, or email-only signup is attempted on a password-only deployment
EmailNotVerified 403 Password validated, but email_verified_at IS NULL and OXICLOUD_REQUIRE_VERIFIED_EMAIL=true. Server has already sent a verification link
RegistrationDisabled 403 Global registration off
RegistrationDomainNotAllowed 403 Email domain outside OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS
AccountLocked 429 Too many failed login attempts for (account, IP) — see rate-limit config

DAV clients (WebDAV / CalDAV / CardDAV): app passwords only

DAV surfaces at /webdav/, /caldav/, and /carddav/ accept HTTP Basic Auth only against app passwords — the user's regular account password is refused on those paths. This is intentional and cannot be switched off.

Reasons:

  • Uniformity across account types. Magic-link-only accounts (email- only signup) and OIDC-linked accounts have no local password to send over Basic Auth. App passwords are the one credential shape that works for every account type.
  • Revocable and scoped. An app password can be revoked individually without touching the account password. Losing a phone or rotating a client only affects that client.
  • Bounded blast radius on phishing / leak. A leaked account password grants web login (which the SPA can gate with 2FA / step-up in future); an app password grants only the DAV surface it was minted for.

User workflow: in the OxiCloud web UI, Profile → App Passwords → Create, name it, copy the token shown once, and use username + token in the DAV client. See DAV Client Setup.

Security Model

  • Local passwords hashed with Argon2id
  • DAV surfaces (WebDAV / CalDAV / CardDAV) accept app passwords only — the account password is refused on /webdav/, /caldav/, /carddav/ by design (see above)
  • Access control is role-based (admin and user)
  • Refresh tokens support session renewal without forcing frequent re-login
  • Login endpoint uses anti-enumeration response shapes — bad-username and bad-password return the same 403
  • Magic-link send returns a uniform 200 whether the account exists or not; the truth lands in the audit log target
  • OIDC can coexist with local auth or disable password login entirely
  • OIDC-enabled deployments have magic-link login hard-disabled to prevent IdP-MFA bypass