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. - Rows persist past
used/expired— the sweeper transitions status, it does not immediately DELETE. This is what makes the self-service resend (next section) possible: the token in the URL keeps working as a recipient-discovery key well after the credential it carried has stopped working.
Self-service resend
The 410-Gone landing page for a stale link is not a dead end. The page server-side branches on whether the token row is recoverable:
- Row exists, status is
expiredorused, owning user is still active → the page renders a one-click form: "Send a fresh link to a…@example.com" (POST to/magic/v1/{token}/resend). - Anything else (unknown token, pending, deactivated account, plumbing missing) → the page falls back to the existing generic "no longer valid" message. The two responses are deliberately indistinguishable to the caller — the rich page only differs when the row already proves the caller has legitimate context.
Why no PII in the URL
An earlier sketch carried the recipient's email as ?r={base64(email)} so the page could greet the user by address. We dropped it: a token is short-lived but a URL persists in browser history forever (and syncs to Chrome / Firefox cloud profiles), the address would leak via any future external Referer, and reverse-proxy access logs would gain a PII field they don't have today. Since the row already carries user_id → users.email, the server can recover the address on demand and the URL stays clean.
Why both expired and used qualify
used covers the "I clicked the link on my phone, now I want to sign in on my laptop" case — the original link is dead by single-use design, but the recipient is real and the row still holds the recipient pointer. Offering resend on used is harmless (the new mail goes to the registered email, not the caller; rate limits are the same) and avoids a confusing dead-end for the most common second-device path.
Endpoint shape
POST /magic/v1/{token}/resend mirrors POST /api/auth/magic-link/send in every operational respect:
- Per-source-IP rate limit (
OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR, default 200/h) — runs first, unconditionally. Burns budget even when the token doesn't resolve so the endpoint can't be used to spread probes thin across many tokens. - Token resolution —
MagicLinkInviteService::lookup_resend_recipient(token). ReturnsSome(ResendRecipientHint)only forexpired/usedrows whose owning user is active.Nonein every other case (pending, unknown, deactivated, repo absent). - Per-target-email rate limit (
OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR, default 5/h) — keyed on the recipient we just resolved. Caps actual mail volume to the recipient regardless of how many IPs hammer the endpoint; effectively a per-token-recipient ceiling without a schema-level counter. - Fresh challenge + send — generate a per-request challenge (cookie + token-row mirror, see PR 22), dispatch through
send_login_link. The new token has the standard short login TTL, not the longer invite TTL — the recipient just clicked, so a slow second click is almost certainly someone else with mailbox access. - Uniform response — every outcome (rate-limited, no-account, account deactivated, SMTP-failed, succeeded) renders the same "Check your inbox" HTML page. The real outcome is in the audit channel via
auth.magic_link_sendevents.
The handler is a sibling under the same /magic/v1/* router, no CSRF middleware (the page that triggers it is the 410 response itself — same-origin, plain HTML form, no JS, no third-party referrer realistically able to forge the POST against a per-token URL).
What the per-token ceiling looks like in practice
A single recovered URL × per-target-email cap (5/h) × 24h = 120 magic-link mails per day maximum to that recipient from that specific URL. Annoying but bounded; the recipient's inbox cannot be flooded into uselessness from one stable handle. A per-token counter column (resend_count with a hard maximum, e.g. 3) would tighten the bound further; it's deferred until real abuse is observed.
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, internal_error | MagicLinkInviteService::send_login_link, auth_handler::send_magic_link, magic_link_handler::resend_magic_link |
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) |
The two send caps are shared between POST /api/auth/magic-link/send and POST /magic/v1/{token}/resend — they're the same moka counters, so an attacker can't double their budget by alternating endpoints.
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.