Files
Oxicloud/docs/architecture/auth-model.md
T
Edouard Vanbelle e94063d96a test(login/register): via password or magic-link
Password login

┌─────┬────────────────────────────────────────────────────┬────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐
│  #  │                        Case                        │         Where          │                                          Assertion                                          │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L1  │ Login by username                                  │ auth_login.hurl Case 1 │ 200 + access_token, user.email match                                                        │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L2  │ Login by email (dispatch on @)                     │ auth_login.hurl Case 2 │ 200, same session shape as L1                                                               │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L3  │ Bad password on username path                      │ auth_login.hurl Case 3 │ 403 anti-enum                                                                               │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L4  │ Bad password on email path                         │ auth_login.hurl Case 4 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L5  │ Unknown username                                   │ auth_login.hurl Case 5 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L6  │ Unknown email                                      │ auth_login.hurl Case 6 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L7  │ /api/auth/oidc/providers reports methods correctly │ auth_login.hurl Case 7 │ password_login_enabled: true, magic_link_login_enabled: true, require_verified_email: false │
└─────┴────────────────────────────────────────────────────┴────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘

Password registration

┌─────┬───────────────────────────────────────────────────┬──────────────────────────────┬─────────────────────────────────────────────────────────┐
│  #  │                       Case                        │            Where             │                        Assertion                        │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R1  │ Classic username + email + password → uniform 200 │ registration.hurl Step 2     │ anti-enum message contains "request received"           │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R2  │ Login after register works                        │ registration.hurl Step 2b    │ 200 + session for the new user                          │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R3  │ Email collision → uniform 200 (no rewrite)        │ registration.hurl Steps 8-10 │ attacker password doesn't work; original account intact │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R4  │ Username collision → uniform 200                  │ registration.hurl Step 11    │ same anti-enum shape                                    │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R5  │ Off-domain rejection                              │ registration.hurl Step 12    │ 403 RegistrationDomainNotAllowed                        │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R6  │ Case-insensitive domain match                     │ registration.hurl Step 12b   │ uniform 200 on charlie@EXAMPLE.COM                      │
└─────┴───────────────────────────────────────────────────┴──────────────────────────────┴────────────────────────────┘

Magic-link registration (email-only signup)

┌─────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐
│  #  │                                               Case                                               │             Where             │                   Assertion                    │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR1 │ Email-only signup → welcome mail queued                                                          │ registration.hurl Step 3      │ uniform 200 + browser-binding cookie set       │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR2 │ Welcome mail contains magic-link URL                                                             │ registration.hurl Step 4      │ captured from mock SMTP                        │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR3 │ PR 22 cross-browser confirmation page                                                            │ registration.hurl Step 5a     │ 200 HTML "different browser"                   │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR4 │ Cookie-bound redemption lands on SPA                                                             │ registration.hurl Step 5b     │ 302 → /files (SvelteKit route, post-migration) │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR5 │ email_verified_at stamped after redemption                                                       │ registration.hurl Step 6      │ field present on /api/auth/me                  │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR6 │ Second magic-link post-signup                                                                    │ registration.hurl Step 7      │ uniform 200                                    │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR7 │ Profile PATCH — no-op, name set, empty-string rejected, username-taken 409, claim-once 409, etc. │ registration.hurl Steps 6a–6i │ full profile lifecycle                         │
└─────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘

Magic-link login (existing account)

┌─────┬──────────────────────────────────────────────────────────┬──────────────────────────────────────┬───────────────────────────────────────┐
│  #  │                           Case                           │                Where                 │                             Assertion                              │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML1 │ Baseline password login still works                      │ auth_magic_link_login.hurl Steps 1-2 │ 200                                                                │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML2 │ magic-link/send with email identifier                    │ auth_magic_link_login.hurl Step 3    │ uniform 200 + cookie                                               │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML3 │ magic-link/send with username identifier (dispatch on @) │ auth_magic_link_login.hurl Step 4    │ uniform 200                                                        │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML4 │ Password-user policy: mail actually sent                 │ auth_magic_link_login.hurl Step 5    │ SMTP capture proves permit_magic_link_for_password_users in effect │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML5 │ Redemption creates a session                             │ auth_magic_link_login.hurl Steps 6-7 │ 302 → /files, /api/auth/me returns the same user                   │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML6 │ Anti-enum on unknown identifier                          │ auth_magic_link_login.hurl Step 8    │ same uniform 200 shape as ML3                                      │
└─────┴──────────────────────────────────────────────────────────┴──────────────────────────────────────┴───────────────────────────────────────┘

