Files
2026-08-21 23:56:25 +02:00

966 lines
48 KiB
Plaintext

# =============================================================
# OxiCloud — OIDC happy-path integration test
# =============================================================
# Drives the full SSO flow against the fake IdP under
# tests/oidc/fake_idp/ (panva/node-oidc-provider with an auto-approve
# interaction handler) and asserts every contract the SPA depends on.
# Pinned regression: commit d1bbe8ba changed the callback's frontend
# redirect from `/?oidc_code=…` to `/login?oidc_code=…` — Step 5's
# `Location matches "^…/login\?oidc_code=…"` is the assertion that
# would have caught that bug before users hit it.
#
# Flow walked manually (no auto-follow) so each handler is asserted
# independently:
#
# 1. Bootstrap: create the local admin (provider list works
# regardless of admin presence, but the rest of the test
# lives more comfortably with a fully-initialised server).
# 2. GET /api/auth/oidc/providers — SPA reads this to render
# the SSO button.
# 3. GET /api/auth/oidc/authorize — server mints state + PKCE,
# redirects to the IdP.
# 4. GET <IdP>/auth (+ full redirect chain) — fake-idp
# auto-approves login + consent, OxiCloud's callback
# JIT-provisions the user and redirects to
# {frontend_url}/login?oidc_code=… The final 404 (no SPA
# shell in test config) IS the test signal: we assert on
# the URL we landed at, which is the d1bbe8ba contract.
# 5. POST /api/auth/oidc/exchange — SPA swaps the one-time
# code for tokens + cookies.
# 6. GET /api/auth/me — proves the cookie session is live AND
# that every OIDC profile claim (name → username, given_name,
# family_name, picture → image, email, groups → admin role)
# was JIT-provisioned correctly into the local user record.
# 7. POST /api/auth/refresh — rotation of all three cookies;
# proves the SPA's session-renewal path works on top of
# an OIDC-provisioned account.
# 8. GET /api/auth/me — the refreshed cookies authenticate too.
# 9. Existing-user re-login — a second OIDC flow with the same
# `sub` resolves to the same local user, not a duplicate.
# 10. Anti-takeover — an unverified-email callback is rejected.
# 11. POST /api/auth/oidc/exchange — replay-protection: the
# one-time code is single-use, second exchange returns 401.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 1 — Create the local admin
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/setup
Content-Type: application/json
{
"username": "{{username}}",
"email": "{{email}}",
"password": "{{password}}"
}
HTTP 201
# ─────────────────────────────────────────────────────────────
# Step 2 — Provider discovery for the SPA
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/auth/oidc/providers
HTTP 200
[Asserts]
jsonpath "$.enabled" == true
# tests/common/server-with-oidc.env sets OXICLOUD_OIDC_PROVIDER_NAME=MockSSO.
jsonpath "$.provider_name" == "MockSSO"
jsonpath "$.authorize_endpoint" == "/api/auth/oidc/authorize"
jsonpath "$.password_login_enabled" == true
# OIDC-master rule: magic-link login must be reported as OFF when OIDC
# is enabled, regardless of `OXICLOUD_AUTH_METHODS` or SMTP wiring.
# Magic-link would bypass any 2FA / step-up the IdP enforces; refusing
# it at the deployment level is a hard invariant. The SPA reads this
# to hide the "Send sign-in link" affordance.
jsonpath "$.magic_link_login_enabled" == false
# ─────────────────────────────────────────────────────────────
# Step 2b — OIDC-master rule regression at the endpoint layer.
# `POST /api/auth/magic-link/send` is refused with 403
# `MagicLinkLoginDisabled` when OIDC is enabled. The
# mock SMTP is configured (server-with-oidc.env has the
# full SMTP block) so this proves the policy gate fires
# BEFORE the "SMTP not wired" 503, which would otherwise
# mask the real reason. The 403 error_type is the machine-
# readable contract the SPA switches on.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/magic-link/send
Content-Type: application/json
{ "email": "someone@example.com" }
HTTP 403
[Asserts]
jsonpath "$.error_type" == "MagicLinkLoginDisabled"
# ─────────────────────────────────────────────────────────────
# Step 3 — SPA-initiated authorize. Server returns a 302 with
# state + PKCE challenge in the Location URL.
# [Options] location: false keeps Hurl from following
# the redirect so we can capture the target intact.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/auth/oidc/authorize
[Options]
location: false
# 307 (not 302): handler uses axum Redirect::temporary, which preserves
# the request method on follow. For a GET-initiated SSO flow it makes
# no practical difference, but the assertion has to match what's emitted.
HTTP 307
[Captures]
idp_url: header "Location"
[Asserts]
# panva/node-oidc-provider publishes authorize at /auth (not
# /authorize). The OXICLOUD_OIDC_ISSUER_URL points at the issuer
# root; the discovery doc tells OxiCloud the actual endpoint.
header "Location" matches "^{{oidc_authorize_endpoint}}\\?"
header "Location" contains "state="
header "Location" contains "code_challenge="
header "Location" contains "code_challenge_method=S256"
header "Location" contains "client_id=oxicloud-test"
header "Location" contains "redirect_uri="
# ─────────────────────────────────────────────────────────────
# Step 4 — Walk the entire IdP + OxiCloud redirect chain.
#
# The fake IdP's auto-approve handler resolves login +
# consent silently and 302s back to OxiCloud's callback;
# the callback validates state + exchanges code with the
# IdP, JIT-provisions the user, then 307s the browser to
# {frontend_url}/login?oidc_code=…
#
# With `location: true` Hurl follows the whole chain and
# lands on the SPA login URL. The SvelteKit SPA serves
# `/login` from `static-dist/login.html` with 200 — this
# is the production contract. The runner (`tests/oidc/run.sh`)
# builds `static-dist/` before launching the server so
# local and CI both see the production behaviour. Without
# that build the route would 404 via the ServeDir fallback.
#
# A pre-d1bbe8ba server would have redirected to
# `http://localhost:8087/?oidc_code=…` instead — the
# `landed_at` regex below catches that regardless.
# ─────────────────────────────────────────────────────────────
GET {{idp_url}}
[Options]
location: true
location-trusted: true
HTTP 200
[Captures]
landed_at: url
oidc_code: url regex "oidc_code=([a-f0-9]+)"
[Asserts]
# The d1bbe8ba regression guard. The exact contract the SvelteKit
# SPA depends on — `/login`, not `/`.
variable "landed_at" matches "^http://localhost:8087/login\\?oidc_code=[a-f0-9]+$"
# ─────────────────────────────────────────────────────────────
# Step 5 — Swap the one-time code for a session.
# Response sets the HttpOnly auth cookies + the
# double-submit CSRF cookie the SPA reads to populate
# X-CSRF-Token on subsequent mutating requests.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/oidc/exchange
Content-Type: application/json
{ "code": "{{oidc_code}}" }
HTTP 200
[Captures]
oidc_session_user: jsonpath "$.user.full.user.username"
# Snapshotted so Step 7's refresh can prove the tokens rotated
# rather than being re-issued unchanged. The refresh handler in
# auth_handler.rs always rotates all three cookies (access JWT,
# refresh UUID, CSRF UUID); a regression that silently keeps the
# old refresh token would let a leaked refresh credential live
# forever — exactly the kind of issue token-family rotation exists
# to prevent.
initial_access_token: jsonpath "$.access_token"
initial_refresh_token: jsonpath "$.refresh_token"
initial_csrf_token: cookie "oxicloud_csrf"
[Asserts]
jsonpath "$.user.full.user.username" == "oidc_user"
jsonpath "$.user.full.user.email" == "oidc@example.com"
jsonpath "$.access_token" isString
# Multiple Set-Cookie headers come back as a list of values, so
# `contains` only matches whole-element strings. Each cookie shows up
# as its own list entry; we use `cookie "<name>"` (Hurl's dedicated
# helper) which finds the cookie by name across all Set-Cookie headers.
cookie "oxicloud_access" exists
cookie "oxicloud_refresh" exists
cookie "oxicloud_csrf" exists
cookie "oxicloud_access[HttpOnly]" exists
cookie "oxicloud_refresh[HttpOnly]" exists
# ─────────────────────────────────────────────────────────────
# Step 6 — Same-jar follow-up GET proves the cookie session
# actually authenticates. Hurl reuses the cookie jar
# across requests in one file by default, so the
# Set-Cookie from Step 5 carries forward.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/auth/me
HTTP 200
[Captures]
# Stash the user id for the re-login check in Step 10 below — a
# second OIDC flow with the same `sub` must resolve back to this
# exact user, not silently create a duplicate.
oidc_user_id: jsonpath "$.full.user.id"
[Asserts]
jsonpath "$.full.user.username" == "oidc_user"
jsonpath "$.full.user.email" == "oidc@example.com"
# Post the federation-identity rename (docs/plan/ocm.md § Schema
# rename) UserDto exposes federation_kind + federation_issuer as
# separate nullable fields. Local users have both null; OIDC users
# get kind="oidc" and issuer=<the id_token iss claim URL>. For
# the fake IdP (tests/oidc/fake_idp/server.js) that URL is the
# issuer published in its discovery document, which matches
# `oidc_issuer` from test.env.
jsonpath "$.full.federation_kind" == "oidc"
jsonpath "$.full.federation_issuer" == "{{oidc_issuer}}"
# Full claim round-trip — the fake IdP (tests/oidc/fake_idp/server.js)
# pins these values and OxiCloud must persist each one verbatim during
# JIT provisioning (see auth_application_service.rs around line 2257).
# A regression that drops, swaps, or truncates a claim trips here.
# Note the field name flip on the API side: OIDC `picture` becomes
# UserDto.image (a URL or data URI).
jsonpath "$.full.user.given_name" == "OIDC"
jsonpath "$.full.user.family_name" == "Test"
jsonpath "$.full.user.image" == "https://example.com/oidc-test-user.png"
# Group-to-role mapping. server-with-oidc.env sets
# OXICLOUD_OIDC_ADMIN_GROUPS=admin-users; the fake IdP's claims include
# `groups: ["admin-users"]`. The JIT path intersects the claim against
# the env and promotes the new user from `user` to `admin`. A
# regression here would silently strip (or wrongly grant) admin rights
# for every SSO deployment that uses group-based role mapping.
jsonpath "$.full.user.role" == "admin"
# ─────────────────────────────────────────────────────────────
# Step 7 — Refresh-token rotation.
#
# POST /api/auth/refresh reads the refresh token from
# the HttpOnly oxicloud_refresh cookie (the browser flow
# OxiCloud's SPA uses; the JSON body shape is only a
# backwards-compat path for non-browser clients) and
# re-issues all three cookies. Token-family rotation:
# the prior refresh token is invalidated server-side
# and a reuse attempt would be caught as a theft signal.
#
# CSRF middleware fires here because we have a cookie
# session — we pass the captured oxicloud_csrf value as
# the double-submit X-CSRF-Token header, matching what
# the SvelteKit SPA does via getCsrfHeaders().
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/refresh
X-CSRF-Token: {{initial_csrf_token}}
Content-Type: application/json
{}
HTTP 200
[Captures]
refreshed_access_token: jsonpath "$.access_token"
refreshed_refresh_token: jsonpath "$.refresh_token"
# Also capture the ROTATED csrf token so Step 8c (logout POST) can
# thread it into `X-CSRF-Token`. The refresh handler rotates all
# three cookies including csrf — using `initial_csrf_token` here
# would 403 at the CSRF middleware because it no longer matches
# the (freshly-rotated) `oxicloud_csrf` cookie on the browser.
refreshed_csrf_token: cookie "oxicloud_csrf"
[Asserts]
jsonpath "$.user.full.user.username" == "oidc_user"
jsonpath "$.access_token" isString
jsonpath "$.refresh_token" isString
# All three cookies must rotate. If any value were re-used, a
# regression in cookie_auth::append_auth_cookies (or in the
# RefreshToken use case) would silently leave the old credential
# live — exactly the kind of bug that motivates rotation.
variable "refreshed_access_token" != "{{initial_access_token}}"
variable "refreshed_refresh_token" != "{{initial_refresh_token}}"
cookie "oxicloud_access" exists
cookie "oxicloud_refresh" exists
cookie "oxicloud_csrf" exists
# ─────────────────────────────────────────────────────────────
# Step 8 — The refreshed cookies authenticate too. Belt-and-braces:
# rotation is only useful if the new tokens actually work.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.full.user.username" == "oidc_user"
# ─────────────────────────────────────────────────────────────
# Step 8b — Admin sessions panel exposes `origin = "oidc"` and
# NEVER leaks the raw IdP `sid`. The oidc_user was JIT-
# provisioned admin by its `groups` claim (see Step 6),
# so /api/admin/sessions is reachable with the current
# cookies. Also asserts `access_token_expiry_secs` is
# served so the SPA can render the revoke-lag notice.
#
# Regressions this pins:
# * origin column drops back to "unknown" on refresh
# (would break the panel filter for OIDC-only view);
# * DTO reintroduces `oidc_sid` (the anti-leak fix from
# dto_never_leaks_oidc_sid in session_dto.rs);
# * handler stops publishing the TTL (would silently
# kill the revoke-lag notice on the admin page).
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/admin/sessions?limit=25
HTTP 200
[Asserts]
# At least our own OIDC session is here. `body contains` rather than a
# jsonpath collection predicate because Hurl unwraps single-element
# `$.sessions[*].origin` results to a scalar, and the deprecated
# `includes` didn't handle that consistently either. Distinctive-enough
# substring — a false positive would need `"origin":"oidc"` to appear
# elsewhere in the SessionSummaryDto shape, which by construction it
# doesn't.
body contains "\"origin\":\"oidc\""
# TTL surfaces for the SPA's revoke-lag notice.
jsonpath "$.access_token_expiry_secs" isInteger
jsonpath "$.access_token_expiry_secs" > 0
# The raw IdP sid must never appear in the wire shape — not the key,
# not even a prefix (see dto_never_leaks_oidc_sid unit test).
body not contains "oidc_sid"
# id_token is a JWT — three base64-url parts joined by `.`. A leak
# would materialise as a long dotted token in the response body.
# The fake IdP's issuer URL is a durable substring of every id_token
# claim payload, so absence of that URL is a cheap "no id_token
# leaked" proof.
body not contains "{{oidc_issuer}}"
# ─────────────────────────────────────────────────────────────
# Step 8c — RP-initiated logout. Server MUST return
# `post_logout_url` on the /api/auth/logout response
# because the session's `oidc_id_token` is populated
# (both at OIDC-callback INSERT time AND — critically
# — after the Step 7 refresh which used to drop it).
# This is the regression that made "logout after OIDC
# login → refresh → logout" go through the local-only
# path, leaving the IdP session live.
#
# The post_logout_url is the IdP's `end_session_endpoint`
# carrying `id_token_hint` + `post_logout_redirect_uri`.
# We assert its shape rather than following it (Step 9
# does a fresh OIDC login anyway).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/logout
X-CSRF-Token: {{refreshed_csrf_token}}
Content-Type: application/json
{}
HTTP 200
[Asserts]
jsonpath "$.post_logout_url" exists
jsonpath "$.post_logout_url" matches "id_token_hint="
jsonpath "$.post_logout_url" matches "post_logout_redirect_uri="
# Cookie-clearing is covered by other tests
# (`auth_session_lifecycle.hurl`); the point of THIS step is the
# `post_logout_url` shape — proving the id_token carried across the
# Step 7 refresh, which the bug we fixed used to drop.
# ─────────────────────────────────────────────────────────────
# Step 9 — Existing-user re-login. A second pass through the same
# OIDC `sub` MUST resolve back to the SAME local user
# (`oidc_user_id` captured in Step 6) — silently creating
# a duplicate account on every login would be the
# regression. Exercises the existing-user branch in
# auth_application_service.rs around line 2157, distinct
# from the JIT-provisioning branch the earlier steps hit.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/auth/oidc/authorize
[Options]
location: false
HTTP 307
[Captures]
relogin_idp_url: header "Location"
GET {{relogin_idp_url}}
[Options]
location: true
location-trusted: true
# Same contract as Step 4 — the SPA serves /login with 200 (the
# runner ensures static-dist/ is built before the server starts).
HTTP 200
[Captures]
relogin_oidc_code: url regex "oidc_code=([a-f0-9]+)"
[Asserts]
variable "landed_at" matches "^http://localhost:8087/login\\?oidc_code=[a-f0-9]+$"
POST {{base_url}}/api/auth/oidc/exchange
Content-Type: application/json
{ "code": "{{relogin_oidc_code}}" }
HTTP 200
[Asserts]
# Same local id — proves the existing-user resolver matched on `sub`
# (or `oidc_provider + oidc_subject`) instead of minting a new row.
jsonpath "$.user.full.user.id" == "{{oidc_user_id}}"
jsonpath "$.user.full.user.username" == "oidc_user"
# Role from the prior JIT-provisioned admin survives the re-login.
# Two regressions this catches: (a) the existing-user branch wiping
# the role to a default `user`; (b) the existing-user branch
# re-evaluating groups but missing the admin-group claim (the fake
# IdP still emits `groups: ["admin-users"]`, OXICLOUD_OIDC_ADMIN_GROUPS
# still resolves to "admin"). Either way, the role should remain
# `admin` — otherwise we have a silent admin demotion on every login.
jsonpath "$.user.full.user.role" == "admin"
# ─────────────────────────────────────────────────────────────
# Step 10 — Operator-override: with `OXICLOUD_REQUIRE_VERIFIED_EMAIL=false`
# in `tests/common/server-with-oidc.env` (matching the
# default test posture), an OIDC callback whose
# `email_verified` claim is `false` MUST be accepted.
# This is the "I trust my IdP end-to-end" posture — the
# operator has told the server not to gate on the
# verification signal.
#
# This test used to be the anti-takeover check (assert
# rejection) BEFORE commit 1801150a moved the OIDC email
# check under the operator flag. Post-1801150a it flipped
# to a positive test of the operator-override branch.
#
# The rejection branch (`flag=true` + IdP `Some(false)` or
# `None`) is proved OUT-OF-SUITE — this Hurl file runs one
# server with one env config; asserting both branches
# needs either a second `hurl` invocation with the flag
# flipped, or a Rust unit test that exercises
# `handle_oidc_callback_with_id_claims` directly. The
# audit-log discriminator (`oidc.email_unverified_accepted`
# with reason `idp_asserts_unverified_flag_off`) is the
# operator-visible signal on the accept path here.
# ─────────────────────────────────────────────────────────────
POST http://localhost:1080/control/email-verified/false
HTTP 200
GET {{base_url}}/api/auth/oidc/authorize
[Options]
location: false
HTTP 307
[Captures]
unverified_idp_url: header "Location"
GET {{unverified_idp_url}}
[Options]
location: true
location-trusted: true
# Positive assertion of the operator-override: the redirect chain
# lands on `/login?oidc_code=` (successful OIDC callback), and
# the status is 2xx or 3xx (never 4xx/5xx). A regression that
# re-added an unconditional rejection would land on the login
# error page instead — either the URL negation or the status
# ceiling catches it.
HTTP *
[Asserts]
status < 400
url matches "^http://localhost:8087/login\\?oidc_code="
# Reset the IdP so this test doesn't poison anything that runs
# after it (defensive — there's nothing after right now, but a
# future test would silently fail with unexpected accept-paths
# if we forgot this).
POST http://localhost:1080/control/email-verified/true
HTTP 200
# ─────────────────────────────────────────────────────────────
# Step 11 — Replay protection: the one-time code is rejected on a
# second attempt. Defense-in-depth check.
#
# Hurl 4.x has no per-request cookie-jar clear, so this
# request still carries the session cookies set in Step 5.
# That means CSRF middleware rejects the unauthenticated
# (no X-CSRF-Token header) POST with 403 BEFORE the OIDC
# single-use-code check runs. Both are valid replay
# defenses; in a real attack the attacker has the code but
# not the session cookie, in which case the rejection
# would come from the OIDC layer as 401.
#
# The single-use-code path itself is covered by unit
# tests in auth_application_service.rs (the
# completed_oidc_logins moka cache + remove-on-use).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/oidc/exchange
Content-Type: application/json
{ "code": "{{oidc_code}}" }
HTTP 403
# ═════════════════════════════════════════════════════════════
# Step 12 — Nextcloud Login Flow v2 via OIDC — MULTI-DRIVE PATH
# ═════════════════════════════════════════════════════════════
# Regression coverage for the customer-reported bug where OIDC
# users were never shown the drive picker: the OIDC callback in
# `auth_handler.rs::oidc_callback` used to mint the app password
# inline and complete the flow with the bare username. Customers
# with ≥ 2 drives had no way to pick a non-home drive under SSO,
# and the deprecated `nc://` redirect broke NC clients that had
# already picked up credentials via the poll backchannel
# (`Impossible de valider la requête`).
#
# The fix routes the OIDC callback through the shared
# `handle_oidc_login_completion` in `login_v2_handler.rs`, which
# lists drives, renders the picker template on ≥ 2, and calls
# `complete_flow(...)` only after the picker submit. Same
# multi-drive fork the password path uses.
#
# What this section exercises (post-fix expected behaviour):
#
# A. Local admin logs in with password to get a JWT for
# administrative operations (creating the shared drive
# below — the OIDC user has no local password).
# B. Admin creates a NEW shared drive owned by `oidc_user`. That
# makes the OIDC user's drive count = 2 (their JIT-provisioned
# personal + this shared), which is the multi-drive branch
# trigger.
# C. NC LFv2 initiate — anonymous, returns { poll_token, login_url }.
# login_url embeds the flow_token that identifies this flow.
# D. Pre-completion poll — MUST return 404. Baseline regression:
# if a future change ever accidentally auto-completes the flow
# before the picker submit, this catches it.
# E. Kick off the NC OIDC branch → GET /login/v2/flow/{token}/oidc.
# Server sets `nc_flow_token` on the OIDC state and 307s to
# the IdP.
# F. Follow the entire IdP → callback chain with `location: true`.
# Post-fix, the callback returns the PICKER HTML (200), NOT a
# `nc://` redirect. Pre-fix it would have 307'd to nc://.
# G. Poll AGAIN — still 404 (picker not yet submitted). Proves
# the callback did NOT call `login_flow.complete(...)` — the
# exact regression the fix prevents.
# H. Submit the picker with the shared-drive folder id.
# I. Post-picker poll — 200 with `loginName` matching
# `oidc_user~<uuid>` (composite marker → chroot-bound app
# password). This is the load-bearing assertion: pre-fix
# loginName was the bare `oidc_user`.
# ═════════════════════════════════════════════════════════════
# ─────────────────────────────────────────────────────────────
# Step 12A — Local admin password login.
# The OIDC-provisioned `oidc_user` (auto-promoted to
# admin via the group claim) has NO local password;
# only local admin (created in Step 1) can authenticate
# with `username/password`. Use JWT (Bearer) instead of
# cookies so we skip CSRF ceremony for the drive-create
# call below.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "username": "{{username}}", "password": "{{password}}" }
HTTP 200
[Captures]
admin_token: jsonpath "$.access_token"
# ─────────────────────────────────────────────────────────────
# Step 12B — Admin creates a shared drive owned by `oidc_user`.
# After this, list_folders_with_perms(oidc_user) returns
# 2 rows (JIT-provisioned personal + this shared). The
# picker template ties the composite `~<folder_id>`
# marker to the FOLDER id (root of the drive), not the
# drive id — that's the identifier the picker's radio
# buttons carry and what `handle_drive_pick` looks up.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/drives
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"kind": "shared",
"name": "oidc-user-shared",
"owner": { "type": "user", "id": "{{oidc_user_id}}" }
}
HTTP 201
[Captures]
fixture_drive_id: jsonpath "$.id"
# ─────────────────────────────────────────────────────────────
# Step 12C — NC client initiates LFv2. Public endpoint, no auth.
# Response carries the flow token (embedded in the
# login URL) and the poll token (used by the NC
# client's backchannel).
#
# Note: the initiate endpoint lives at
# `/index.php/login/v2` (nc_routes.rs:50) — the bare
# `/login/v2` variant only exists for the poll
# surface, not for initiate. NC clients build the URL
# from the `/index.php` convention.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/index.php/login/v2
HTTP 200
[Captures]
nc_poll_token: jsonpath "$.poll.token"
# Regex-extract the flow_token from the login URL. Shape is
# `http://localhost:8087/login/v2/flow/<hex>`. The trailing hex is
# what /login/v2/flow/{token}/... routes bind on.
nc_flow_token: jsonpath "$.login" regex "/login/v2/flow/([a-f0-9]+)"
# ─────────────────────────────────────────────────────────────
# Step 12D — Baseline poll. No user has authenticated yet, so the
# flow has no `completed` result. MUST be 404.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/login/v2/poll
Content-Type: application/x-www-form-urlencoded
`token={{nc_poll_token}}`
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 12E — Kick off the NC OIDC branch. Server prepares an OIDC
# authorize with the NC flow token attached to state
# (auth_application_service::prepare_oidc_authorize_for_nextcloud)
# and 307s to the IdP. `location: false` so we can
# capture the exact IdP URL for the manual chain follow
# below.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/login/v2/flow/{{nc_flow_token}}/oidc
[Options]
location: false
HTTP 307
[Captures]
nc_idp_url: header "Location"
[Asserts]
# The IdP URL must carry `state` (which encodes nc_flow_token
# server-side) and the PKCE challenge — same shape as the SPA
# path in Step 3, just prepared through a different code path.
header "Location" matches "^{{oidc_authorize_endpoint}}\\?"
header "Location" contains "state="
header "Location" contains "code_challenge_method=S256"
# ─────────────────────────────────────────────────────────────
# Step 12F — Follow the full IdP → callback chain. The fake IdP
# auto-approves (the earlier flow left a session cookie
# for `oidc-test-user` — this exercises the realistic
# "user already signed into their IdP" flow), the IdP
# 302s back to /api/auth/oidc/callback?code=…&state=…,
# and the callback routes into the NextcloudLogin arm.
#
# POST-FIX EXPECTED: the callback returns the drive
# picker template (HTTP 200, HTML body) because
# `handle_oidc_login_completion` saw ≥ 2 drives.
# PRE-FIX would have been a 307 to
# `nc://login/server:…&user:oidc_user&password:…` —
# the very redirect the fix drops.
# ─────────────────────────────────────────────────────────────
GET {{nc_idp_url}}
[Options]
location: true
location-trusted: true
HTTP 200
[Captures]
# The picker HTML has one radio input per drive. Two drives here,
# so two `value=` attributes on `<input name="drive">`. Home is
# first (loop.first in the template); the shared drive is second.
# Local-name XPath so the DAV/HTML namespace doesn't matter.
shared_folder_id: xpath "string((//input[@name='drive']/@value)[2])"
[Asserts]
# Picker markers — proves this is the picker template and not
# some other 200 response. Uses `contains` on distinctive strings
# from the template.
body contains "Choose a drive"
body contains "name=\"drive\""
# The picker's form MUST post to /login/v2/flow/{nc_flow_token}/drive.
# A regression that generated a wrong action would ship users
# into an unrelated flow and this pins the wire target.
body contains "action=\"/login/v2/flow/{{nc_flow_token}}/drive\""
# Load-bearing regression guard for the exact bug this fix
# closes: pre-fix, the OIDC callback body would have been empty
# and the Location header would have carried the nc:// URL. Now
# there's no nc:// anywhere in the response.
body not contains "nc://login"
# ─────────────────────────────────────────────────────────────
# Step 12G — Poll AGAIN. Still 404 — the picker has not been
# submitted, so `complete_flow` hasn't run and the
# flow has no `completed` result.
#
# Pre-fix regression this catches: the OIDC callback
# used to call `login_flow.complete(...)` inline before
# the picker step. If a future change ever reintroduces
# that shortcut, this 404 assertion flips to 200 and
# the CI red flag lights up.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/login/v2/poll
Content-Type: application/x-www-form-urlencoded
`token={{nc_poll_token}}`
HTTP 404
# ─────────────────────────────────────────────────────────────
# Step 12H — Submit the picker choice. Payload is form-encoded
# (the picker's <form> is a POST HTML form). The
# `drive` field is the folder UUID captured from the
# picker's radio buttons.
#
# handle_drive_pick reads `pending_user_id` from the
# flow (stashed by `resolve_drive_or_complete` when we
# rendered the picker), validates the folder is
# visible, resolves home vs non-home, and calls
# complete_flow(..., Some(folder_id)).
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/login/v2/flow/{{nc_flow_token}}/drive
Content-Type: application/x-www-form-urlencoded
`drive={{shared_folder_id}}`
# The completion path redirects the browser to the friendly
# success page. NOT a nc:// URL — the poll below is what
# delivers credentials.
HTTP *
[Asserts]
status >= 300
status < 400
header "Location" == "/nextcloud/success"
# ─────────────────────────────────────────────────────────────
# Step 12I — Post-picker poll. NOW the credentials are ready.
#
# The composite `oidc_user~<folder_id>` login name is
# the whole point of this test — it proves the OIDC
# path honoured the drive pick and produced a
# chroot-bound app-password credential. Pre-fix,
# loginName here was the bare `oidc_user`.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/login/v2/poll
Content-Type: application/x-www-form-urlencoded
`token={{nc_poll_token}}`
HTTP 200
[Asserts]
# Loose host match — the server derives base_url from its bind
# config (which lands on `127.0.0.1` when neither
# OXICLOUD_BASE_URL nor the host env is set), while test.env
# uses `localhost` for its own variable. Both resolve to the
# same address for a client; pin the port, not the host.
jsonpath "$.server" matches "^https?://[^/]+:8087$"
jsonpath "$.appPassword" isString
# Composite marker present — this is the load-bearing regression
# assertion. A pre-fix run would show `"oidc_user"` with no `~`.
jsonpath "$.loginName" matches "^oidc_user~[0-9a-f-]{36}$"
# Belt-and-braces: assert the folder id echoed back matches the
# picker's radio value we submitted (no accidental drive/folder
# swap in `handle_drive_pick`).
jsonpath "$.loginName" contains "{{shared_folder_id}}"
# ─────────────────────────────────────────────────────────────
# Step 12J — Poll again — MUST 404. The completed result is
# single-use (poll() removes it from the map). A
# regression that failed to remove would leak
# credentials to any subsequent poll with the same
# token.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/login/v2/poll
Content-Type: application/x-www-form-urlencoded
`token={{nc_poll_token}}`
HTTP 404
# ─────────────────────────────────────────────────────────────
# No teardown for the fixture drive.
#
# The drive was created with `oidc_user` as SOLE owner (Step
# 12B). Local `admin` created it via the admin-only
# `POST /api/drives` but isn't a grant-holder — deleting the
# drive requires `manage` on the drive resource, which admin's
# Bearer token doesn't carry. Cleanup would have to happen as
# `oidc_user`, but `oidc_user` has no local password and
# running a second OIDC dance mid-file would pollute the
# session cookies the earlier steps depend on.
#
# Safe to skip: `tests/oidc/run.sh` spawns a fresh DB per
# invocation (`bash "$COMMON/spawn-db.sh"`), so nothing
# downstream sees the leftover. The API-suite sibling
# (`tests/api/nc_login_flow_v2_drive_picker.hurl`) DOES clean
# up because that version creates the drive owned by admin —
# and its runner IS multi-file. See
# `feedback_hurl_teardown_shared_db` for the general rule.
# ─────────────────────────────────────────────────────────────
# =============================================================
# Steps 13* — OIDC Back-Channel Logout 1.0
# =============================================================
# Proves the /api/auth/oidc/backchannel-logout endpoint accepts a
# valid IdP-signed logout_token and evicts the corresponding
# OxiCloud session — the shared-computer / single-sign-out fix
# that RP-initiated logout alone doesn't cover (RPI needs the
# browser; BCL is server-to-server and works even when the user's
# device is offline).
#
# The fake IdP mints and posts the logout_token itself via its
# `/control/backchannel-logout` endpoint (server.js handleControl):
# it signs with the same RS256 keypair whose public half sits at
# /jwks.json, so OxiCloud's validator (identical code path to
# id_token verification) accepts the signature. The Node fetch()
# then POSTs the token as application/x-www-form-urlencoded to
# OxiCloud, matching BCL §2.5.
#
# We deliberately test the sub-only path here (no `sid`) so the
# service exercises revoke_user_sessions_by_oidc_subject (the
# fallback branch used when the IdP doesn't emit `sid`). Sid-based
# per-device revocation shares the same validator + audit shape;
# a unit test in session_pg_repository covers that branch.
# =============================================================
# ─────────────────────────────────────────────────────────────
# Step 13a — Freshly log in as `oidc_user`. Cookies from Step 9's
# re-login could still be usable, but the Nextcloud flow
# in Steps 12* has interleaved admin login/logout since
# then and the safest thing to prove BCL revoked
# "something live" is to start with a session we JUST
# minted. The [Options] block clears cookies so the
# `Set-Cookie` from the exchange below is what we assert
# on.
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/auth/oidc/authorize
[Options]
location: false
HTTP 307
[Captures]
bcl_idp_url: header "Location"
GET {{bcl_idp_url}}
[Options]
location: true
location-trusted: true
HTTP 200
[Captures]
bcl_oidc_code: url regex "oidc_code=([a-f0-9]+)"
POST {{base_url}}/api/auth/oidc/exchange
Content-Type: application/json
{ "code": "{{bcl_oidc_code}}" }
HTTP 200
[Asserts]
jsonpath "$.user.full.user.username" == "oidc_user"
# ─────────────────────────────────────────────────────────────
# Step 13b — Confirm the cookie session is live before we knock
# it down. If /me fails here the eviction assertion in
# 13d becomes meaningless (couldn't tell "was live,
# got revoked" from "was never live").
# ─────────────────────────────────────────────────────────────
GET {{base_url}}/api/auth/me
HTTP 200
[Asserts]
jsonpath "$.full.user.username" == "oidc_user"
# ─────────────────────────────────────────────────────────────
# Step 13c — IdP-driven logout. The fake IdP's control endpoint
# mints a spec-compliant logout_token (RS256-signed,
# correct iss/aud, events claim, sub, jti, fresh iat)
# and POSTs it to OxiCloud as
# application/x-www-form-urlencoded per BCL §2.5.
# OxiCloud MUST accept it and revoke every session
# belonging to the OIDC subject — a 200 response with
# oxicloud_status=200 in the forwarding echo proves it.
# ─────────────────────────────────────────────────────────────
POST {{oidc_issuer}}/control/backchannel-logout
Content-Type: application/json
{}
HTTP 200
[Asserts]
jsonpath "$.oxicloud_status" == 200
# ─────────────────────────────────────────────────────────────
# Step 13d — Refresh MUST fail. This is the load-bearing "BCL
# actually kicked the user" proof.
#
# Note on why we assert on /refresh and NOT /api/auth/me:
# OxiCloud access tokens are stateless JWTs — the auth
# middleware validates signature + expiry in-memory and
# does NOT consult `sessions.revoked` on every request.
# BCL flipped `sessions.revoked=true` (see audit log
# `oidc.backchannel_logout_by_sub` — 3 sessions revoked)
# which kills the refresh path immediately, but the
# still-valid in-memory access token would let /me
# return 200 until its natural expiry (~1 h default).
# That is the standard JWT trade-off: BCL fully evicts
# within one access-token TTL. The refresh 401 below is
# what proves the eviction landed; once the access
# token expires the user can't mint a new one.
# ─────────────────────────────────────────────────────────────
POST {{base_url}}/api/auth/refresh
Content-Type: application/json
{}
# 403 (not 401): the JWT signature validates, but the session is
# revoked — that's an "access denied on a valid credential" outcome.
# The refresh handler maps DomainError::AccessDenied to StatusCode::
# FORBIDDEN. Also fires TokenReused audit + revokes the whole session
# family, which is the reuse-detection path (correctly identified: a
# call with a revoked refresh token is indistinguishable from theft
# from the server's viewpoint).
HTTP 403
# ─────────────────────────────────────────────────────────────
# Step 13f — Replay guard. Firing the exact same logout_token
# twice in the freshness window must be a no-op —
# OxiCloud's app service dedupes by `jti` (see
# auth_application_service::backchannel_logout). The
# IdP still returns 200 for the second call because
# the control endpoint mints a NEW jti each time
# (Math.random() salt), so this is really testing
# "sending the same content twice is safe": second
# call would find no live sessions and revoke 0 rows.
# Either way the assertion is the same: HTTP 200 from
# the control endpoint, oxicloud_status 200.
# ─────────────────────────────────────────────────────────────
POST {{oidc_issuer}}/control/backchannel-logout
Content-Type: application/json
{}
HTTP 200
[Asserts]
jsonpath "$.oxicloud_status" == 200