e94063d96a
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
163 lines
8.4 KiB
Plaintext
163 lines
8.4 KiB
Plaintext
# =============================================================
|
|
# OxiCloud — magic-link login for password users
|
|
# =============================================================
|
|
# Regression pin for the `OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users`
|
|
# switch. Default eligibility ladder refuses `has_password` accounts
|
|
# (the strict argument: mailbox-strength shouldn't shadow the stronger
|
|
# credential). Operators who prefer modern-SaaS UX opt-in via this
|
|
# policy; when set, `POST /api/auth/magic-link/send` mints a login token
|
|
# for accounts that also have a password.
|
|
#
|
|
# Cross-file coupling: `tests/common/server.env` sets
|
|
# `OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users`. Without
|
|
# it, Step 2 below would land on `reason="has_password"` and mail nothing
|
|
# — Step 3's SMTP capture would fail with an empty inbox.
|
|
#
|
|
# What is NOT exercised here:
|
|
# * OIDC-master rule: covered separately in tests/oidc/oidc.hurl
|
|
# step 2b (magic-link SEND refused when OIDC is enabled).
|
|
# * `has_password` rejection under the strict default: can't be
|
|
# exercised in the same run — the env is global. Rust unit test
|
|
# on `magic_link_eligibility()` covers it directly.
|
|
# =============================================================
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 1 — Admin login. Needed to reach the mock-SMTP capture
|
|
# endpoint (admin-scoped: /api/admin/smtp/test/captured).
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "{{username}}", "password": "{{password}}" }
|
|
|
|
HTTP 200
|
|
[Captures]
|
|
alice_token: jsonpath "$.access_token"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 2 — Baseline: admin logs in normally with a password.
|
|
# Confirms nothing about the policy has broken the
|
|
# classic path. Same call as Step 1, kept as a
|
|
# named baseline for readers of the test log.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/auth/login
|
|
Content-Type: application/json
|
|
{ "username": "{{username}}", "password": "{{password}}" }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.access_token" exists
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 3 — Request a magic-link for the SAME user via email.
|
|
# Anti-enum uniform 200 regardless of eligibility, so
|
|
# the real proof of "policy fired, mail actually sent"
|
|
# is the SMTP capture in Step 5. Without the policy
|
|
# in server.env, this same request would be refused
|
|
# under `reason="has_password"` and no mail would be
|
|
# captured.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/auth/magic-link/send
|
|
Content-Type: application/json
|
|
{ "email": "{{email}}" }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.message" contains "sign-in link"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 4 — Same request, but with the LOGIN-IDENTIFIER passed
|
|
# as a username (no `@`). Server dispatches on `@` and
|
|
# resolves the username to the registered email BEFORE
|
|
# rate-limiting, so `admin` and `admin@example.com`
|
|
# bucket on one budget. Uniform 200 either way.
|
|
#
|
|
# The browser-binding challenge cookie is captured HERE
|
|
# (not on Step 3): each `/send` request mints a fresh
|
|
# challenge, and Step 5 will fetch the MOST RECENT mail —
|
|
# which was minted by this very request. Capturing from
|
|
# Step 3 instead would pair a stale cookie with Step 4's
|
|
# token, and Step 6's redemption would land on PR 22's
|
|
# cross-browser confirmation page (200 HTML) instead of
|
|
# the direct 302.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/auth/magic-link/send
|
|
Content-Type: application/json
|
|
{ "email": "{{username}}" }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.message" contains "sign-in link"
|
|
[Captures]
|
|
alice_magic_cookie: header "set-cookie" regex "oxicloud_magic_request=([^;]+)"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 5 — Capture the mail. The mock SMTP records every
|
|
# outbound message keyed on the recipient. Two magic-
|
|
# link mails should have landed (steps 3 and 4), both
|
|
# addressed to the admin's registered email. The
|
|
# captured endpoint returns the MOST RECENT one — we
|
|
# extract its link.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/admin/smtp/test/captured?to={{email}}
|
|
Authorization: Bearer {{alice_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.to" == "{{email}}"
|
|
jsonpath "$.text_body" matches "/magic/v1/[A-Za-z0-9_-]+"
|
|
[Captures]
|
|
alice_magic_url: jsonpath "$.text_body" regex "(https?://[^\\s]+/magic/v1/[A-Za-z0-9_-]+)"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 6 — Redeem the link with the matching browser-binding
|
|
# cookie. Internal user, no resource target → lands
|
|
# on `/files` (SPA route). Access-token cookie is set
|
|
# on the redirect response.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{alice_magic_url}}
|
|
Cookie: oxicloud_magic_request={{alice_magic_cookie}}
|
|
|
|
HTTP 302
|
|
[Asserts]
|
|
header "Location" == "/files"
|
|
[Captures]
|
|
alice_magic_access_token: cookie "oxicloud_access"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 7 — The cookie session works: /api/auth/me returns the
|
|
# admin's own profile. Proves the magic-link redemption
|
|
# created a real session for the password-holding user
|
|
# — the point of the whole `permit_magic_link_for_password_users`
|
|
# policy.
|
|
# ─────────────────────────────────────────────────────────────
|
|
GET {{base_url}}/api/auth/me
|
|
Authorization: Bearer {{alice_magic_access_token}}
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.email" == "{{email}}"
|
|
jsonpath "$.username" == "{{username}}"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────
|
|
# Step 8 — Anti-enum sanity: magic-link for a non-existent
|
|
# identifier. Same uniform 200 shape, no mail sent.
|
|
# The audit log records reason="no_account" — not
|
|
# observable from the client, but the response shape
|
|
# is IDENTICAL to Step 3, which is the whole point.
|
|
# ─────────────────────────────────────────────────────────────
|
|
POST {{base_url}}/api/auth/magic-link/send
|
|
Content-Type: application/json
|
|
{ "email": "ghost-user-that-doesnt-exist" }
|
|
|
|
HTTP 200
|
|
[Asserts]
|
|
jsonpath "$.message" contains "sign-in link"
|