20 KiB
Plan — Auth simplification (PR 16–21)
Context
The magic-link work (PRs 6–12) shipped external users as a second principal kind with username = email. PR 13 closed the route-level lockouts. Across a design conversation on 2026-06-02 we agreed the resulting model is needlessly two-tier and can be simplified by making email the identity, username an optional handle, and credentials (password / OIDC) truly optional and orthogonal. The proximate motivation: avoid the username-vs-email cross-collision class of bugs we just spent time guarding against, and reduce password-hash density in the DB by letting users sign up email-only with magic-link as their bootstrap path.
The end state: every user is identified by email; username, password_hash, and oidc_subject are all Option<…> columns whose presence is observable but never required. Login is a one-line dispatch on @-in-input. Magic-link eligibility is a three-branch rule (OIDC always rejected, password rejected by default, no-credential allowed) with one env knob to flip the middle branch. No new auth methods land here — TOTP / WebAuthn / passkey enrolment stays out of scope. The whole work is reorganisation of existing primitives, not new ones.
Design decisions (locked in by conversation)
Identity model
| Slot | Type | Meaning |
|---|---|---|
email |
String (NOT NULL UNIQUE) |
The identity. Every login path ultimately resolves here. |
username |
Option<String> (UNIQUE, NULL allowed) |
Optional handle. 2-64 chars inclusive, [A-Za-z0-9._-]+ (no @). Claimable post-creation. Multiple NULLs coexist under the existing UNIQUE index. |
password_hash |
Option<String> |
An Argon2 hash if the user chose one. NULL otherwise. NO placeholder strings. |
oidc_subject |
Option<String> |
The IdP subject claim if the user linked one. NULL otherwise. |
is_external |
bool |
Provisioning origin marker. true when created via email-invite from a sharer. Future "promote to internal" flow (separate TODO) flips this. |
Eligibility predicates derive from the slots:
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()
}
No more sentinel strings (__EXTERNAL_NO_PASSWORD__, __OIDC_NO_PASSWORD__). The schema migration NULLs them out as part of PR 16.
Login dispatch
input contains '@' → lookup by email, verify password
input does not → lookup by username, verify password
Single DB hit. Unambiguous because @ is forbidden in username. The same dispatcher serves the magic-link send endpoint, but that endpoint only takes email — input without @ is a 400.
Magic-link eligibility ladder
pub fn magic_link_eligibility(user: &User, open_to_password_users: bool) -> Eligibility {
if user.has_oidc() { return Reject("oidc_user"); } // unconditional; IdP is the security boundary
if user.has_password() {
return if open_to_password_users { Allow } else { Reject("has_password") };
}
Allow // no credentials at all
}
| User state | Magic-link eligible? |
|---|---|
| No password, no OIDC | Yes — always |
| Has password, no OIDC | Default no; OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true opens it |
| Has OIDC (with or without password) | No — always. Flag has no effect. |
The oidc_user reject is unconditional because OIDC is the only path to MFA today (delegated to the IdP), and magic-link must never bypass it. Future native 2FA (TOTP, WebAuthn) lands a third reject branch behind the existing patterns.
Registration paths
| Path | Pre-condition | Effect |
|---|---|---|
POST /api/auth/register with email + password |
Public registration enabled | User row with password_hash set. JWT issued. |
POST /api/auth/register with email only |
Public registration enabled | User row with password_hash = None. Magic-link mailed to the address for first-session bootstrap. JWT NOT issued by the register call. |
POST /api/grants { subject.type: "email" } |
Sharer has Share permission | External user lazily provisioned. Magic-link invitation mailed. Existing PR 9 flow. |
| OIDC JIT | First IdP-mediated login | User row with oidc_subject set, password_hash = None. JWT issued. |
All four return the same uniform shape regardless of email existence (PR 20 closes the register oracle that survives from the original schema). Real reason recorded in audit.
Anti-enumeration is preserved everywhere
POST /api/auth/register→ 200 uniform, audit reasonscreated/email_taken/username_taken/disabledPOST /api/auth/magic-link/send→ 200 uniform, audit reasonssent/no_account/has_password/oidc_user/account_deactivated/malformed_email/rate_limited_email/rate_limited_ipPOST /api/auth/login→ 403 uniformInvalid credentials, audit reasonsunknown_user/bad_password/account_deactivated
Migration discipline
Forward-only migrations. The previous …000003_users_username_email_login.sql widening to 254 chars has already been applied to dev / CI environments — squashing with the new shrink-and-NULL migration would break _sqlx_migrations checksum tracking. Add a fresh migration file; history records the two-step story honestly.
What stays the same
- JWT structure, session lifetimes, refresh-token rotation
is_externalflag and its DB CHECK constraints (users_external_not_admin,users_external_no_storage)- All PR 13 route-level lockouts (external users still can't reach CalDAV/CardDAV/WebDAV/NC, can't mint app passwords, can't enumerate groups)
- ReBAC
access_grantstable and all permission semantics - SMTP-unconfigured ⟹ magic-link unavailable ⟹ external invitations unavailable (existing 503 paths)
- The three rate limiters from PR 12 — caps and keys unchanged
PR sequence
| PR | Subject | Why land separately |
|---|---|---|
| 16 | Schema + entity: nullable username / password_hash, format CHECK, sentinel cleanup, User::new collapse, audit-log Option handling |
Foundational — every later PR consumes Option<String> columns. Verifiable in isolation by re-running the existing Hurl suite. |
| 17 | Login dispatcher: input contains('@') decides email vs. username path. Pure refactor of AuthApplicationService::login. |
One behavioural axis; isolated test surface. |
| 18 | Optional password at registration: RegisterDto.password: Option<String>. Password-less path mints a magic-link to the supplied email. |
The "email-only signup" UX. Depends on 16. |
| 19 | OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS env knob + magic_link_eligibility() refactor with three audit reasons. |
Single env-driven policy switch with observability. |
| 20 | Anti-enumeration register: uniform 200 regardless of outcome, real reasons in audit channel. |
Closes the username/email enumeration oracle that survives from the original schema. |
| 21 | docs/architecture/auth-model.md (~250 lines) + sidebar + cross-references. Acceptance gate. |
Big-picture documentation of the final identity / credential / login surface. |
Critical files
New
migrations/20260603000000_username_optional_no_email_shape.sql— username + password_hash schema cleanuptests/api/auth_login.hurl— dispatcher coverage (PR 17)tests/api/registration.hurl— email-only signup + anti-enumeration (PR 18 + PR 20)docs/architecture/auth-model.md— final architecture page (PR 21)
Modified — by PR
PR 16:
src/domain/entities/user.rs—username: Option<String>,password_hash: Option<String>,oidc_subjectconfirmedOption<String>. Collapsenew_password/new_oidc/new_externalinto oneUser::new(or keep three named factories if readability outweighs the duplication). Drop sentinel string comparisons;has_login_credentialbecomes a two-lineOption::is_somecheck.src/infrastructure/repositories/pg/user_pg_repository.rs— Option binding in all 8 SELECT/INSERT/UPDATE sitessrc/application/dtos/user_dto.rs—username: Option<String>src/application/services/magic_link_invite_service.rs::resolve_or_create_recipient— passNonefor username when minting an externalsrc/application/services/auth_application_service.rs— every audit line emittingusername = %user.username()switches to adisplay_for_audit(&user)helper that falls back to user_id whenNonesrc/interfaces/api/handlers/contacts_handler.rs::user_to_contact— already handles None fallback (from yesterday's given/family work), but verify after the type changesrc/interfaces/nextcloud/routes.rs::verify_url_user— whenauth_user.usernameis None, return 403 instead of comparing to the URL segment (externals are already PR-13-blocked but the type change forces an explicit branch)- Every
auditlog line inauth_handler.rs,auth_application_service.rs,magic_link_invite_service.rs— review forusername =interpolations
PR 17:
src/application/services/auth_application_service.rs::login— dispatch on@src/application/dtos/user_dto.rs::LoginDto— renameusernamefield tousername_or_emailwith a serde alias for backward compat, or leave the name and document the new semantics- Frontend
static/login.html— change the input placeholder from "Username" to "Username or email"
PR 18:
src/application/dtos/user_dto.rs::RegisterDto.password: Option<String>+ validation: when present, enforce length minimumsrc/application/services/auth_application_service.rs::register— branch ondto.password.as_ref(). When None: create user withpassword_hash = None, then callMagicLinkInviteService::send_login_link(&dto.email)as a best-effort post-actionsrc/interfaces/api/handlers/auth_handler.rs::register— uniform 201 either way
PR 19:
src/common/config.rs::MagicLinkConfig.open_to_password_users: bool(default false) + env loadersrc/application/services/magic_link_invite_service.rs— newmagic_link_eligibility()function; replace the singleif user.has_login_credential()check insend_login_linkandissue_invitationexample.env— new entry with explanation
PR 20:
src/application/services/auth_application_service.rs::register— silence the descriptive error strings; return Ok with the same uniform shape on collision; audit-log the truthsrc/interfaces/api/handlers/auth_handler.rs::register— response body becomes the uniform "If the email is available, a confirmation link has been sent."- Hurl regression: the existing register test in
tests/api/setup.hurletc. needs to assert the new uniform shape
PR 21:
docs/architecture/auth-model.md(new file, ~250 lines)docs/.vitepress/config.mts— sidebar entry aftermagic-link-authdocs/architecture/magic-link-auth.md— § "Identity model" links toauth-model.mddocs/architecture/share-integration.md— already referencesmagic-link-auth; chain stays one hop deep
Existing patterns to reuse (with paths)
- MockEmailSender at
src/infrastructure/services/mock_email_sender.rs— every PR 18 / 20 Hurl test uses it viaGET /api/admin/smtp/test/captured?to=…. Already configured intests/common/server.env(OXICLOUD_SMTP_MOCK=true). - Audit-log convention from
CLAUDE.md§ Authorization — every reject emitstracing::info!(target: "audit", event = "<domain>.<verb>", reason = "<key>", …)with structured fields. New reasons in this work:auth.register_rejectedwithemail_taken,username_taken,disabledauth.magic_link_sendgainshas_password,oidc_user(replacing singlehas_credential)auth.login_rejectedreasons unchanged
- Migration mirroring —
migrations/20260612000003_users_username_email_login.sql(or the actual existing file) is the precedent for username-column changes. New migration sits beside it. - Three rate limiters from PR 12 — caps and keys unchanged. No new limiters in this work.
- Eligibility split pattern —
Eligibility::Allow / Reject(reason)enum is new; canonical home isapplication/services/magic_link_invite_service.rsnext to the existingMagicLinkResourceKind.
Verification
Per-PR (mandatory, every PR):
cargo fmt --all
cargo clippy --all-features --all-targets -- -D warnings
cargo test --workspace --lib
bash tests/api/run.sh
Frontend checks when touching static/:
biome check --fix static/
stylelint static/css/
tsc -p jsconfig.json --noEmit
End-to-end gates
After PR 16 (smoke): the existing 14 Hurl files still pass. Bob's flow in external_users.hurl works with bob's username now NULL (assertion updates in Step 11c and Step 12).
After PR 17:
- Hurl: alice (internal, picked username "alice") logs in via
username = "alice"→ 200 - Hurl: alice logs in via
username = "alice@oxicloud.local"(her email) → 200 - Hurl: bob (external, NULL username) logs in via
username = "bob@externalcompany.com"→ 403 (no password); via magic-link path → still works as before - Hurl: wrong password on either path → uniform
403 Invalid credentials
After PR 18:
- Hurl:
POST /api/auth/registerwith{email, password}→ 201, JWT returned (existing behaviour) - Hurl:
POST /api/auth/registerwith{email}only → 200 with uniform body;GET /api/admin/smtp/test/captured?to=…returns the welcome magic-link - Hurl: follow the captured URL → session cookies set, redirect to
/#/ - Hurl: user now has
password_hash = NULLin DB; subsequent magic-link send is allowed (eligible) - Hurl: user sets a password via
PUT /api/auth/change-password→ next magic-link send to same email returns 200 but NO new mail captured (strict mode +has_passwordaudit)
After PR 19:
- Hurl in default mode: password user gets no mail on magic-link send (existing strict-mode test)
- Hurl in lenient mode (
OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=trueintests/common/server.env, or a separate run): mail IS captured for the same scenario - Hurl: OIDC user → no mail regardless of flag value (the
oidc_useraudit reason fires unconditionally) - Unit test: 16-row matrix of
(has_password, has_oidc, is_external, flag)× eligibility outcome
After PR 20:
- Hurl:
POST /api/auth/registerwith an already-used email → 200 uniform; no new user row in DB; audit log lineauth.register_rejected reason=email_taken - Hurl:
POST /api/auth/registerwith an already-used username → 200 uniform; same shape - Hurl: timing comparison (informal) — collision path returns within ±10ms of the success path
After PR 21 (acceptance):
auth-model.mdrenders correctly in VitePress dev (cd docs && npm run dev)- Sidebar shows the new entry under Architecture
- Every audit reason listed in the doc is grep-able to a real
tracing::info!call insrc/
Out of scope (do NOT bundle)
Items the conversation explicitly deferred. Each has a clear future trigger.
- Native 2FA (TOTP, WebAuthn enrolment) for password users. Today OIDC delegation is the only MFA path; native enrolment requires UI, recovery codes, and a third reject branch in
magic_link_eligibility()(mfa_enrolled). Listed inauth-model.md§ "What is deliberately out of scope". login_strategyper-user policy enum. The conversation surfaced this as a future architectural direction. Captured inauth-model.md§ "Future direction — per-user login strategy" with the matrix (passwordless,password,password_or_magic_link,password_and_magic_link,oidc,password_and_totp,password_and_webauthn). No implementation in this work.- External-user promotes to internal. Triggered when an external sets a credential. Today
is_externalstays TRUE post-credential-set; the upgrade flips it to FALSE and provisions a home folder + Internal-group membership + DAV access. Depends on registration UX direction (Design A "claim username when needed" vs. Design B "auto-generate handle") being decided. Separate work. session_kindon sessions emitted from magic-link. A magic-link session today is indistinguishable from a password session. Enables scoped sessions later (Option-B style "magic-link session can only access granted resources"). Not load-bearing for v1.- Differentiated session TTL for externals. Uniform refresh-token expiry today. Future env
OXICLOUD_EXTERNAL_REFRESH_TOKEN_EXPIRY_DAYS. - Open Cloud Mesh (OCM) federation. Third source for external provisioning. The
ExternalIdentityLifecycleHook::on_user_createddesign accommodates thesourcediscriminator (magic_link/oidc/ocm). - Recovery codes / passkey enrolment. Tied to native 2FA above.
- Per-user opt-out of magic-link when lenient mode is on. Today the env flag is instance-wide. A future per-account toggle (e.g. high-privilege admins disabling magic-link for themselves) would need an
auth.users.magic_link_disabled BOOLEANcolumn and one extra branch in eligibility. Listed inauth-model.md.
Locked-in design decisions (confirmed before PR 16)
- Single
User::newconstructor. Signature:User::new(email, password_hash: Option<String>, oidc_subject: Option<String>, is_external: bool, …). The three named factories (new_password/new_oidc/new_external) collapse into call-site helpers if needed, but the canonical API is one constructor. OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERSis symmetric. Whentrue, both/api/auth/magic-link/sendAND/api/grantsemail-invitations mail through to password users. Singlemagic_link_eligibility()predicate, same audit reasonhas_passwordin both code paths.- Email-only signup welcome redemption lands on
/#/files. Redemption logic adjustment: NULLresource_id+is_external = false→/#/files; NULL +is_external = true→/#/sharedwithme(existing rule). One extra branch inmagic_link_handler::redeem. LoginDto.usernamefield name kept with docstring updated to "accepts email or username". Frontend already sends the input asusernameregardless of what the user typed. Mentioned inauth-model.mdas an intentional ambiguity in the API shape.
Recommended future event triggers (DON'T ship in this work)
Same convention as previous plans — a future event ships only when there's a concrete consumer.
| Future event | What would force it |
|---|---|
on_password_set |
When a user first sets a password — useful for audit ("alice is no longer magic-link-eligible" or "alice is now lenient-mode-eligible") and for invalidating any outstanding magic-link tokens she has. Today the new tokens are simply unused; reaping them via this event would be cleaner. |
on_oidc_linked |
When a user first links OIDC — useful for the same audit purpose, and especially load-bearing because OIDC linkage permanently disables magic-link. |
on_username_claimed |
When a NULL-username user picks one. Audit visibility for the share-modal autocomplete suddenly showing a new entry. |
on_credential_revoked |
When a user removes their password or unlinks OIDC. Reverses the eligibility decision. |
These are doc-only; their absence doesn't block anything.
Doc skeleton — auth-model.md
The PR 21 deliverable. Headings only:
- Why this page
- Identity model (email, username, credentials)
- Credential slots and the eligibility derivation
- Login paths
- Username + password
- Email + password
- Email + magic-link (with the 3-branch table)
- OIDC redirect
- Login dispatcher — how the input is interpreted (the
@-in-input rule) - Registration paths
- Email + password
- Email-only
- OIDC JIT
- Email invitation
- Anti-enumeration — what each endpoint returns
- Security trade-offs
- Mailbox-as-bypass when
OPEN_TO_PASSWORD_USERS=true - Why OIDC is unconditionally excluded
- No native 2FA today; how OIDC delegation provides MFA via IdPs
- Rate-limit caps
- Mailbox-as-bypass when
- Audit events — table
- Migration path for existing instances
- Future direction — per-user
login_strategy(sketch with the 7-row matrix) - What is deliberately out of scope
- Related documents (cross-refs)
Target: ~250 lines, big-picture, no code walkthroughs (in line with magic-link-auth.md).
Status: ready to start at PR 16 on confirmation. No code written yet. No tasks created in the TaskCreate system — those happen per-PR when implementation begins.