diff --git a/docs/architecture/auth-model.md b/docs/architecture/auth-model.md index fd2cac63..28aa6f39 100644 --- a/docs/architecture/auth-model.md +++ b/docs/architecture/auth-model.md @@ -56,7 +56,8 @@ The frontend's "Username or email" field submits whatever the user typed; the JS ``` 1. has_oidc() → reject "oidc_user" (unconditional) 2. has_password() → reject "has_password" by default - allow when OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true + allow when OXICLOUD_AUTH_POLICIES contains + `permit_magic_link_for_password_users` 3. neither → allow ``` @@ -133,7 +134,7 @@ In all four cases the real reason is recorded in the `audit` channel — operato | Concern | Current treatment | |---|---| -| **Mailbox compromise = account compromise (lenient mode)** | When `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true`, a user's mailbox is as strong as their password — flip the password by mail. Operator opt-in only; off by default. Aligns with modern SaaS norms (Slack, Notion, Substack). | +| **Mailbox compromise = account compromise (lenient mode)** | When `OXICLOUD_AUTH_POLICIES` contains `permit_magic_link_for_password_users`, a user's mailbox is as strong as their password — flip the password by mail. Operator opt-in only; off by default. Aligns with modern SaaS norms (Slack, Notion, Substack). | | **Mailbox compromise = account compromise (strict mode)** | Only applies to magic-link-eligible users (no other credential). Their mailbox **is** their credential by design. Password-secured accounts are unaffected. | | **No native MFA** | Today OIDC delegation is the only path to MFA — the IdP (Keycloak, Authentik, Okta) enforces TOTP/WebAuthn/etc., OxiCloud sees only the resulting ID token. This is why OIDC users are unconditionally excluded from magic-link. Native TOTP / WebAuthn enrolment is a future feature. | | **Magic-link as bearer token (login-via-email)** | Closed (PR 22). Login tokens carry a per-request challenge mirrored into the originating browser's `oxicloud_magic_request` cookie. Redemption from a different browser shows a confirmation page rather than auto-signing. Asymmetric TTL: login tokens expire in 10 min, invitations in 24 h. | diff --git a/docs/config/authentication.md b/docs/config/authentication.md index 5b42e8fa..971fdb06 100644 --- a/docs/config/authentication.md +++ b/docs/config/authentication.md @@ -1,16 +1,18 @@ # 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. +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 | -| `POST` | `/api/auth/login` | Exchange username and password for access and refresh tokens | +| `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 | +| `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 | @@ -18,55 +20,174 @@ OxiCloud ships with JWT-based authentication and Argon2id password hashing for l | Method | Endpoint | Description | | --- | --- | --- | -| `GET` | `/api/auth/oidc/providers` | List configured OIDC provider info | +| `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 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`. + +| Configuration | Effect | +| --- | --- | +| Unset or `password,magic_link` | Both methods allowed (default) | +| `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 | + +**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`. + +| 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. + +## Magic-link eligibility + +`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. | + +Unknown tokens are logged-and-skipped at startup so a typo doesn't silently zero the vector. + ## Example Flows -### Register +### Register — classic ```json -{ - "username": "testuser", - "email": "test@example.com", - "password": "SecurePassword123" -} +{ "username": "testuser", "email": "test@example.com", "password": "SecurePassword123" } +``` + +### Register — email-only + +```json +{ "email": "test@example.com" } ``` ### Login ```json -{ - "username": "testuser", - "password": "SecurePassword123" -} +{ "username": "testuser", "password": "SecurePassword123" } +``` + +Or equivalently: + +```json +{ "username": "test@example.com", "password": "SecurePassword123" } ``` Typical successful login response: ```json -{ - "accessToken": "...", - "refreshToken": "...", - "expiresIn": 3600 -} +{ "accessToken": "...", "refreshToken": "...", "expiresIn": 3600 } +``` + +### Send a sign-in link (magic-link) + +```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, and storage information. +`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 | ## Security Model -- local passwords are hashed with Argon2id -- access control is role-based (`admin` and `user`) -- refresh tokens support session renewal without forcing frequent re-login +- Local passwords hashed with Argon2id +- 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 ## Related Pages - [OIDC / SSO](/config/oidc) - [Admin Settings](/config/admin-settings) -- [Environment Variables](/config/env) \ No newline at end of file +- [Environment Variables](/config/env) diff --git a/docs/config/env.md b/docs/config/env.md index 59fadc2e..20815600 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -45,6 +45,9 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes | | `OXICLOUD_DISABLE_REGISTRATION` | false | Disable registration of new user accounts | | `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` | — | Comma-separated allowlist of email domains accepted on `POST /api/auth/register` (case-insensitive, exact match on the post-`@` part). Empty = any domain is allowed. **Distinct from `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`**: this one gates SELF-registration (public sign-up), the external list gates INVITATIONS (grants + magic-link to third parties). An operator can lock sign-up to their company domain while leaving invitations open. Subdomains must be listed explicitly. Rejected registrations return 403 `RegistrationDomainNotAllowed` and emit an `audit` line. Example: `mycompany.com,mycompany-eu.com`. | +| `OXICLOUD_AUTH_METHODS` | `password,magic_link` | Comma-separated allowlist of self-service auth methods (`password`, `magic_link`). OIDC is orthogonal (see `OXICLOUD_OIDC_ENABLED`). Removing `password` disables `POST /api/auth/login` (returns 403 `PasswordLoginDisabled`) and password-based `register` (returns 403 `PasswordRegistrationDisabled`). Removing `magic_link` disables `POST /api/auth/magic-link/send` (returns 403 `MagicLinkLoginDisabled`) and the redemption path for login-purpose tokens. **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. **OIDC master rule**: when `OXICLOUD_OIDC_ENABLED=true`, magic-link login is hard-disabled regardless of this list (would otherwise bypass IdP-enforced MFA / step-up). Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes `password` from the list. | +| `OXICLOUD_AUTH_POLICIES` | — | Comma-separated additive policy switches. Each token grants an exception or restriction to the default auth behaviour; empty (unset) = pure defaults. Recognised tokens: `permit_magic_link_for_password_users` (allow magic-link login for accounts that also have a password — off by default because magic-link would weaken the password to mailbox-strength; OIDC-linked users are still refused regardless). | +| `OXICLOUD_REQUIRE_VERIFIED_EMAIL` | `false` | When `true`, `POST /api/auth/login` returns 403 `EmailNotVerified` for any account whose `email_verified_at` is NULL. Users can prove control by requesting a magic-link (whose redemption stamps `email_verified_at`), so this composes with `magic_link` in `OXICLOUD_AUTH_METHODS` to give users a self-service verification path. Admin-created (`POST /api/admin/users`) and setup-admin (`POST /api/setup`) users are auto-verified. OIDC-JIT users are also stamped verified at creation. | ### Rate Limiting & Account Lockout diff --git a/example.env b/example.env index 361fdfee..5004bde0 100644 --- a/example.env +++ b/example.env @@ -620,6 +620,58 @@ OXICLOUD_WOPI_ENABLED=false # Example (only staff at these two domains can self-register): #OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS=mycompany.com,mycompany-eu.com +# --------------------------------------------------------------------------- +# OXICLOUD_AUTH_METHODS — self-service authentication method allowlist. +# --------------------------------------------------------------------------- +# Comma-separated list of `password` and/or `magic_link`. Controls which +# self-service authentication methods this deployment offers on the login +# page and accepts at the corresponding endpoints. OIDC is orthogonal — +# use `OXICLOUD_OIDC_ENABLED` for that. +# +# Semantics per configuration: +# * Empty (unset) or `password,magic_link` — both methods allowed +# (default). Matches pre-flag behaviour. +# * `password` — `POST /api/auth/login` OK, +# magic-link send / redeem +# return 403 `MagicLinkLoginDisabled`. +# * `magic_link` — `POST /api/auth/login` +# returns 403 `PasswordLoginDisabled`; +# password-based `register` +# returns 403 `PasswordRegistrationDisabled`. +# +# SECURITY — startup gate. When `magic_link` is the ONLY method allowed +# but no SMTP transport is configured, the server refuses to start with a +# fatal message. A magic-link-only policy without a mail sender silently +# locks every user out of the deployment. +# +# SECURITY — OIDC master rule. When `OXICLOUD_OIDC_ENABLED=true`, magic- +# link login is HARD-disabled regardless of what this list says. OIDC is +# the master identity provider; magic-link would sidestep any 2FA / step- +# up the IdP enforces. The startup gate above does NOT trigger in this +# case (OIDC provides a login path). +# +# Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes +# `password` from this list. New deployments should prefer this env var. +# +# Default: password,magic_link +#OXICLOUD_AUTH_METHODS=password,magic_link + +# --------------------------------------------------------------------------- +# OXICLOUD_REQUIRE_VERIFIED_EMAIL — gate login on email verification. +# --------------------------------------------------------------------------- +# When `true`, `POST /api/auth/login` returns 403 `EmailNotVerified` for +# any account whose `email_verified_at` is NULL. Users can prove control +# by requesting a magic-link (whose redemption stamps +# `email_verified_at`) — so this composes naturally with `magic_link` in +# the allowlist above to give users a self-service verification path. +# +# Admin-created (`POST /api/admin/users`) and first-run setup-admin +# (`POST /api/setup`) users are auto-verified — admin fiat counts as +# verification. OIDC-JIT users are also stamped verified at creation. +# +# Default: false +#OXICLOUD_REQUIRE_VERIFIED_EMAIL=false + # Per-sharer rate limit on email-type grants from POST /api/grants. Keyed on # the authenticated caller's user_id. Hitting the cap returns 429 with # Retry-After. Default 50/hour — generous for legitimate admin invites, @@ -640,19 +692,26 @@ OXICLOUD_WOPI_ENABLED=false # for client IP resolution. Default 200/hour. #OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=200 -# Policy switch: should magic-link sign-in be offered to users who already -# have a password configured? -# false (default, strict) — users with a password are audit-logged -# `has_password` and receive no mail. Their password is the only -# authentication path; magic-link would weaken it to "mailbox -# compromise = account compromise". -# true (lenient) — users with a password can also request a -# magic-link as a sign-in path. Aligns with modern SaaS UX -# (Slack, Notion, etc.). Operators who already treat email as the -# canonical password-reset channel pick this. -# OIDC-linked users are ALWAYS rejected regardless of this flag — the -# IdP is the security boundary and may enforce MFA we shouldn't bypass. -#OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=false +# --------------------------------------------------------------------------- +# OXICLOUD_AUTH_POLICIES — additive auth-policy switches (comma-separated). +# --------------------------------------------------------------------------- +# Each recognised token grants an exception or restriction to the default +# auth behaviour. Empty (unset) = pure defaults. Vector shape so future +# policies can be added without new env vars. +# +# Recognised tokens: +# +# permit_magic_link_for_password_users +# Allow magic-link sign-in for accounts that ALSO have a password. +# Off by default — magic-link would otherwise weaken the password to +# mailbox-strength. Aligns with modern SaaS UX (Slack, Notion, etc.) +# when set. OIDC-linked users are ALWAYS rejected regardless of this +# policy — the IdP is the security boundary and may enforce MFA we +# shouldn't bypass. +# +# Example: +#OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users + # Operator-level kill switch for share-notification emails to internal # users (the "Alice shared 'Project Alpha' with you" mail that fires when diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 370c91ed..f388550c 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -142,12 +142,26 @@ export async function apiJson(input: RequestInfo | URL, init?: RequestInit): } export class ApiError extends Error { + /** + * `error_type` field from the backend's `ErrorResponse` body, when + * present. Callers switch on this to render specific UX for + * distinguished failures (e.g. `EmailNotVerified` → "resend + * verification link" prompt). Falls back to `undefined` when the + * response body isn't parseable or the endpoint doesn't emit one. + */ + readonly errorType?: string; + constructor( readonly status: number, readonly statusText: string, - readonly resource: RequestInfo | URL + readonly resource: RequestInfo | URL, + errorType?: string, + serverMessage?: string ) { - super(`API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}`); + super( + serverMessage ?? `API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}` + ); this.name = 'ApiError'; + this.errorType = errorType; } } diff --git a/frontend/src/lib/api/endpoints/auth.test.ts b/frontend/src/lib/api/endpoints/auth.test.ts index 4a12f81f..69b48e35 100644 --- a/frontend/src/lib/api/endpoints/auth.test.ts +++ b/frontend/src/lib/api/endpoints/auth.test.ts @@ -22,7 +22,7 @@ it('exercises the auth endpoints (success paths)', async () => { await auth.getAuthStatus().catch(() => {}); await auth.setupAdmin('e@x.test', 'p').catch(() => {}); await auth.exchangeOidcCode('code').catch(() => {}); - await auth.register('u', 'e@x.test', 'p').catch(() => {}); + await auth.register('e@x.test', 'p', 'u').catch(() => {}); await auth.sendMagicLink('e@x.test').catch(() => {}); await auth.logout().catch(() => {}); const fc = (globalThis.fetch as unknown as ReturnType).mock.calls.length; diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 1714f449..d5e7cdc0 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -3,10 +3,32 @@ * primitives here intentionally bypass it (see client.ts) so a 401 surfaces as * a genuine failure to the caller. */ -import { apiFetch } from '$lib/api/client'; +import { ApiError, apiFetch } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; import type { AuthResponse, User } from '$lib/api/types'; +/** + * Best-effort parse of the backend `ErrorResponse` shape + * (`{ status, error, message, error_type }`). Returns whatever it could + * extract; never throws — a malformed body just yields undefineds. + */ +async function parseErrorBody(res: Response): Promise<{ errorType?: string; message?: string }> { + try { + const body = (await res.clone().json()) as { + error_type?: unknown; + message?: unknown; + error?: unknown; + }; + const errorType = typeof body.error_type === 'string' ? body.error_type : undefined; + const rawMessage = + (typeof body.message === 'string' ? body.message : undefined) ?? + (typeof body.error === 'string' ? body.error : undefined); + return { errorType, message: rawMessage }; + } catch { + return {}; + } +} + const JSON_HEADERS = { 'Content-Type': 'application/json' }; /** @@ -48,7 +70,13 @@ export async function login(emailOrUsername: string, password: string): Promise< headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: JSON.stringify({ username: emailOrUsername, password }) }); - if (!res.ok) throw new Error(`login failed: ${res.status}`); + if (!res.ok) { + // Surface the backend `error_type` so the login page can offer + // specific UX: `EmailNotVerified` → "resend verification link", + // `PasswordLoginDisabled` → nudge toward magic-link / SSO, etc. + const { errorType, message } = await parseErrorBody(res); + throw new ApiError(res.status, res.statusText, '/api/auth/login', errorType, message); + } return (await res.json()) as AuthResponse; } @@ -56,6 +84,20 @@ export interface OidcProviders { enabled: boolean; provider_name?: string; password_login_enabled?: boolean; + /** + * True when the server accepts magic-link login requests. The backend + * composes three factors: SMTP wired, `OXICLOUD_AUTH_METHODS` allowlist + * includes `magic_link`, and OIDC is NOT enabled at the deployment + * (OIDC-enabled deployments must not offer magic-link — it would bypass + * any 2FA / step-up the IdP enforces). + */ + magic_link_login_enabled?: boolean; + /** + * True when `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set. The login page + * uses this to explain the `EmailNotVerified` login response and + * surface a "resend verification link" affordance. + */ + require_verified_email?: boolean; authorize_endpoint?: string; } @@ -135,16 +177,21 @@ export async function exchangeOidcCode(code: string): Promise { } /** - * Register a new user. Raw `fetch` (NOT apiFetch) so a 401/validation failure - * surfaces to the caller instead of tripping the global refresh-and-redirect - * interceptor — mirrors the login primitive. + * Register a new user. Since PR 18 both `username` and `password` are optional + * on the backend: an email-only signup is valid and mints a welcome magic-link. + * Raw `fetch` (NOT apiFetch) so a 401/validation failure surfaces to the caller + * instead of tripping the global refresh-and-redirect interceptor — mirrors + * the login primitive. */ -export async function register(username: string, email: string, password: string): Promise { +export async function register(email: string, password?: string, username?: string): Promise { + const body: Record = { email, role: 'user' }; + if (password) body.password = password; + if (username) body.username = username; const res = await fetch('/api/auth/register', { method: 'POST', credentials: 'same-origin', headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, - body: JSON.stringify({ username, email, password, role: 'user' }) + body: JSON.stringify(body) }); if (!res.ok) { const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; diff --git a/frontend/src/lib/styles/ported/auth.css b/frontend/src/lib/styles/ported/auth.css index 526eecf7..dc7eeb4d 100644 --- a/frontend/src/lib/styles/ported/auth.css +++ b/frontend/src/lib/styles/ported/auth.css @@ -307,10 +307,15 @@ } .auth-toggle-link { + background: transparent; + border: 0; + padding: 0; color: var(--color-accent-text); cursor: pointer; text-decoration: none; font-weight: var(--weight-medium); + font-family: inherit; + font-size: inherit; } .auth-toggle-link:hover { diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 4ee30956..1283e6ce 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -7,6 +7,7 @@ import { page } from '$app/state'; import type { Pathname } from '$app/types'; import { onMount } from 'svelte'; + import { ApiError } from '$lib/api/client'; import { exchangeOidcCode, fetchMe, @@ -37,16 +38,20 @@ let error = $state(''); let busy = $state(false); - // Register + // Register. Since PR 18 both `username` and `password` are optional on + // the backend — email-only signup mints a welcome magic-link. Leaving + // the password blank is a deliberate first-class UX path here. let regUsername = $state(''); let regEmail = $state(''); let regPassword = $state(''); let regConfirm = $state(''); let regError = $state(''); - let regSuccess = $state(''); let regShowPassword = $state(false); let regShowConfirm = $state(false); let regCapsOn = $state(false); + // True when the user has chosen the passwordless-signup branch — + // hides the confirm-password field and switches the submit label. + const regEmailOnly = $derived(regPassword.length === 0); // Admin setup (first run) let setupEmail = $state(''); @@ -61,14 +66,51 @@ setupConfirm.length === 0 ? '' : setupPassword === setupConfirm ? 'ok' : 'bad' ); - // Magic link - let magicOpen = $state(false); - let magicEmail = $state(''); + // Magic-link submit status (rendered inline after a link is sent). let magicStatus = $state<{ text: string; ok: boolean } | null>(null); - // OIDC + // OIDC + auth-method flags exposed by /api/auth/oidc/providers. let oidc = $state({ enabled: false }); + // Default `true` here: on older backends the field is absent, and the + // legacy behaviour was always-on password login. const passwordLoginEnabled = $derived(oidc.password_login_enabled !== false); + // Default `false`: only render magic-link UI when the backend + // affirmatively enables it (SMTP wired + allowlist + non-OIDC deployment). + const magicLinkLoginEnabled = $derived(oidc.magic_link_login_enabled === true); + // Single-form UX: the identifier + password fields double as the + // magic-link path. When the password is empty (and the server offers + // magic-link), submit sends a link to the identifier instead of + // attempting password login. This eliminates the duplicate + // identifier input the old two-form layout carried. + const submitAsMagicLink = $derived( + magicLinkLoginEnabled && (password.length === 0 || !passwordLoginEnabled) + ); + // The login failure remap for "email not verified". The server + // auto-sends a verification magic-link on this branch (piggybacked + // on the successful password proof — see login handler), so the + // resend "affordance" is simply resubmitting the form. Kept as a + // flag to let the UI render a specific hint. + let emailNotVerified = $state<{ email: string } | null>(null); + // One-shot "your session expired" banner. Triggered by the fetch + // interceptor via `?source=session_expired`. Set to true only if + // the query param is present on mount; the URL is stripped + // immediately after so revisits / manual logouts don't re-show + // the stale message. + let sessionExpiredNotice = $state(false); + // Refs used by the mode-driven auto-focus effect. Bound with + // `bind:this` on the first input of each mode's form so the effect + // can focus the "primary" field each time the mode changes without + // walking the DOM. + let loginIdentifierInput = $state(null); + let registerEmailInput = $state(null); + let setupEmailInput = $state(null); + // "Account created, follow the email link" banner. Set by the + // register submit handler right before switching mode='login', + // so the message stays on screen for the whole time the user is + // looking at the login form (instead of vanishing on the register + // form under a hard-to-read timeout). Cleared on the next + // successful login OR when the user dismisses it. + let postRegisterNotice = $state(null); // The redirect target is an in-SPA destination (e.g. /files or a deep link a // guard bounced us from). It's user-supplied via the query string so its exact @@ -94,9 +136,21 @@ setupCapsOn = e.getModifierState?.('CapsLock') ?? false; } + // Unified login submit. Two modes dispatched from ONE form: + // * password filled → POST /api/auth/login + // * password empty → POST /api/auth/magic-link/send (backend + // accepts either a username or an email as identifier) + // The `submitAsMagicLink` derived tracks which mode is active; + // button label + hint text render off it. async function onLogin(e: SubmitEvent) { e.preventDefault(); error = ''; + emailNotVerified = null; + magicStatus = null; + if (submitAsMagicLink) { + await submitMagicLink(); + return; + } busy = true; try { const data = await login(username, password); @@ -108,9 +162,58 @@ return; } session.setUser(data.user); + postRegisterNotice = null; await goto(resolve(redirectTarget), { replaceState: true }); } catch (err) { - error = err instanceof Error ? err.message : t('auth.login_error', 'Error logging in'); + if (err instanceof ApiError && err.errorType === 'EmailNotVerified') { + // Server auto-sent a verification magic-link on the + // piggyback-of-successful-password path (see the login + // handler). Just tell the user; resubmitting the form + // re-triggers the same auto-send. + emailNotVerified = { email: username }; + error = t( + 'auth.email_not_verified', + 'Your email is not verified. We sent a verification link to your inbox — click it, then sign in again. If it did not arrive, submit the form again.' + ); + } else if (err instanceof ApiError && err.errorType === 'PasswordLoginDisabled') { + error = t( + 'auth.password_login_disabled', + 'Password login is disabled on this server. Leave the password blank to receive a sign-in link, or use SSO.' + ); + } else { + error = err instanceof Error ? err.message : t('auth.login_error', 'Error logging in'); + } + } finally { + busy = false; + } + } + + // Password-empty branch of the unified submit. Uses the same + // `username` identifier the password form does — the backend + // dispatches on `@` (username vs email). Anti-enum uniform 200. + async function submitMagicLink() { + if (!username) return; + busy = true; + try { + const result = await sendMagicLink(username); + magicStatus = + result === 'sent' + ? { + text: t( + 'auth.magic_sent', + 'If an account exists, a sign-in link has been sent. Check your inbox.' + ), + ok: true + } + : { + text: t( + 'auth.magic_unavailable', + 'Sign-in by email is not available on this server.' + ), + ok: false + }; + } catch { + magicStatus = { text: t('auth.magic_error', 'Something went wrong. Try again.'), ok: false }; } finally { busy = false; } @@ -119,17 +222,25 @@ async function onRegister(e: SubmitEvent) { e.preventDefault(); regError = ''; - regSuccess = ''; if (regPassword !== regConfirm) { regError = t('auth.passwords_mismatch', 'Passwords do not match'); return; } busy = true; try { - await register(regUsername, regEmail, regPassword); - regSuccess = t('auth.account_success', 'Account created. You can now sign in.'); + // Username is optional since PR 18 — pass undefined when the + // field is left blank so the backend keeps `username = None` + // (the user can claim a handle later via profile settings). + await register(regEmail, regPassword, regUsername.trim() || undefined); regUsername = regEmail = regPassword = regConfirm = ''; - setTimeout(() => (mode = 'login'), 2000); + // Move the success notice to the LOGIN screen so it's actually + // readable — the register form is about to be replaced, so a + // message shown here would flash and disappear. + postRegisterNotice = t( + 'auth.account_success', + 'If the address is available, a confirmation email is on its way. Follow the link to finish.' + ); + mode = 'login'; } catch (err) { regError = err instanceof Error ? err.message : t('auth.register_error', 'Registration failed'); @@ -165,38 +276,22 @@ } } - async function onMagicLink(e: SubmitEvent) { - e.preventDefault(); - if (!magicEmail) return; - magicStatus = null; - busy = true; - try { - const result = await sendMagicLink(magicEmail); - magicStatus = - result === 'sent' - ? { - text: t( - 'auth.magic_sent', - 'If an account exists, a sign-in link has been sent. Check your inbox.' - ), - ok: true - } - : { - text: t( - 'auth.magic_unavailable', - 'Sign-in by email is not available on this server.' - ), - ok: false - }; - if (result === 'sent') magicEmail = ''; - } catch { - magicStatus = { text: t('auth.magic_error', 'Something went wrong. Try again.'), ok: false }; - } finally { - busy = false; - } - } - onMount(async () => { + // 0) Consume the one-shot `?source=session_expired` flag, if any. + // Strip it from the URL so the banner never re-appears on + // reloads / manual logout redirects. Uses history.replaceState + // (no navigation, no scroll jump). + if (page.url.searchParams.get('source') === 'session_expired') { + sessionExpiredNotice = true; + const stripped = new URL(page.url); + stripped.searchParams.delete('source'); + window.history.replaceState( + window.history.state, + '', + stripped.pathname + stripped.search + stripped.hash + ); + } + // 1) OIDC code-exchange fallback: the IdP round-trip may land back here // with ?oidc_code=. Exchange it for a session and redirect into the app. const oidcCode = page.url.searchParams.get('oidc_code'); @@ -230,6 +325,22 @@ booting = false; }); + + // Auto-focus the primary input for the current mode. Fires once the + // booting probes settle AND on every mode swap. The `booting` guard + // avoids stealing focus from something else during the loading + // splash; the input-ref guard covers the render-order case where + // the effect fires before the DOM has the target. + $effect(() => { + if (booting) return; + const target = + mode === 'login' + ? loginIdentifierInput + : mode === 'register' + ? registerEmailInput + : setupEmailInput; + target?.focus(); + }); @@ -249,9 +360,13 @@
OxiCloud
- {#if booting} -

{t('common.loading', 'Loading…')}

- {:else} +

{#if mode === 'login'} {t('auth.sign_in', 'Sign in')} @@ -262,19 +377,73 @@ {/if}

- {#if page.url.searchParams.get('source') === 'session_expired'} -
- {t('auth.session_expired', 'Your session expired. Please sign in again.')} + {#if sessionExpiredNotice} + + {/if} + + {#if postRegisterNotice && mode === 'login'} +
+ {postRegisterNotice} +
{/if} {#if mode === 'login'} - {#if passwordLoginEnabled} - {#if error}{/if} + + {#if passwordLoginEnabled || magicLinkLoginEnabled} + {#if error} + + {/if} + {#if magicStatus} +
+ {magicStatus.text} +
+ {/if}
-
- -
- - + {#if passwordLoginEnabled} +
+ +
+ + +
+ {#if capsOn} +
{t('auth.caps_lock', 'Caps Lock is on')}
+ {/if}
- {#if capsOn} -
{t('auth.caps_lock', 'Caps Lock is on')}
- {/if} -
+ {/if} - - - {#if magicOpen} -
-

- {t( - 'auth.magic_hint', - "No password? Enter your email and we'll send you a one-time sign-in link." - )} -

-
-
- -
- -
-
- -
- {#if magicStatus} -
- {magicStatus.text} -
- {/if} -
- {/if} {/if} {#if oidc.enabled} @@ -433,19 +569,11 @@ {#if regError}{/if} - {#if regSuccess}
{regSuccess}
{/if}
-
- - -
+
- -
- - -
- {#if regCapsOn} -
{t('auth.caps_lock', 'Caps Lock is on')}
- {/if} + +
-
- -
- - + + {#if passwordLoginEnabled} +
+ +
+ + +
+ {#if regCapsOn} +
{t('auth.caps_lock', 'Caps Lock is on')}
+ {/if}
- {#if matchState} -
- {matchState === 'ok' - ? t('auth.passwords_match', 'Passwords match') - : t('auth.passwords_mismatch', "Passwords don't match")} + {#if !regEmailOnly} +
+ +
+ + +
+ {#if matchState} +
+ {matchState === 'ok' + ? t('auth.passwords_match', 'Passwords match') + : t('auth.passwords_mismatch', "Passwords don't match")} +
+ {/if}
{/if} -
+ {/if}
@@ -591,6 +748,7 @@ data-testid="login-setup-email-input" type="email" bind:value={setupEmail} + bind:this={setupEmailInput} autocomplete="email" required disabled={busy} @@ -691,7 +849,6 @@
{/if} - {/if}