OIDC

┌─────┬────────────────────────────────────────────────────────────────────────┬───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
│  #  │                                  Case                                  │       Where       │                                                        Assertion                                                        │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O1  │ Setup local admin (bootstrap)                                          │ oidc.hurl Step 1  │ 201                                                                                                                     │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O2  │ Providers endpoint — OIDC visible                                      │ oidc.hurl Step 2  │ enabled: true, provider_name: MockSSO, password_login_enabled: true, magic_link_login_enabled: false (OIDC-master rule) │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O2b │ Magic-link/send refused (endpoint layer)                               │ oidc.hurl Step 2b │ 403 MagicLinkLoginDisabled — proves the policy gate fires, not a 503 SMTP-unwired                                       │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O3  │ Authorize redirect includes PKCE + state                               │ oidc.hurl Step 3  │ 307 to fake IdP                                                                                                         │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O4  │ IdP round-trip + JIT provisioning                                      │ oidc.hurl Step 4  │ Callback lands on /login?oidc_code=…                                                                                    │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O5  │ Code exchange → session cookies                                        │ oidc.hurl Step 5  │ 200 + all three cookies                                                                                                 │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O6  │ JIT profile mapping (name, given/family, picture, groups → admin role) │ oidc.hurl Step 6  │ every claim reflected on /api/auth/me                                                                                   │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O7  │ Refresh rotation on OIDC session                                       │ oidc.hurl Step 7  │ new access/refresh/CSRF cookies                                                                                         │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O8  │ Refreshed cookies authenticate                                         │ oidc.hurl Step 8  │ 200 on /api/auth/me                                                                                                     │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O9  │ Repeat login = same local user (no dup)                                │ oidc.hurl Step 9  │ user_id stable                                                                                                          │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O10 │ Anti-takeover: unverified email → refused                              │ oidc.hurl Step 10 │ 401/403                                                                                                                 │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O11 │ One-time code replay refused                                           │ oidc.hurl Step 11 │ second /exchange → 401                                                                                                  │
└─────┴────────────────────────────────────────────────────────────────────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘

test
2026-07-14 03:16:25 +02:00

236 lines
20 KiB
Markdown

