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