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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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, ¤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 `~<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,
|
||||
¤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
|
||||
|
||||
Reference in New Issue
Block a user