From 05ef55a8e0107dbadb59fa7afee5248fdfa7dfc1 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 13 Jul 2026 18:29:30 +0200 Subject: [PATCH] fix(nc): login OIDC + drive picker ensure OIDC is supported during nextcloud login flow is: 1. nextcloud 2. oxicloud login ( direct pass or OIDC according config) 3. drive picker (if user has multiple drive) 4. success page + backchannel login to nextcloud --- .../services/nextcloud_login_flow_service.rs | 36 +++ src/interfaces/api/handlers/auth_handler.rs | 67 ++-- src/interfaces/nextcloud/login_v2_handler.rs | 162 +++++++++- tests/api/nc_login_flow_v2_drive_picker.hurl | 245 ++++++++++++++ tests/api/run.sh | 1 + tests/oidc/oidc.hurl | 303 ++++++++++++++++++ 6 files changed, 761 insertions(+), 53 deletions(-) create mode 100644 tests/api/nc_login_flow_v2_drive_picker.hurl diff --git a/src/application/services/nextcloud_login_flow_service.rs b/src/application/services/nextcloud_login_flow_service.rs index 147c1db2..7e608b34 100644 --- a/src/application/services/nextcloud_login_flow_service.rs +++ b/src/application/services/nextcloud_login_flow_service.rs @@ -38,6 +38,12 @@ struct PendingFlow { /// if the flow token leaks. `None` for single-drive accounts (legacy /// path goes straight to `completed`). pending_user_id: Option, + /// App-password label to persist when this multi-drive flow finally + /// completes. Stashed by `resolve_drive_or_complete` (login_v2_handler) + /// alongside `pending_user_id` so `handle_drive_pick` can preserve + /// provenance (`"Nextcloud"` vs `"Nextcloud (OIDC)"`) across the + /// picker round-trip. Consumed by `take_pending_app_password_label`. + pending_app_password_label: Option, completed: Option, } @@ -89,6 +95,7 @@ impl NextcloudLoginFlowService { created_at: Instant::now(), poll_token: poll_token.clone(), pending_user_id: None, + pending_app_password_label: None, completed: None, }, ); @@ -143,6 +150,35 @@ impl NextcloudLoginFlowService { .and_then(|pending| pending.pending_user_id.take()) } + /// Stash the app-password label to use when the flow eventually + /// completes via `handle_drive_pick`. Called alongside + /// `mark_awaiting_drive` so the multi-drive round-trip preserves + /// the provenance string passed in at the auth step + /// (`"Nextcloud"` for password login, `"Nextcloud (OIDC)"` for OIDC). + /// Silently no-ops when the flow token is unknown or expired — + /// the earlier `mark_awaiting_drive` on the same token is the + /// authoritative "exists?" signal so we don't need to log again. + pub fn set_pending_app_password_label(&self, flow_token: &str, label: &str) { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + prune_expired(&mut state, self.ttl); + if let Some(pending) = state.flows.get_mut(flow_token) { + pending.pending_app_password_label = Some(label.to_string()); + } + } + + /// Consume the stashed app-password label (single-use). Returns + /// `None` when the flow was never marked, was password-shortcut + /// (single-drive), or the token is unknown / expired — the caller + /// falls back to a sensible default in that case. + pub fn take_pending_app_password_label(&self, flow_token: &str) -> Option { + let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner()); + prune_expired(&mut state, self.ttl); + state + .flows + .get_mut(flow_token) + .and_then(|pending| pending.pending_app_password_label.take()) + } + pub fn complete( &self, flow_token: &str, diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index b28dab8b..6ef8a8e4 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -944,53 +944,38 @@ pub async fn oidc_callback( let frontend_url = config.frontend_url.trim_end_matches('/'); let redirect_url = format!("{}/login?oidc_code={}", frontend_url, exchange_code); tracing::info!("OIDC login successful, redirecting with exchange code"); - Ok(Redirect::temporary(&redirect_url)) + Ok(Redirect::temporary(&redirect_url).into_response()) } OidcCallbackResult::NextcloudLogin { nc_flow_token, user_id, username, } => { - // Nextcloud Login Flow v2, create app password and complete flow - let nextcloud = state - .nextcloud - .as_ref() - .ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?; - - let (_id, app_password) = nextcloud - .app_passwords - .create_nc(user_id, "Nextcloud (OIDC)") - .await - .map_err(|e| { - tracing::error!(error = %e, user = %username, "OIDC+NC: failed to create app password"); - AppError::from(e) - })?; - - let base_url = state.core.config.base_url(); - let completed = - nextcloud - .login_flow - .complete(&nc_flow_token, &username, &base_url, &app_password); - - if completed { - tracing::info!( - user = %username, - "OIDC login completed Nextcloud Login Flow v2 successfully" - ); - let nc_url = format!( - "nc://login/server:{}&user:{}&password:{}", - base_url, username, app_password - ); - Ok(Redirect::temporary(&nc_url)) - } else { - tracing::error!( - user = %username, - "OIDC+NC: login flow token expired or not found" - ); - Ok(Redirect::temporary( - "/nextcloud-error.html?type=session-expired", - )) - } + // Hand the browser off to the shared LFv2 completion path. + // That path lists the user's drives, renders the picker + // when there are ≥ 2, and only completes the flow (via the + // poll backchannel) when the user has picked. Prior to + // this refactor the OIDC arm minted the app password + // inline and completed with the bare username — customers + // with multiple drives had no way to pick a non-home + // drive under SSO, and the deprecated `nc://` redirect + // caused the "Impossible de valider la requête" dialog on + // NC clients that had already picked up credentials via + // the poll endpoint. Routing through the shared helper + // fixes both. + tracing::info!( + user = %username, + "OIDC callback → NC Login Flow v2: handing off to picker/completion path" + ); + Ok( + crate::interfaces::nextcloud::login_v2_handler::handle_oidc_login_completion( + &state, + &nc_flow_token, + user_id, + &username, + ) + .await, + ) } } } diff --git a/src/interfaces/nextcloud/login_v2_handler.rs b/src/interfaces/nextcloud/login_v2_handler.rs index 0d48a172..bd3986ea 100644 --- a/src/interfaces/nextcloud/login_v2_handler.rs +++ b/src/interfaces/nextcloud/login_v2_handler.rs @@ -196,7 +196,7 @@ pub async fn handle_login_submit( // the common case stays one click. With ≥2 drives we pause the // flow, stash the user_id, and render the picker — drive selection // resumes the flow via `handle_drive_pick`. - let mut drives = match state + let drives = match state .applications .folder_service .list_folders_with_perms(None, current_user.id) @@ -209,6 +209,37 @@ pub async fn handle_login_submit( } }; + resolve_drive_or_complete( + &state, + nextcloud, + &token, + ¤t_user, + "Nextcloud", + drives, + ) + .await +} + +/// Shared "multi-drive fork" step used by both the password path +/// (`handle_login_submit`) and the OIDC path +/// (`handle_oidc_login_completion`). +/// +/// - `label` is the app-password label persisted when `complete_flow` +/// creates the credential. Callers pass a channel-identifying string +/// (`"Nextcloud"` for password, `"Nextcloud (OIDC)"` for OIDC) so the +/// audit trail can distinguish provenance without another column. +/// - `drives` is the caller's pre-fetched drive list — the two callers +/// already list drives before invoking us (the password path lists +/// after `verify_credentials`, the OIDC path lists after +/// `get_user_by_id`), so re-listing here would be a wasted query. +async fn resolve_drive_or_complete( + state: &Arc, + nextcloud: &crate::common::di::NextcloudServices, + token: &str, + current_user: &CurrentUser, + label: &'static str, + mut drives: Vec, +) -> Response { if drives.len() >= 2 { // Reorder so home is at index 0. The picker template ties // both the default-checked radio and the "Home" badge to @@ -236,18 +267,105 @@ pub async fn handle_login_submit( if !nextcloud .login_flow - .mark_awaiting_drive(&token, current_user.id) + .mark_awaiting_drive(token, current_user.id) { - // Flow token vanished (TTL?) between password submit and - // here — extremely unlikely but treat the same as any + // Flow token vanished (TTL?) between auth and here — + // extremely unlikely but treat the same as any // session-expired case. return axum::response::Redirect::to("/nextcloud/error?type=session-expired") .into_response(); } - return render_drive_picker(&token, &drives); + // Persist the label so `handle_drive_pick` can pass the correct + // provenance string when it later calls `complete_flow`. Set + // even for the password path (where label == "Nextcloud") so + // the read-back is uniform. + nextcloud + .login_flow + .set_pending_app_password_label(token, label); + return render_drive_picker(token, &drives); } - complete_flow(&state, &nextcloud.login_flow, &token, ¤t_user, None).await + complete_flow( + state, + &nextcloud.login_flow, + token, + current_user, + None, + label, + ) + .await +} + +/// Complete an OIDC-authenticated NC Login Flow v2. +/// +/// Called from the OIDC callback (`auth_handler::oidc_callback`) when +/// the state carried an `nc_flow_token`. Mirrors the password path's +/// multi-drive fork exactly — the browser lands on the drive picker +/// when the user has ≥ 2 drives, or on the success page when they +/// have one. NC clients pick up credentials via the poll endpoint in +/// both cases (backchannel), so no `nc://` frontchannel URL is emitted. +/// +/// Prior to this refactor the OIDC callback minted the app password +/// inline and completed the flow with the bare username (no `~` +/// marker) — customers with multiple drives had no way to pick a +/// non-home drive under SSO. Routing through `resolve_drive_or_complete` +/// fixes that and dedups the branching logic against the password path. +pub async fn handle_oidc_login_completion( + state: &Arc, + token: &str, + user_id: uuid::Uuid, + username: &str, +) -> Response { + let nextcloud = match state.nextcloud.as_ref() { + Some(nc) => nc, + None => return StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + + let auth = match state.auth_service.as_ref() { + Some(a) => a, + None => return StatusCode::SERVICE_UNAVAILABLE.into_response(), + }; + + // Full user record — needed to build the `CurrentUser` the shared + // helpers expect (email + role in particular). We already have the + // username from the OIDC claims, but not the rest. + let user_dto = match auth.auth_application_service.get_user_by_id(user_id).await { + Ok(u) => u, + Err(e) => { + tracing::error!(error = %e, %user_id, user = %username, "OIDC+NC: failed to fetch user by id"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let current_user = CurrentUser { + id: user_id, + username: username.to_string(), + email: user_dto.email.clone(), + role: user_dto.role.clone(), + }; + + let drives = match state + .applications + .folder_service + .list_folders_with_perms(None, current_user.id) + .await + { + Ok(d) => d, + Err(e) => { + tracing::error!(error = %e, user = %current_user.username, "OIDC+NC: failed to list drives"); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + resolve_drive_or_complete( + state, + nextcloud, + token, + ¤t_user, + "Nextcloud (OIDC)", + drives, + ) + .await } /// Render the drive picker page. The form posts to @@ -299,17 +417,18 @@ async fn complete_flow( token: &str, user: &CurrentUser, drive_id: Option<&str>, + // Persisted verbatim as `auth.app_passwords.label`. Callers pass + // `"Nextcloud"` for the password path and `"Nextcloud (OIDC)"` for + // the OIDC path so operators can distinguish provenance from the + // audit log alone. + label: &str, ) -> Response { let nextcloud = match state.nextcloud.as_ref() { Some(nc) => nc, None => return StatusCode::SERVICE_UNAVAILABLE.into_response(), }; - let app_password = match nextcloud - .app_passwords - .create_nc(user.id, "Nextcloud") - .await - { + let app_password = match nextcloud.app_passwords.create_nc(user.id, label).await { Ok((_id, password)) => password, Err(e) => { tracing::error!(error = %e, user = %user.username, "Login Flow v2: failed to create app password"); @@ -492,7 +611,26 @@ pub async fn handle_drive_pick( Some(drive_id.as_str()) }; - complete_flow(&state, &nextcloud.login_flow, &token, &user, drive_marker).await + // Preserved label from the auth step ("Nextcloud" for password + // flow, "Nextcloud (OIDC)" for OIDC). Stashed by + // `resolve_drive_or_complete` when the picker was rendered; falls + // back to `"Nextcloud"` if the stash is missing (defensive — should + // never happen post-refactor, but keeps behaviour identical to the + // pre-refactor hardcoded label if some future path forgets to set). + let label = nextcloud + .login_flow + .take_pending_app_password_label(&token) + .unwrap_or_else(|| "Nextcloud".to_string()); + + complete_flow( + &state, + &nextcloud.login_flow, + &token, + &user, + drive_marker, + &label, + ) + .await } /// GET /login/v2/flow/{token}/oidc — Start an OIDC authorization flow that is diff --git a/tests/api/nc_login_flow_v2_drive_picker.hurl b/tests/api/nc_login_flow_v2_drive_picker.hurl new file mode 100644 index 00000000..a3e6bc94 --- /dev/null +++ b/tests/api/nc_login_flow_v2_drive_picker.hurl @@ -0,0 +1,245 @@ +# ============================================================= +# OxiCloud — NC Login Flow v2 — password path, multi-drive picker +# ============================================================= +# Sibling to `nc_login_flow_v2.hurl` (protocol init + poll edge +# cases). Where that file exercises the wire shape, THIS file +# exercises the multi-drive fork — the branch in +# `handle_login_submit` (login_v2_handler.rs) that renders the +# drive picker template when `list_folders_with_perms` returns +# ≥ 2 rows, then defers completion until the user picks. +# +# The OIDC equivalent lives at `tests/oidc/oidc.hurl` Step 12 and +# regression-pins the customer-reported bug where OIDC callback +# skipped the picker. This file pins the SAME multi-drive fork +# for the classic password path so a refactor of the shared +# `resolve_drive_or_complete` helper can't silently regress +# either channel. +# +# Coverage (end-to-end simulation of the NC desktop client's +# browser leg + backchannel): +# +# A. Admin password login → JWT for creating fixtures. +# B. Create a shared drive owned by admin so admin has +# exactly 2 drives (default personal + this shared). +# C. NC LFv2 initiate → capture flow_token + poll_token. +# D. Pre-completion poll → 404 baseline. +# E. Submit login form POST /login/v2/flow/{token} with +# user + password → picker HTML (200), NOT a redirect, +# because the user has ≥ 2 drives. +# F. Poll AGAIN → still 404. Proves the submit did NOT +# complete the flow — regression against a future change +# that accidentally shortcuts past the picker. +# G. Submit picker → POST /login/v2/flow/{token}/drive. +# H. Post-picker poll → 200 with composite `admin~` +# loginName. This is the load-bearing assertion: the +# picker choice must round-trip into the app-password's +# login name so NC uploads land on the chosen drive. +# I. Poll again → 404 (single-use consumed). +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step A — Admin password login for fixture creation. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +admin_token: jsonpath "$.access_token" +admin_user_id: jsonpath "$.user.id" + + +# ───────────────────────────────────────────────────────────── +# Step B — Create a shared drive owned by admin. The admin's +# default personal drive is already there; this second +# drive triggers the multi-drive picker branch on the +# next login (`list_folders_with_perms` returns 2 rows). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/drives +Authorization: Bearer {{admin_token}} +Content-Type: application/json +{ + "kind": "shared", + "name": "admin-picker-fixture", + "owner": { "type": "user", "id": "{{admin_user_id}}" } +} + +HTTP 201 +[Captures] +fixture_drive_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step C — NC LFv2 initiate. Public endpoint at +# `/index.php/login/v2` (the bare `/login/v2` alias +# only exists for the poll surface, not initiate — +# nc_routes.rs:50 vs :79). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/index.php/login/v2 + +HTTP 200 +[Captures] +poll_token: jsonpath "$.poll.token" +# Regex-extract flow_token from the login URL. +# Shape: http:///login/v2/flow/ +flow_token: jsonpath "$.login" regex "/login/v2/flow/([a-f0-9]+)" + + +# ───────────────────────────────────────────────────────────── +# Step D — Baseline poll. No submission yet, so the flow has +# no `completed` result. MUST 404. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/login/v2/poll +[FormParams] +token: {{poll_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step E — Submit the login form. `handle_login_submit` +# verifies credentials, calls list_folders_with_perms, +# sees ≥ 2 drives, and returns the picker template +# (HTTP 200 with HTML body). Pre-picker era this +# would have been a redirect straight to +# `/nextcloud/success`; that regression is what this +# assertion pins. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/login/v2/flow/{{flow_token}} +[FormParams] +user: {{username}} +password: {{password}} + +HTTP 200 +[Captures] +# Two drives in the picker — home is `loop.first` (index [1] in +# XPath 1-based), shared is [2]. We submit the shared value in +# Step G so the composite marker actually differs from the +# bare login name, exercising the ~ path (Step H asserts +# on it). Local-name XPath so DAV/HTML namespace scoping doesn't +# interfere. +shared_folder_id: xpath "string((//input[@name='drive']/@value)[2])" +[Asserts] +# Picker markers — distinguish the picker template from any +# other 200 response. +body contains "Choose a drive" +body contains "name=\"drive\"" +# Load-bearing regression: the picker's
must +# target the drive endpoint. A wrong action would ship the user +# into an unrelated flow and only surface at the next request. +body contains "action=\"/login/v2/flow/{{flow_token}}/drive\"" +# No `nc://` frontchannel URL should ever appear on the +# response — NC clients pick up credentials via the poll +# endpoint, not via a URL redirect. This mirrors the OIDC path +# fix from tests/oidc/oidc.hurl Step 12F. +body not contains "nc://login" + + +# ───────────────────────────────────────────────────────────── +# Step F — Poll AGAIN. Still 404 — the picker has been +# rendered but not submitted, so no `complete_flow` +# call has run. Guards against a future refactor that +# accidentally auto-completes the flow at the submit +# step (e.g. re-introducing the pre-picker shortcut +# the OIDC arm used to have). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/login/v2/poll +[FormParams] +token: {{poll_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step G — Submit the picker choice. 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, and +# calls complete_flow(..., Some(folder_id)). +# +# Response redirects to /nextcloud/success — that's +# where NC clients that don't use the poll backchannel +# would land visually. NC clients that DO use the poll +# (standard) have credentials in-hand by the time this +# redirect fires, courtesy of `login_flow.complete()`. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/login/v2/flow/{{flow_token}}/drive +[FormParams] +drive: {{shared_folder_id}} + +# 3xx redirect to /nextcloud/success. Not `nc://` — that's the +# whole point of the earlier "friendly success page" fix. +HTTP * +[Asserts] +status >= 300 +status < 400 +header "Location" == "/nextcloud/success" + + +# ───────────────────────────────────────────────────────────── +# Step H — Post-picker poll. NOW the credentials appear. +# +# The composite `admin~` login name proves +# the picker choice round-tripped into the app +# password's login name (basic_auth_middleware.rs +# treats the `~` suffix as a chroot marker for +# subsequent WebDAV / NC requests). Without the +# composite, the sync client would target the home +# drive regardless of what the user picked. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/login/v2/poll +[FormParams] +token: {{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 +# Load-bearing composite-marker assertion. Pre-fix (or if a +# refactor ever drops the picker branch) this would show the +# bare `admin` with no `~`. +jsonpath "$.loginName" matches "^{{username}}~[0-9a-f-]{36}$" +# Belt-and-braces: the exact folder id we picked in Step G is +# what got wired into the login name. Catches a hypothetical +# drive/folder id swap in `handle_drive_pick`. +jsonpath "$.loginName" contains "{{shared_folder_id}}" + + +# ───────────────────────────────────────────────────────────── +# Step I — Poll again — MUST 404. The completed result is +# single-use (poll() removes it from the state map +# on read); a regression that failed to remove would +# leak credentials to any subsequent poll with the +# same token, effectively a replay window. +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/login/v2/poll +[FormParams] +token: {{poll_token}} + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Teardown — remove the fixture shared drive. +# +# CRITICAL: individual Hurl files inside `tests/api/run.sh` +# share DB state within a single run (postgres restarts once +# per run.sh, not per file). Leaving this drive around inflates +# admin's `list_folders_with_perms` result from 1 to 2, which +# breaks any downstream file that assumes admin has exactly one +# root folder (files-folders.hurl:43, favorites.hurl:59, +# recent.hurl:47, and any future test using `/api/folders`). +# Every hurl that creates a persistent drive/folder MUST clean +# it up here, not rely on the next run.sh invocation to reset. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/drives/{{fixture_drive_id}} +Authorization: Bearer {{admin_token}} + +HTTP 204 diff --git a/tests/api/run.sh b/tests/api/run.sh index 5b9bdc35..5048d86a 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -150,6 +150,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/registration.hurl" \ "$API_DIR/nc_status_capabilities.hurl" \ "$API_DIR/nc_login_flow_v2.hurl" \ + "$API_DIR/nc_login_flow_v2_drive_picker.hurl" \ "$API_DIR/nc_ocs_user_info.hurl" \ "$API_DIR/nc_avatar_preview.hurl" \ "$API_DIR/files-folders.hurl" \ diff --git a/tests/oidc/oidc.hurl b/tests/oidc/oidc.hurl index 47e7b428..cb938e71 100644 --- a/tests/oidc/oidc.hurl +++ b/tests/oidc/oidc.hurl @@ -403,3 +403,306 @@ 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~` (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 `~` +# 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/`. 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 ``. 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 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~` 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. +# ─────────────────────────────────────────────────────────────