From 3cea306e17b59bf3b04b3a477db262a5ff6d1804 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 6 Apr 2026 01:20:55 +0200 Subject: [PATCH] feat: auto-redirect to OIDC provider for true SSO When OIDC is configured, the login page automatically redirects to the provider with prompt=none. If the user has an active OIDC session, they are signed in without seeing a login page. If the provider returns login_required (no session), falls back to the login form via ?local. Users can bypass auto-redirect with /login?local. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/auth/LoginPage.tsx | 23 +++++++++++++++++++++-- ui/src/auth/OidcCallback.tsx | 5 +++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx index a1c522bc..9077bfbf 100644 --- a/ui/src/auth/LoginPage.tsx +++ b/ui/src/auth/LoginPage.tsx @@ -1,5 +1,5 @@ -import { type FormEvent, useEffect, useMemo, useState } from 'react'; -import { Navigate } from 'react-router'; +import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { Navigate, useSearchParams } from 'react-router'; import { useAuthStore } from './auth-store'; import { api } from '../api/client'; import { config } from '../config'; @@ -41,11 +41,14 @@ const SUBTITLES = [ export function LoginPage() { const { isAuthenticated, login, loading, error } = useAuthStore(); + const [searchParams] = useSearchParams(); + const forceLocal = searchParams.has('local'); const subtitle = useMemo(() => SUBTITLES[Math.floor(Math.random() * SUBTITLES.length)], []); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [oidc, setOidc] = useState(null); const [oidcLoading, setOidcLoading] = useState(false); + const autoRedirected = useRef(false); useEffect(() => { api.GET('/auth/oidc/config') @@ -60,6 +63,22 @@ export function LoginPage() { .catch(() => {}); }, []); + // Auto-redirect to OIDC provider for SSO (skip if ?local is in URL) + useEffect(() => { + if (oidc && !forceLocal && !autoRedirected.current) { + autoRedirected.current = true; + const redirectUri = `${window.location.origin}${config.basePath}oidc/callback`; + const params = new URLSearchParams({ + response_type: 'code', + client_id: oidc.clientId, + redirect_uri: redirectUri, + scope: 'openid email profile', + prompt: 'none', + }); + window.location.href = `${oidc.authorizationEndpoint}?${params}`; + } + }, [oidc, forceLocal]); + if (isAuthenticated) return ; const handleSubmit = (e: FormEvent) => { diff --git a/ui/src/auth/OidcCallback.tsx b/ui/src/auth/OidcCallback.tsx index d98d146d..c1158d07 100644 --- a/ui/src/auth/OidcCallback.tsx +++ b/ui/src/auth/OidcCallback.tsx @@ -18,6 +18,11 @@ export function OidcCallback() { const errorParam = params.get('error'); if (errorParam) { + // prompt=none SSO attempt failed (no active session) — fall back to login form + if (errorParam === 'login_required' || errorParam === 'interaction_required') { + window.location.replace(`${config.basePath}login?local`); + return; + } useAuthStore.setState({ error: params.get('error_description') || errorParam, loading: false,