From 9adcdc436f74a6e444ecc8d5b449d8ff47555cc6 Mon Sep 17 00:00:00 2001 From: Jared Wolff Date: Thu, 5 Mar 2026 16:56:09 -0500 Subject: [PATCH] fix(auth): use middleware-based auth for app-password API endpoints The Nextcloud integration added duplicate /api/auth/app-passwords handlers that only accepted Bearer tokens, breaking cookie-authenticated browser sessions (profile page). Remove the duplicates and mount the original app_password_handler routes which use CurrentUser from the auth middleware, supporting all auth methods (cookie, Bearer, Basic). --- src/interfaces/api/handlers/auth_handler.rs | 150 +------------------- src/main.rs | 11 ++ static/js/views/profile/profile.js | 9 +- 3 files changed, 22 insertions(+), 148 deletions(-) diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index fae2f4f5..2f262393 100755 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -1,18 +1,16 @@ use axum::{ Router, - extract::{Json, Path, Query, State}, - http::{HeaderMap, StatusCode, header}, + extract::{Json, Query, State}, + http::{HeaderMap, StatusCode}, response::{IntoResponse, Redirect, Response}, - routing::{delete, get, post, put}, + routing::{get, post, put}, }; use std::sync::Arc; use crate::application::dtos::user_dto::{ - AppPasswordCreatedDto, AppPasswordDto, ChangePasswordDto, CreateAppPasswordDto, LoginDto, - OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto, RefreshTokenDto, RegisterDto, - SetupAdminDto, + ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto, + RefreshTokenDto, RegisterDto, SetupAdminDto, }; -use crate::application::ports::auth_ports::TokenServicePort; use crate::application::services::auth_application_service::OidcCallbackResult; use crate::common::di::AppState; use crate::interfaces::api::cookie_auth; @@ -37,11 +35,6 @@ pub fn auth_protected_routes() -> Router> { .route("/me", get(get_current_user)) .route("/change-password", put(change_password)) .route("/logout", post(logout)) - .route( - "/app-passwords", - get(list_app_passwords).post(create_app_password), - ) - .route("/app-passwords/{id}", delete(delete_app_password)) } /// Rate-limited auth routes — split out so main.rs can apply per-endpoint @@ -500,139 +493,6 @@ async fn get_system_status( } // ============================================================================ -// App Password Handlers -// ============================================================================ - -async fn create_app_password( - State(state): State>, - headers: HeaderMap, - Json(dto): Json, -) -> Result { - let auth_service = state - .auth_service - .as_ref() - .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - - let token = headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .ok_or_else(|| AppError::unauthorized("Authorization token not found"))?; - - let claims = auth_service - .token_service - .validate_token(token) - .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - - let nextcloud = state - .nextcloud - .as_ref() - .ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?; - - let label = dto.label.trim(); - if label.is_empty() || label.len() > 128 { - return Err(AppError::new( - StatusCode::BAD_REQUEST, - "Label must be between 1 and 128 characters", - "InvalidInput", - )); - } - - let (id, password) = nextcloud - .app_passwords - .create_nc(&claims.sub, label) - .await - .map_err(AppError::from)?; - - Ok(( - StatusCode::CREATED, - Json(AppPasswordCreatedDto { - id, - label: label.to_string(), - password, - }), - )) -} - -async fn list_app_passwords( - State(state): State>, - headers: HeaderMap, -) -> Result { - let auth_service = state - .auth_service - .as_ref() - .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - - let token = headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .ok_or_else(|| AppError::unauthorized("Authorization token not found"))?; - - let claims = auth_service - .token_service - .validate_token(token) - .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - - let nextcloud = state - .nextcloud - .as_ref() - .ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?; - - let records = nextcloud - .app_passwords - .list_nc(&claims.sub) - .await - .map_err(AppError::from)?; - - let passwords: Vec = records - .into_iter() - .map(|r| AppPasswordDto { - id: r.id, - label: r.label, - created_at: r.created_at, - last_used_at: r.last_used_at, - }) - .collect(); - - Ok((StatusCode::OK, Json(passwords))) -} - -async fn delete_app_password( - State(state): State>, - headers: HeaderMap, - Path(id): Path, -) -> Result { - let auth_service = state - .auth_service - .as_ref() - .ok_or_else(|| AppError::internal_error("Authentication service not configured"))?; - - let token = headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .ok_or_else(|| AppError::unauthorized("Authorization token not found"))?; - - let claims = auth_service - .token_service - .validate_token(token) - .map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?; - - let nextcloud = state - .nextcloud - .as_ref() - .ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?; - - nextcloud - .app_passwords - .delete_by_user(&id, &claims.sub) - .await - .map_err(AppError::from)?; - - Ok(StatusCode::NO_CONTENT) -} - // ============================================================================ // OIDC Handlers // ============================================================================ diff --git a/src/main.rs b/src/main.rs index f0c166a2..e23c5ce9 100755 --- a/src/main.rs +++ b/src/main.rs @@ -187,6 +187,7 @@ async fn main() -> Result<(), Box> { auth_protected_routes, auth_public_routes, login_route, refresh_route, register_route, setup_route, }; + use oxicloud::interfaces::api::handlers::app_password_handler; use oxicloud::interfaces::api::handlers::device_auth_handler; use oxicloud::interfaces::middleware::auth::auth_middleware; use oxicloud::interfaces::middleware::csrf::csrf_middleware; @@ -250,6 +251,14 @@ async fn main() -> Result<(), Box> { auth_middleware, )) .with_state(app_state.clone()); + // App password management routes — require auth + CSRF + let app_pw_protected = app_password_handler::app_password_routes() + .layer(axum::middleware::from_fn(csrf_middleware)) + .layer(axum::middleware::from_fn_with_state( + app_state.clone(), + auth_middleware, + )) + .with_state(app_state.clone()); // One-time setup route — public, rate-limited like register let setup_router = setup_route() .layer(axum::middleware::from_fn_with_state( @@ -302,6 +311,8 @@ async fn main() -> Result<(), Box> { .nest("/api/auth", auth_public) // Protected auth endpoints (/me, /change-password, /logout) .nest("/api/auth", auth_protected) + // App password management (create, list, revoke) + .nest("/api/auth", app_pw_protected) // One-time setup endpoint — public, rate-limited .nest("/api", setup_router) // Device Auth Grant public endpoints (authorize + token polling) diff --git a/static/js/views/profile/profile.js b/static/js/views/profile/profile.js index 06ff026b..5039816b 100755 --- a/static/js/views/profile/profile.js +++ b/static/js/views/profile/profile.js @@ -165,12 +165,13 @@ function renderPwRow(pw) { async function loadAppPasswords() { try { - const resp = await fetch(API + '/auth/app-passwords', { headers: headers() }); + const resp = await fetch(API + '/auth/app-passwords', { headers: headers(), credentials: 'same-origin' }); if (!resp.ok) { document.getElementById('app-passwords-section').classList.add('hidden'); return; } - const passwords = await resp.json(); + const data = await resp.json(); + const passwords = data.app_passwords || data; const userPws = passwords.filter(function (pw) { return !isAutoPassword(pw); }); const autoPws = passwords.filter(isAutoPassword); @@ -231,6 +232,7 @@ async function createAppPassword() { const resp = await fetch(API + '/auth/app-passwords', { method: 'POST', headers: headers(), + credentials: 'same-origin', body: JSON.stringify({ label: label }) }); if (!resp.ok) { @@ -266,7 +268,8 @@ async function revokeAppPassword(id, label) { try { const resp = await fetch(API + '/auth/app-passwords/' + encodeURIComponent(id), { method: 'DELETE', - headers: headers() + headers: headers(), + credentials: 'same-origin' }); if (resp.ok || resp.status === 204) { document.getElementById('app-pw-created').classList.add('hidden');