Private
Public Access
1
0

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
This commit is contained in:
2026-05-13 14:53:12 +00:00
parent 69d2e88bbd
commit d76450759a
4 changed files with 50 additions and 5 deletions

View File

@ -233,6 +233,8 @@ pub fn build_router(state: AppState) -> Router {
.route("/status/health", get(health_handler)) .route("/status/health", get(health_handler))
// Public auth routes (no JWT needed) // Public auth routes (no JWT needed)
.nest("/api/v1/auth", routes::auth::public_router()) .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) // Public Azure SSO routes (no JWT needed)
.nest("/api/v1/auth/azure", routes::sso::azure_compat_router()) .nest("/api/v1/auth/azure", routes::sso::azure_compat_router())
// Protected API routes (JWT required) // Protected API routes (JWT required)

View File

@ -124,6 +124,7 @@ pub fn public_router() -> Router<AppState> {
Router::new() Router::new()
.route("/login", get(sso_login)) .route("/login", get(sso_login))
.route("/callback", get(sso_callback)) .route("/callback", get(sso_callback))
.route("/config", get(sso_config))
} }
/// Backward-compatible Azure SSO routes — redirect to generic SSO endpoints. /// Backward-compatible Azure SSO routes — redirect to generic SSO endpoints.
@ -133,6 +134,34 @@ pub fn azure_compat_router() -> Router<AppState> {
.route("/callback", get(azure_callback_redirect)) .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<AppState>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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 // GET /api/v1/auth/sso/login
// ============================================================ // ============================================================

View File

@ -94,6 +94,18 @@ apiClient.interceptors.response.use(
) )
// ── Auth API functions ─────────────────────────────────────────────────────── // ── 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<SsoConfigResponse>('/auth/sso/config'),
}
export const authApi = { export const authApi = {
login: (username: string, password: string, totpCode?: string) => login: (username: string, password: string, totpCode?: string) =>
apiClient.post('/auth/login', { username, password, totp_code: totpCode }), apiClient.post('/auth/login', { username, password, totp_code: totpCode }),

View File

@ -11,7 +11,7 @@ import {
Check as CheckIcon, Close as CloseIcon, Check as CheckIcon, Close as CloseIcon,
Cloud as CloudIcon, VpnKey as KeyIcon, Cloud as CloudIcon, VpnKey as KeyIcon,
} from '@mui/icons-material' } from '@mui/icons-material'
import { authApi, settingsApi } from '../api/client' import { authApi, ssoConfigApi } from '../api/client'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import type { User } from '../types' import type { User } from '../types'
@ -64,6 +64,7 @@ export default function LoginPage() {
const [ssoEnabled, setSsoEnabled] = useState(false) const [ssoEnabled, setSsoEnabled] = useState(false)
const [ssoDisplayName, setSsoDisplayName] = useState('SSO') const [ssoDisplayName, setSsoDisplayName] = useState('SSO')
const [ssoAuthUrl, setSsoAuthUrl] = useState('/api/v1/auth/sso/login')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
const [confirmNewPassword, setConfirmNewPassword] = useState('') const [confirmNewPassword, setConfirmNewPassword] = useState('')
@ -75,9 +76,10 @@ export default function LoginPage() {
const pwMismatch = !!(confirmNewPassword && newPassword !== confirmNewPassword) const pwMismatch = !!(confirmNewPassword && newPassword !== confirmNewPassword)
useEffect(() => { useEffect(() => {
settingsApi.get().then(({ data }) => { ssoConfigApi.get().then(({ data }) => {
setSsoEnabled(data.oidc.enabled) setSsoEnabled(data.enabled)
setSsoDisplayName(data.oidc.display_name || 'SSO') setSsoDisplayName(data.display_name || 'SSO')
if (data.auth_url) setSsoAuthUrl(data.auth_url)
}).catch(() => { /* SSO settings unavailable */ }) }).catch(() => { /* SSO settings unavailable */ })
}, []) }, [])
@ -198,7 +200,7 @@ export default function LoginPage() {
{ssoEnabled && ( {ssoEnabled && (
<> <>
<Divider sx={{ my: 3 }}>or</Divider> <Divider sx={{ my: 3 }}>or</Divider>
<Button fullWidth variant="outlined" size="large" startIcon={ssoIcon} onClick={() => { window.location.href = '/api/v1/auth/sso/login' }} disabled={loading}>Sign in with {ssoDisplayName}</Button> <Button fullWidth variant="outlined" size="large" startIcon={ssoIcon} onClick={() => { window.location.href = ssoAuthUrl }} disabled={loading}>Sign in with {ssoDisplayName}</Button>
</> </>
)} )}
</Box> </Box>