# Authentication Model
OxiCloud's authentication is built on a single principle: **email is the identity, everything else is optional**. A user account is uniquely identified by their email address. Username, password, and OIDC linkage are each independent, optional slots — none of them is required, and none of them is the source of identity. Which slots a user has determines which login paths are available to them.
This page is the canonical reference for the identity and authentication surface. For the magic-link mechanism in detail (token lifecycle, invitation flow, kill switches), see [Magic-link external authentication](/architecture/magic-link-auth). For how grants are evaluated, see [ReBAC Authorization](/architecture/rebac-authorization).
## Identity model
Every user row in `auth.users` carries one identity field, three independent credential slots, and one derived signal (`email_verified_at`).
| Slot | Type | Required | Meaning |
|---|---|---|---|
| `email` | `String UNIQUE NOT NULL` | yes | The identity. Every login path ultimately resolves here. |
| `username` | `String UNIQUE NULL` | no | Optional handle. 2-64 chars, `[A-Za-z0-9._-]+`, **no `@`**. **Claim-once, immutable** (PR 24) — a user can claim a handle once if they have none, but cannot rename or unclaim. Multiple NULLs coexist under the UNIQUE index. |
| `password_hash` | `String NULL` | no | Argon2 hash if the user chose one. NULL = no password. No sentinel strings. |
| `oidc_subject` | `String NULL` | no | IdP subject claim if the user linked an external identity. NULL = no OIDC. |
| `is_external` | `bool` | yes (default false) | Provisioning origin marker. `true` = created via email-invitation. Affects home-folder provisioning and DAV access. |
| `email_verified_at` | `Timestamp NULL` | no | PR 23 — when the user demonstrated control of their email. NULL = unverified. Stamped on first magic-link redemption OR OIDC JIT with verified claim. Idempotent: the first proof timestamp is preserved. No policy gates today; future PRs may gate features on this signal. |
The **`@` ban on usernames** is what makes the username and email namespaces provably disjoint. The login dispatcher relies on this — input containing `@` is unambiguously an email lookup, input without is a username lookup. No fallback chain, single DB hit.
Eligibility predicates derive from the slots:
```rust
fn has_password(&self) -> bool { self.password_hash.is_some() }
fn has_oidc(&self) -> bool { self.oidc_subject.is_some() }
fn has_login_credential(&self) -> bool {
self.has_password() || self.has_oidc()
}
```
## Login dispatcher
`POST /api/auth/login` accepts one identifier field that holds **either** a username or an email. The server dispatches in one branch:
```
input contains '@' → lookup by email, verify password
input does not → lookup by username, verify password
```
The `@` ban on usernames makes this unambiguous. A single DB lookup, no fallback chain, no cross-column scan.
The frontend's "Username or email" field submits whatever the user typed; the JSON field is still named `username` for backwards compatibility, with a docstring noting the dual semantics.
## Login paths
| Path | How it works | When available |
|---|---|---|
| **Username + password** | Type a handle and a password. Backend looks up by username, verifies the Argon2 hash. | User has both `username` and `password_hash` set. |
| **Email + password** | Type an email and a password. Backend looks up by email, verifies the hash. | User has `password_hash` set (username optional). |
| **Email + magic-link** | Type an email, click "Send sign-in link", receive a magic-link in the inbox, click it. | Magic-link eligibility (below). |
| **OIDC redirect** | Click "Sign in with {IdP}", redirect to IdP, return to OxiCloud authenticated. | User has `oidc_subject` set OR JIT-provisioning is enabled. |
### Magic-link eligibility
```
1. has_oidc() → reject "oidc_user" (unconditional)
2. has_password() → reject "has_password" by default
allow when OXICLOUD_AUTH_POLICIES contains
`permit_magic_link_for_password_users`
3. neither → allow
```
| User state | Magic-link eligible? |
|---|---|
| No password, no OIDC (typical external / fresh email-only signup) | Yes — always |
| Has password, no OIDC | Default no; flag flips to yes for lenient mode |
| Has OIDC (with or without password) | **No — always.** Flag has no effect. |
**OIDC is excluded unconditionally** because the IdP is the security boundary and may enforce MFA (TOTP, WebAuthn, conditional access, etc.) that a magic-link would bypass. Even when the operator wants lenient magic-link for password users, OIDC-linked accounts must stay on the IdP path.
## Device-bound magic-link redemption
PR 22 binds **login-via-email** magic-links to the originating browser via a challenge cookie. The mechanism closes the mailbox-as-bearer-token attack class on this surface — mailbox compromise alone is no longer enough to redeem a session.
**Asymmetric scope.** Binding applies only to login-via-email, not invitations:
| Flow | Initiator | Bound to browser? | TTL | Env |
|---|---|---|---|---|
| `POST /api/auth/magic-link/send` (login-via-email) | The user themselves, in a browser | **Yes** (challenge cookie) | **10 minutes** | `OXICLOUD_MAGIC_LINK_LOGIN_TTL_MINUTES` |
| `POST /api/grants subject.type=email` (invitation) | A sharer; recipient has no prior browser context | No (cross-device by design) | **24 hours** | `OXICLOUD_MAGIC_LINK_INVITE_TTL_HOURS` |
The legacy `OXICLOUD_MAGIC_LINK_TTL_HOURS` env is preserved as a deprecated alias for the invitation TTL.
**Cookie mechanism.** `POST /api/auth/magic-link/send` generates a random per-request challenge, sets it as `oxicloud_magic_request=<value>` (HttpOnly, SameSite=Strict, Path=`/magic`, Max-Age = login TTL), and mirrors it into the new `auth.magic_link_tokens.request_challenge` column. On redemption (`GET /magic/v1/{token}`):
- **Cookie matches** → redeem instantly. The user clicked the link in the same browser they requested it from.
- **Cookie absent or mismatched** → render a confirmation HTML page warning that the link was opened on a different device. The Continue button submits back to `/magic/v1/{token}?confirm=1`; the redemption proceeds with audit `magic_link.redeemed cross_browser_confirmed=true`.
- **Invitation tokens** (no `request_challenge`) bypass the check entirely. Invitations are cross-device by design — the recipient was never going to have a matching cookie.
The cookie is **set on every 200 response** from `POST /api/auth/magic-link/send`, including the silently-absorbed rate-limit and ineligibility paths. The DB row only stores the challenge when a token is actually minted, so the cookie's presence alone isn't an enumeration oracle.
## Profile editing
PR 24 adds `PATCH /api/auth/me/profile` for the user to edit their own profile. Three optional fields:
| Field | Mutability |
|---|---|
| `username` | **Claim-once, immutable**. Accepted only when the caller has no username; subsequent calls (whether claiming a different handle or the same value) return `409 UsernameImmutable`. Admin override is the only escape hatch for genuine typos. |
| `given_name` | Freely settable. Empty string is rejected — use field absence for "no change". |
| `family_name` | Same as `given_name`. |
**OIDC users are rejected wholesale with 403.** Their profile is owned by the IdP and changes there propagate on next sign-in.
**Why claim-once on username?** The DAV / NextCloud compat layer at `/remote.php/dav/files/{user}/…` and the `verify_url_user` check both bake username in as a stable identifier in URL paths. Allowing renames would silently break every configured NC client (clients build URLs from the username they were given at login, and don't re-fetch a URL template). The immutability decision sidesteps the whole problem — usernames stay stable for the lifetime of the account, NC clients keep working forever. Native OxiCloud surfaces (`/api/*`, `/webdav/*`, `/caldav/*`) don't include username in the path and would have been fine with renames; the NC surface drives the policy.
## Registration paths
| Path | Pre-condition | What happens |
|---|---|---|
| `POST /api/auth/register` with `{email, password}` | Public registration enabled | User row created with both slots; classic path. |
| `POST /api/auth/register` with `{email}` only | Public registration enabled + SMTP configured | User row created with `password_hash = NULL`; welcome magic-link mailed. |
| `POST /api/grants` with `{ subject: { type: "email", email: "..." } }` | Sharer has Share permission | Recipient lazily provisioned as external; invitation magic-link mailed. |
| OIDC JIT | First IdP-mediated login + auto-provisioning enabled | User row created with `oidc_subject` set, no password. |
Anti-enumeration applies to the public `register` endpoint — see below.
## Anti-enumeration
The endpoint responses are tuned per attacker model:
| Endpoint | Response shape | Why |
|---|---|---|
| `POST /api/auth/register` (SMTP wired) | Uniform 200 on success **and** collision: `{"message": "Registration request received."}` | Per-user oracle on `email` / `username` would let an attacker probe account existence. The "check your email" cover story is honest because successful email-only signups receive a welcome mail. |
| `POST /api/auth/register` (SMTP not wired) | `201 + UserDto` on success, `409` on collision (classic) | Without the email cover story, a uniform response is misleading UX with no security benefit. |
| `POST /api/auth/magic-link/send` | Uniform 200 regardless of outcome | The mailbox owner is the only one who'd see whether mail arrived. |
| `POST /api/auth/login` | Uniform `403 "Invalid credentials"` | Same shape for unknown user / bad password / deactivated account. |
In all four cases the real reason is recorded in the `audit` channel — operators see the truth; attackers see the same response.
**Instance-wide policy stays visible** in every flow. `OXICLOUD_ENABLE_REGISTRATION=false`, OIDC-only mode, and SMTP-not-configured for email-only signup all return clear errors (403 / 503) — these are not per-user oracles, so hiding them would just frustrate legitimate users.
## Security trade-offs
| Concern | Current treatment |
|---|---|
| **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. |
| **Magic-link as bearer token (invitations)** | Open by design. Invitations have no `request_challenge` because the recipient has no prior browser context — Alice can't pre-authorise Bob's device. The shorter TTL on login tokens (10 min) does most of the work; invitations get the longer 24 h window because recipients may not check their email immediately. |
| **Enumeration via timing** | Best-effort. `register` collision is the same code path as success (uniform response, similar latency); `magic-link/send` is bounded by per-target-email and per-IP rate limits. |
## Rate limits
Three caps protect the magic-link surface, two protect classic auth:
| Cap | Keyed on | Default | Env |
|---|---|---|---|
| Login attempts | client IP | 360/hour (test env) — production should tighten | `OXICLOUD_RATE_LIMIT_LOGIN_MAX` |
| Register attempts | client IP | 360/hour (test env) | `OXICLOUD_RATE_LIMIT_REGISTER_MAX` |
| Email-invite per sharer | `caller_id` | 50/hour | `OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR` |
| Magic-link send per target email | normalised email | 5/hour | `OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR` |
| Magic-link send per IP | client IP | 200/hour | `OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR` |
The two `magic-link/send` caps are **silently absorbed** when exceeded (uniform 200, no mail dispatched). The other caps surface 429 to the authenticated caller.
## Audit events
Every meaningful denial / suppression / outcome emits a structured event on the `audit` tracing target. Reason keys are stable — log aggregators key off them.
| Event | Reasons / fields (subset) | Where it fires |
|---|---|---|
| `auth.login` | `created` | success path, `register` service |
| `auth.login_rejected` | `unknown_user`, `bad_password`, `account_deactivated` | `login` |
| `auth.register` | `created`, `email_taken`, `username_taken` | `register` service |
| `auth.magic_link_send` | `sent`, `no_account`, `oidc_user`, `has_password`, `account_deactivated`, `malformed_email`, `rate_limited_ip`, `rate_limited_email` | `send_login_link` + handler |
| `magic_link.invitation_suppressed` | `oidc_user`, `has_password` | `issue_invitation` |
| `magic_link.cross_browser_prompt` | `incoming_present` flag, token_id, user_id | `redeem` when the challenge cookie is absent or mismatched (PR 22) |
| `magic_link.redeemed` | `cross_browser_confirmed` flag, `is_external`, resource fields | `redeem` success path |
| `magic_link.redemption_rejected` | `token_not_found`, `token_used`, `token_expired`, `account_deactivated` | `redeem` |
| `auth.profile_updated` | `fields` (list of changed names) | `update_profile_with_perms` (PR 24) |
| `auth.profile_update_rejected` | `oidc_user`, `username_immutable`, `username_taken` | `update_profile_with_perms` (PR 24) |
| `auth.app_password_create_rejected` | `external_user`, `no_username` | `create_app_password` |
| `authz.external_user_blocked` | `internal_only_surface` | `require_internal_user_layer` (CalDAV/CardDAV/WebDAV) |
| `auth.nc_basic_rejected` | `external_user` | `basic_auth_middleware` |
| `groups.search_rejected` | `external_user` | `search_groups` |
| `user_profile.rejected` | `external_no_relationship`, `target_external_hidden`, `target_hidden` | `get_user_profile` |
| `authz.denied` | resource-specific | `AuthorizationEngine::require` |
## Migration path for existing instances
The auth model lands across PR 16-24, all forward-only and non-destructive.
**PR 16 — schema cleanup.**
- `username` and `password_hash` drop their `NOT NULL` constraints; existing rows keep their values.
- Email-shaped usernames on `is_external = true` users are NULL'd (they were redundant duplicates of the email column).
- Sentinel password strings (`__EXTERNAL_NO_PASSWORD__`, `__OIDC_NO_PASSWORD__`) are replaced with `NULL`.
- A CHECK constraint bans `@` in usernames going forward. Existing usernames are pre-validated as compliant.
**PR 22 — device-bound login tokens.** Adds `auth.magic_link_tokens.request_challenge TEXT NULL`. Invitation tokens already in flight keep NULL and continue to redeem cross-device. New login tokens get the challenge and the cookie binding.
**PR 23 — email-verified signal.** Adds `auth.users.email_verified_at TIMESTAMPTZ NULL`. Backfill stamps OIDC users (`oidc_subject IS NOT NULL`) and externals who have logged in at least once (`is_external = TRUE AND last_login_at IS NOT NULL`) — for both groups, the proof-of-control event happened in the past. Everyone else stays NULL until they go through a magic-link flow.
**Continuity guarantees.** Existing internal users with `username` + `password_hash` continue to work unchanged. External users keep their session UUIDs; their JWTs reference `user_id`, not `username`, so session continuity is preserved. The address-book and share-modal use the `username → given_name family_name → email` fallback chain for display. NextCloud clients keep working because (a) the URL path uses username, which is now immutable for the lifetime of the account, and (b) app passwords are tied to `user_id` and survive every other change to the user record.
## Future direction — per-user `login_strategy`
The current model is implicit: a user's available login paths derive from which credential slots they have set. A future direction is to make this **explicit** with a per-user policy enum:
| Strategy | Login requires |
|---|---|
| `passwordless` | magic-link only (current external default) |
| `password` | password only |
| `password_or_magic_link` | either (today's lenient mode, account-scoped instead of instance-scoped) |
| `password_and_magic_link` | both — true 2FA, mailbox-as-second-factor |
| `oidc` | IdP redirect (existing) |
| `password_and_totp` | once native TOTP enrolment ships |
| `password_and_webauthn` | once native WebAuthn enrolment ships |
`password_and_magic_link` is particularly interesting: it turns the parallel single-factor paths we have today into a real MFA primitive (something you know + access to a mailbox). No new auth code required — just a policy gate.
This stays out of the current PR sequence; the data model already accommodates it (the eligibility predicate is the single migration point).
## What is deliberately out of scope
- **Native TOTP / WebAuthn enrolment.** The eligibility predicate has room for a `Reject("mfa_enrolled")` branch once native MFA lands. OIDC delegation is the only MFA path today.
- **External-user → internal-user promotion.** When an external user later sets a credential, today `is_external` stays true (they remain second-class for home folders, DAV, etc.). A future PR promotes them properly.
- **Session-kind discriminator.** A magic-link session is indistinguishable from a password session today. Scoped sessions (Option-B style: "magic-link sessions only access granted resources") are deferred.
- **Differentiated session TTL for externals.** Refresh-token expiry is uniform today. Future env: `OXICLOUD_EXTERNAL_REFRESH_TOKEN_EXPIRY_DAYS`.
- **Open Cloud Mesh (OCM) federation.** A third source for external provisioning. The `ExternalIdentityLifecycleHook::on_user_created` design accommodates the `source` discriminator (`magic_link` / `oidc` / `ocm`).
- **Email-verified policy gates.** PR 23 introduced the `email_verified_at` signal; gating features (uploads, shares, etc.) on it is future work — likely a single `OXICLOUD_REQUIRE_EMAIL_VERIFICATION=true` env var that adds middleware to the relevant routes.
- **Username rename via the API.** PR 24 makes `username` claim-once-immutable on `/api/auth/me/profile`. A future admin endpoint at `PATCH /api/admin/users/{id}` can override for typo correction; that surface is admin-policy territory, not user-self-service.
- **Anti-enumeration latency parity.** The success and collision branches of `register` already use similar code paths, but a sophisticated attacker could still time-distinguish. Deferred; rate-limiting bounds the damage.
- **Per-user opt-out of magic-link.** The `OPEN_TO_PASSWORD_USERS` flag is instance-wide today. A future per-account toggle for high-privilege users (admins, etc.) would need a column + extra eligibility branch.
- **Clearing `given_name` / `family_name`.** PR 24's profile endpoint can SET them but not CLEAR them back to NULL. A future patch with `Option<Option<String>>` serde semantics or a dedicated DELETE endpoint can add that.
- **`login_strategy` enum** (above) — the data model accommodates it but the policy code is future work.
## Related documents
- [Magic-link external authentication](/architecture/magic-link-auth) — the magic-link mechanism in depth: token lifecycle, invitation flow, kill switches, defence-in-depth boundary protections.
- [ReBAC Authorization](/architecture/rebac-authorization) — how grants are evaluated against `auth.users` rows (including externals).
- [Share Integration](/architecture/share-integration) — how share-link flow relates to the email-invite flow.
- [Environment Variables](/config/env) — the full set of `OXICLOUD_*` knobs referenced in this page.