From d76450759a62323c481d49ba1c9dc0dc2bef1da3 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 13 May 2026 14:53:12 +0000 Subject: [PATCH] fix: add public SSO config endpoint for login page The SSO button on the login page was not appearing because the settings API requires authentication, but the login page cannot authenticate before the user logs in. Changes: - Backend: Add GET /api/v1/auth/sso/config public endpoint that returns only enabled, display_name, and auth_url (no secrets exposed) - Backend: Mount sso::public_router() at /api/v1/auth/sso in main.rs (was previously missing - only azure_compat_router was mounted) - Frontend: Replace settingsApi.get() call in LoginPage.tsx with ssoConfigApi.get() which calls the public endpoint - Frontend: Add SsoConfigResponse interface and ssoConfigApi helper to client.ts - Frontend: Use auth_url from config response instead of hardcoded path --- crates/pm-web/src/main.rs | 2 ++ crates/pm-web/src/routes/sso.rs | 29 +++++++++++++++++++++++++++++ frontend/src/api/client.ts | 12 ++++++++++++ frontend/src/pages/LoginPage.tsx | 12 +++++++----- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index 6370c73..b0bd044 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -233,6 +233,8 @@ pub fn build_router(state: AppState) -> Router { .route("/status/health", get(health_handler)) // Public auth routes (no JWT needed) .nest("/api/v1/auth", routes::auth::public_router()) + // Public SSO routes (no JWT needed) + .nest("/api/v1/auth/sso", routes::sso::public_router()) // Public Azure SSO routes (no JWT needed) .nest("/api/v1/auth/azure", routes::sso::azure_compat_router()) // Protected API routes (JWT required) diff --git a/crates/pm-web/src/routes/sso.rs b/crates/pm-web/src/routes/sso.rs index 4eef122..f1fbda0 100644 --- a/crates/pm-web/src/routes/sso.rs +++ b/crates/pm-web/src/routes/sso.rs @@ -124,6 +124,7 @@ pub fn public_router() -> Router { Router::new() .route("/login", get(sso_login)) .route("/callback", get(sso_callback)) + .route("/config", get(sso_config)) } /// Backward-compatible Azure SSO routes — redirect to generic SSO endpoints. @@ -133,6 +134,34 @@ pub fn azure_compat_router() -> Router { .route("/callback", get(azure_callback_redirect)) } +// ============================================================ +// GET /api/v1/auth/sso/config +// ============================================================ + +/// Public endpoint returning minimal SSO configuration for the login page. +/// Returns only: enabled, display_name, auth_url — no secrets exposed. +async fn sso_config( + State(state): State, +) -> Result, (StatusCode, Json)> { + let config = match load_oidc_config(&state.db).await { + Ok(c) => c, + Err(_) => { + // If we can't load config, SSO is effectively disabled + return Ok(Json(json!({ + "enabled": false, + "display_name": "SSO", + "auth_url": "" + }))); + }, + }; + + Ok(Json(json!({ + "enabled": config.enabled, + "display_name": if config.display_name.is_empty() { "SSO".to_string() } else { config.display_name }, + "auth_url": "/api/v1/auth/sso/login" + }))) +} + // ============================================================ // GET /api/v1/auth/sso/login // ============================================================ diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4cdd09c..ab3a133 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -94,6 +94,18 @@ apiClient.interceptors.response.use( ) // ── Auth API functions ─────────────────────────────────────────────────────── + +export interface SsoConfigResponse { + enabled: boolean + display_name: string + auth_url: string +} + +export const ssoConfigApi = { + /** Public endpoint — no JWT required. Returns minimal SSO config for the login page. */ + get: () => apiClient.get('/auth/sso/config'), +} + export const authApi = { login: (username: string, password: string, totpCode?: string) => apiClient.post('/auth/login', { username, password, totp_code: totpCode }), diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 00afbed..311c1f2 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -11,7 +11,7 @@ import { Check as CheckIcon, Close as CloseIcon, Cloud as CloudIcon, VpnKey as KeyIcon, } from '@mui/icons-material' -import { authApi, settingsApi } from '../api/client' +import { authApi, ssoConfigApi } from '../api/client' import { useAuthStore } from '../store/authStore' import type { User } from '../types' @@ -64,6 +64,7 @@ export default function LoginPage() { const [ssoEnabled, setSsoEnabled] = useState(false) const [ssoDisplayName, setSsoDisplayName] = useState('SSO') + const [ssoAuthUrl, setSsoAuthUrl] = useState('/api/v1/auth/sso/login') const [newPassword, setNewPassword] = useState('') const [confirmNewPassword, setConfirmNewPassword] = useState('') @@ -75,9 +76,10 @@ export default function LoginPage() { const pwMismatch = !!(confirmNewPassword && newPassword !== confirmNewPassword) useEffect(() => { - settingsApi.get().then(({ data }) => { - setSsoEnabled(data.oidc.enabled) - setSsoDisplayName(data.oidc.display_name || 'SSO') + ssoConfigApi.get().then(({ data }) => { + setSsoEnabled(data.enabled) + setSsoDisplayName(data.display_name || 'SSO') + if (data.auth_url) setSsoAuthUrl(data.auth_url) }).catch(() => { /* SSO settings unavailable */ }) }, []) @@ -198,7 +200,7 @@ export default function LoginPage() { {ssoEnabled && ( <> or - + )}