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 - + )}