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
This commit is contained in:
Edouard Vanbelle
2026-07-13 18:29:30 +02:00
parent 06da428493
commit 05ef55a8e0
6 changed files with 761 additions and 53 deletions
@@ -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<Uuid>,
/// 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<String>,
completed: Option<LoginResult>,
}
@@ -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<String> {
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,
+26 -41
View File
@@ -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,
)
}
}
}
+150 -12
View File
@@ -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,
&current_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<AppState>,
nextcloud: &crate::common::di::NextcloudServices,
token: &str,
current_user: &CurrentUser,
label: &'static str,
mut drives: Vec<crate::application::dtos::folder_dto::FolderDto>,
) -> 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, &current_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 `~<uuid>`
/// 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<AppState>,
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,
&current_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
@@ -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~<uuid>`
# 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://<host>/login/v2/flow/<hex>
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 ~<uuid> 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 <form action> 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~<folder_id>` login name proves
# the picker choice round-tripped into the app
# password's login name (basic_auth_middleware.rs
# treats the `~<uuid>` 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
+1
View File
@@ -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" \
+303
View File
@@ -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~<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.
# ─────────────────────────────────────────────────────────────