1. Identity model — email is the identity, three optional credential slots (username, password_hash, oidc_subject), the @-in-username ban that makes namespaces provably disjoint. 2. Login dispatcher — @-in-input decides the path. Single DB lookup. 3. Login paths — four ways in (username+password, email+password, email+magic-link, OIDC redirect) with their pre-conditions. 4. Magic-link eligibility ladder — the three-branch table with OIDC unconditional reject, password flag-gated, no-credential allowed. 5. Registration paths — four ways (/api/auth/register with full creds, email-only, grant-by-email invitation, OIDC JIT). 6. Anti-enumeration — per-endpoint table showing what each surface returns and why; explicit note that instance-wide policy stays visible. 7. Security trade-offs — mailbox-as-bypass (lenient vs strict), no native MFA, magic-link as bearer token, enumeration via timing. 8. Rate limits — the five caps from PR 12 + classic auth, with env knobs. 9. Audit events — table of 12 event names with reason keys; every entry verified grep-able in src/. 10. Migration path — what existing instances see when PR 16-20 land (forward-only, non-destructive). 11. Future direction — login_strategy — the seven-row matrix sketch as the explicit-policy north star. 12. Out of scope — TOTP/WebAuthn, external→internal promotion, session_kind, differentiated session TTL, OCM, email-verified gates (PR 23), device-bound tokens (PR 22), anti-enum timing parity, per-user opt-out, login_strategy implementation. 13. Related documents — cross-refs to magic-link-auth, ReBAC, share-integration, env-vars.
21 KiB
Magic-Link External Authentication
OxiCloud supports sharing resources with people who do not yet have an account on the instance, via per-email invitations and per-email sign-in links. Recipients are provisioned lazily as external users and authenticate exclusively through one-time URLs delivered by email, until they later set a password or link an OIDC identity.
This page is the architectural overview of the magic-link mechanism specifically. For the overall identity / login / registration model — what credential slots a user has, which login paths are available, anti-enumeration behaviour — see the canonical Authentication model page. For configuration knobs, see Environment Variables. For how grants are evaluated, see ReBAC Authorization.
Why this exists
Two scenarios are not covered by username/password or OIDC:
- Sharing with someone who is not yet an OxiCloud user. The sharer should be able to type an email address into the share modal and let the server handle the rest.
- A pre-existing external user has lost their bookmark. They never had a password — the only way to get back in is a fresh magic link to the address on file.
Both are handled by the same magic-link primitive: a single-use, time-limited token issued out of band (by email) that exchanges for a session on redemption.
Two flows
Invitation flow Login-via-email flow
─────────────── ────────────────────
Alice fills share modal Bob hits /login, types his email
│ │
POST /api/grants POST /api/auth/magic-link/send
{ subject.type: "email" } { email: "bob@example.com" }
│ │
resolve_or_create_recipient find user by email
├─ found → reuse (no creation here)
└─ new → User::new_external │
│ │
mint token (resource_type/id set) mint token (NULL resource)
│ │
queue invitation email queue sign-in email
│ │
return GrantDto (201) return uniform 200
│ │
└──────────────┬──────────────────────────┘
│
recipient clicks /magic/v1/{token}
│
validate, mark used, issue cookies
│
┌──────────────┴──────────────────────────┐
↓ ↓
Redirect to /#/files/folder/{id} Redirect to /#/sharedwithme
(resource target) (no resource target)
The same redemption endpoint serves both — the only difference is the landing redirect, which is decided by whether magic_link_tokens.resource_id IS NULL.
Identity model for external users
A user is magic-link-eligible if and only if they have no other authentication method configured. The single source of truth is User::has_login_credential():
| State | password_hash |
oidc_subject |
Eligible? |
|---|---|---|---|
| External, freshly invited | __EXTERNAL_NO_PASSWORD__ |
NULL | yes |
| External who set a password | real Argon2 hash | NULL | no |
| External who linked OIDC | __OIDC_NO_PASSWORD__ |
set | no |
| Internal, password | real Argon2 hash | NULL | no |
| Internal, OIDC-only | __OIDC_NO_PASSWORD__ |
set | no |
The placeholder strings (__EXTERNAL_NO_PASSWORD__, __OIDC_NO_PASSWORD__) are an acknowledged smell. A future refactor introduces an auth.user_auth_methods side-table with one row per (user_id, method_type); the migration touches the body of has_login_credential() only.
The eligibility rule rules out one specific bypass: an internal user with a password cannot be signed in via a magic link sent to their mailbox. Mailbox ownership is not a substitute for the password — that distinction matters when mailboxes are easier to compromise than passwords (mail-forwarding rules, shared aliases, etc.).
Username and display
- External users get
username = normalised_email. Login forms accept username OR email; lookup triesusernamefirst, falls back toemail. - The
auth.users.usernamecolumn was widened from 32 to 254 chars (RFC 5321 maximum) when this work landed. auth.users.given_nameandauth.users.family_nameareTEXT NULL— populated from OIDC claims at JIT provisioning; external users get NULL initially and can fill them in later.- Home folder name (
"My Folder - alice") is not renamed when username changes — it was display text at creation; the folder is semantically owned byuser_id.
Email normalisation
Every email crossing the boundary into the DB or a rate-limit key goes through domain::services::email_normalize::normalize_email:
- Trim whitespace.
- Split on the last
@. - Lowercase the local part.
- Punycode-encode the domain via
idna::domain_to_ascii.
So Alice@Example.COM, alice@example.com , and alice@münchen.de all map to a stable ASCII form before storage or comparison. Gmail's +tag and . insensitivities are deliberately not special-cased — addresses are treated as opaque strings post-normalisation.
Token lifecycle
┌─────────┐
(insert) ─────► │ pending │ ─── redeem ──► ┌──────┐
│ │ │ used │
└────┬────┘ └──────┘
│
(sweeper, TTL)
↓
┌─────────┐
│ expired │
└─────────┘
auth.magic_link_tokens mirrors auth.device_codes exactly: PostgreSQL ENUM status, 32-byte CSPRNG token in base64url, single-use via UPDATE … WHERE status = 'pending', partial index on expires_at WHERE pending, and a background sweeper that promotes pending-and-overdue rows to expired.
Salient properties:
- Single-use — second redemption attempt rejected as "link already used".
- TTL-enforced —
expires_at < NOW()→ "link expired". TTL isOXICLOUD_MAGIC_LINK_TTL_HOURS, default 24. - Token in path, not query —
GET /magic/v1/{token}so the secret stays out ofRefererheaders. - 302 immediately on success — the URL is replaced in the address bar before the user can navigate away or screenshot it.
- Optional resource target —
resource_type+resource_idcolumns, with aCHECK ((resource_type IS NULL) = (resource_id IS NULL))constraint to make the two-or-neither rule explicit.
User-profile visibility rule (GET /api/users/{id})
The endpoint is the cornerstone of the share modal's "who is this person" rendering. Its visibility rule is intentionally narrow, evaluated in this order:
- Self — caller asks for their own profile.
- Shared-grant relationship — caller and target share at least one access grant in either direction. Applies to internal AND external callers; this is what lets a recipient resolve the granter's name/photo in the SharedWithMe view.
- External lockout — if the caller is external and rule 2 did not match, stop and return 404.
- Directory exposure — if the target is internal AND
OXICLOUD_EXPOSE_SYSTEM_USERS=true, return the target. - Admin — admins can always look up any user.
- 404 — otherwise. Same response as "user does not exist" (anti-enumeration).
A per-caller sliding-window rate limit (60 req/minute) guards against an attacker iterating UUIDs against rule 2 with a stale JWT. The visibility rule alone is sufficient defence-in-principle; the rate limit makes the attack uneconomical.
Audit events
Every denial or rejection in the magic-link path emits a structured event on the audit tracing target. Operators tail target=audit for compliance and incident response.
| Event | Reasons (subset) | Where it fires |
|---|---|---|
authz.denied |
permission missing | AuthorizationEngine::require |
auth.login |
user_not_found, bad_password, account_deactivated |
AuthApplicationService::login |
auth.magic_link_send |
sent, no_account, has_credential, account_deactivated, malformed_email, rate_limited_ip, rate_limited_email |
MagicLinkInviteService::send_login_link and the handler |
auth.magic_link_redeem |
redeemed, token_not_found, token_used, token_expired, account_deactivated |
MagicLinkInviteService::redeem |
user_profile.rejected |
external_no_relationship, target_external_hidden, target_hidden |
AuthApplicationService::get_user_profile |
grants.email_invite |
rate_limited |
grant_handler::create_grant |
authz.external_user_blocked |
internal_only_surface |
require_internal_user_layer (CalDAV / CardDAV / WebDAV) |
auth.nc_basic_rejected |
external_user |
basic_auth_middleware (NC Basic-Auth surface) |
auth.app_password_create_rejected |
external_user |
create_app_password |
groups.search_rejected |
external_user |
search_groups |
The convention (see CLAUDE.md § Authorization) is: any branch that denies or rejects a request must emit an audit event before returning the user-facing response. Anti-enumeration is preserved at the API surface (uniform response shape, 404 not 403), and the true reason is recorded only in the audit channel.
Rate limits
Three caps protect the magic-link surface. Each is a moka sliding-window counter; the keys differ.
| Cap | Keyed on | Default | Env var | Visible on hit? |
|---|---|---|---|---|
| Per-sharer email-invite | caller_id |
50 / hour | OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR |
yes (429 + Retry-After) |
| Per-target-email send | normalised email | 5 / hour | OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR |
no (uniform 200) |
| Per-source-IP send (backstop) | trusted client IP | 200 / hour | OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR |
no (uniform 200) |
Two distinct visibility regimes:
- Authenticated callers see 429 when they hit a cap, because their own rate-limit state leaks nothing about other accounts. The invite cap is in this regime.
- Anonymous callers never see 429 on the send endpoint — the status itself would become an enumeration oracle ("this email has been probed recently → probably has an account"). The two send caps are silently absorbed: the response stays the uniform 200, and the real reason is recorded in the audit channel.
An authenticated caller resending to themselves bypasses both send caps. The bypass signal is "presence (not validity) of an Authorization header or access cookie" — a stale-cookie holder gets a 401 from any other endpoint they touch, so the worst-case bypass is narrow.
The per-IP backstop respects OXICLOUD_TRUST_PROXY_CIDR for client IP resolution: behind a reverse proxy, the upstream IP from X-Forwarded-For (leftmost) is used; without a configured trusted CIDR, the proxy's IP would be used and the backstop would effectively become a single bucket.
Defence-in-depth boundary protections
External users are a new principal kind, and several pre-existing surfaces would over-share once they appeared. The protections fall in two layers — service-level filters that every surface inherits, and route-level layers that close protocol surfaces with no semantic meaning for externals.
Service-layer filters (every surface inherits these)
- Subject groups reject external members.
subject_group_service.rs::add_membershort-circuits if the candidate user hasis_external = TRUE. Otherwise an admin could addalice@example.comto "Engineering", which later receives a grant on internal-only resources — silent privilege escalation. Mirrors the no-external-admins enforcement. - System contacts hide externals by default.
auth_service.list_usersandauth_service.search_userstakeinclude_external: bool, defaulting tofalse. The share modal autocomplete (via/api/address-books/system/contacts) therefore never surfaces external users to internal callers, and external users never see internal users at the address book layer. - External users are excluded from the Internal virtual group.
pg_acl_engine.rs::expand_userno longer insertsINTERNAL_GROUP_IDfor users withis_external = TRUE. The group's name finally honours its semantics; every grant addressed to "all internal users" is now genuinely internal-only.
Route-level lockouts (close protocol surfaces upfront)
External users have no calendar, no address book, no home folder, and (by design) no persistent credential. The protocol surfaces that assume those things are closed to them at the middleware layer — before any handler runs:
/caldav/*,/carddav/*,/webdav/*are wrapped withrequire_internal_user_layerinmain.rs. The layer runs afterauth_middleware, reads the populatedCurrentUserfrom request extensions, callsrequire_internal_useronce per request, and 403s + audit-logs on rejection. PROPFIND / REPORT / OPTIONS — every DAV verb is closed.- NextCloud
/remote.php/*and/ocs/*are gated insidebasic_auth_middleware: after a successful app-password match, a follow-up lookup checksis_externaland returns 401 if true. This is belt-and-braces — externals can't create app passwords in the first place (next item) — but it covers users who later flip tois_externalafter creating one. POST /api/auth/app-passwordsis closed. App passwords are persistent credentials; the magic-link-eligibility rule (has_login_credential) assumes externals have no other credential configured. Letting an external mint an app password would break that invariant and would also be the only way to authenticate them on the NC surface. 403 + audit on rejection.GET /api/groups/searchis closed. Group names aren't strictly secret, but externals have no legitimate use for the share-dialog autocomplete (they can't be added to groups anyway).
Pre-existing safeguards from the user-lifecycle work continue to apply: the DB CHECK constraints users_external_not_admin and users_external_no_storage, and the HomeFolderLifecycleHook short-circuit that skips home-folder provisioning for externals.
Why protocol-level instead of handler-level
The route layer is one require_internal_user_layer per nest rather than one check per handler. Three reasons:
- Coverage. Every DAV verb (and every NC OCS endpoint) is gated in one place. New handlers added under the same nest inherit the protection automatically.
- Cost. The layer hits the DB once per request (already cached in moka under the hood); a per-handler check would do the same work without the reuse.
- Auditability. A single audit event (
authz.external_user_blockedwith the requestpath) covers the whole subtree. Operators can grep oneevent=value across all DAV traffic.
Kill switches and feature scoping
| Knob | What it does |
|---|---|
OXICLOUD_ALLOW_EXTERNAL_USERS=false |
Coarse off-switch. POST /api/grants rejects email-typed subjects for unknown emails; send endpoint returns the uniform stub without issuing a token. Pre-existing externals continue to function. |
OXICLOUD_EXTERNAL_EMAIL_DOMAINS=… |
Fine-grained allowlist of accepted domains for new external users. Empty = no restriction. Exact-match (case-insensitive) on the post-@ part — partner.com does NOT match eng.partner.com. |
OXICLOUD_SMTP_* unconfigured |
The whole magic-link feature is unavailable. Endpoints that depend on it return 503 Service Unavailable with a clear message. |
OXICLOUD_MAGIC_LINK_TTL_HOURS |
Token lifetime. Default 24 hours. Shortening it raises the resend rate; lengthening it raises the window for token theft. |
The send endpoint does return 503 (not the uniform 200) when SMTP is entirely unconfigured: the absence of the feature is visible from any other /api/auth/magic-link/* route anyway, so hiding the 503 leaks nothing the attacker could not learn elsewhere.
What is deliberately out of scope
These are intentionally deferred. Each has a clear future trigger; none block the present design.
auth.user_auth_methodsside-table. Replaces the placeholder-string smell.has_login_credential()is the single migration point.- Email-locale routing. v1 ships English-only invitation templates. A future PR adds recipient-locale detection (Accept-Language at send time, or stored preference) and a template engine.
- MX-record validation at share time. Regex is the only pre-send check; bad domains surface via SMTP bounce.
- Dormant external user sweeper. Purges users with no
last_login_atfor 13+ months. The GDPR-purge variant inUserLifecycleHook::on_user_deletedis its hook entry point. OXICLOUD_EXTERNAL_USERS_CAN_RESHARE=false. Forbids externals from being a grant'sgranted_by. Today an external withPermission::Sharecan mint more externals — a soft policy worth tightening but not load-bearing.- Differentiated session TTL for externals. Uniform across all users today. Future env:
OXICLOUD_EXTERNAL_REFRESH_TOKEN_EXPIRY_DAYS. session_kindon sessions emitted from magic-link. Enables scoped sessions ("magic-link session can only access granted resources, not the user's own folders"). External users have no own folders so the practical exposure is small.- Admin-list-users that includes externals. Today
list_usersfilters externals by default. A future admin UI for managing externals (rename, deactivate, view their grants) will need aninclude_externalquery param. - Open Cloud Mesh (OCM) federation. A separate path for external identity;
ExternalIdentityLifecycleHook::on_user_createdaccommodates asourcediscriminator (magic_link/oidc/ocm). - WebAuthn / passkey enrolment. Distinct future feature; magic-link is the bootstrap.
- Bounce tracking. No webhook listener for SES-style bounce notifications. A future
on_email_bounceevent would surface "this user's email is dead" in admin UI.
Related documents
- User lifecycle — the hook framework that fires on user creation and the deletion modes.
- ReBAC Authorization — how grants are evaluated against
auth.usersrows (including external ones). - Share Integration — how the public-share-link flow relates to the email-invite flow (both create
access_grantsrows; only the former lives instorage.shares). - Environment Variables — the full set of
OXICLOUD_*knobs.