Skip to content

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

MethodEndpointDescription
POST/api/auth/registerCreate a local user account. email is required; username and password are both optional.
POST/api/auth/loginExchange an identifier (username or email — dispatches on @) and password for access and refresh tokens
POST/api/auth/magic-link/sendSend 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/refreshRefresh the session tokens
GET/api/auth/meReturn the current authenticated user
PUT/api/auth/change-passwordChange the current user's password (requires the current password)
POST/api/auth/logoutInvalidate the current session
GET/api/auth/statusReturn auth system state, including OIDC availability

OIDC Endpoints Under Auth

MethodEndpointDescription
GET/api/auth/oidc/providersReport which self-service auth methods this deployment offers (see fields below)
GET/api/auth/oidc/authorizeBuild the authorization redirect URL
GET/api/auth/oidc/callbackHandle provider redirect callback
POST/api/auth/oidc/exchangeExchange the auth code for OxiCloud session tokens

GET /api/auth/oidc/providers fields:

FieldMeaning
enabledOIDC is configured on this deployment
provider_nameDisplay name for the IdP (shown on the SSO button)
authorize_endpointWhere the SPA should start the OIDC round-trip
password_login_enabledPOST /api/auth/login will accept credentials
magic_link_login_enabledPOST /api/auth/magic-link/send will mint tokens (SMTP wired + allowlist + no OIDC — see rules below)
require_verified_emailOXICLOUD_REQUIRE_VERIFIED_EMAIL is set — the SPA uses this hint to explain EmailNotVerified responses

Configuring which methods are offered

Two environment variables control the self-service surface (OIDC is orthogonal — see OXICLOUD_OIDC_ENABLED).

OXICLOUD_AUTH_METHODS

Comma-separated allowlist of password and/or magic_link. Default password,magic_link.

ConfigurationEffect
Unset or password,magic_linkBoth methods allowed (default)
passwordPassword login OK. Magic-link send / redeem → 403 MagicLinkLoginDisabled
magic_linkPassword login → 403 PasswordLoginDisabled. Password-based register → 403 PasswordRegistrationDisabled. Email-only signup still works

Startup gate. If magic_link is the only method allowed 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 OXICLOUD_OIDC_ENABLED=true, 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.

CombinationResult
email + passwordClassic signup — account gets a password hash; user can log in immediately
email + password + usernameSame, plus the username is claimed at creation
email onlyEmail-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...).

TokenEffect
permit_magic_link_for_password_usersAllow magic-link login for accounts that also have a password. OIDC-linked users are still refused.

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

Example Flows

Register — classic

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

Register — email-only

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

Login

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

Or equivalently:

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

Typical successful login response:

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

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

json
{ "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_typeHTTPMeaning
PasswordLoginDisabled403OXICLOUD_AUTH_METHODS doesn't include password
PasswordRegistrationDisabled403Same, on register with a password field
MagicLinkLoginDisabled403OXICLOUD_AUTH_METHODS doesn't include magic_link, OIDC is enabled, or email-only signup is attempted on a password-only deployment
EmailNotVerified403Password validated, but email_verified_at IS NULL and OXICLOUD_REQUIRE_VERIFIED_EMAIL=true. Server has already sent a verification link
RegistrationDisabled403Global registration off
RegistrationDomainNotAllowed403Email domain outside OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS
AccountLocked429Too 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

Released under the MIT License.