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