From 99d504842e502e51f47997ed545f643cf2694ef9 Mon Sep 17 00:00:00 2001 From: Claudio Date: Sun, 10 May 2026 21:59:15 +0200 Subject: [PATCH] feat(auth): auto-redirect to Authentik when OIDC enabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even when the user has a live Authentik session, hitting photos.hubris.network used to drop them on the LoginPage with a 'Sign in with Authentik' button they had to click manually. With OIDC set up for a single trusted IdP that's friction with no upside. LoginPage now reads /auth/config on mount and, if OIDC is enabled, immediately navigates to the OIDC login URL. Authentik recognizes the existing session and bounces the browser back through the callback signed in — no clicks needed. Two escape hatches so the user is never stuck: - ?password=1 in the URL forces the password form - sessionStorage 'skipAutoSso' flag, set by the logout flow and by the OIDC callback's error branch, suppresses the next auto-redirect so logouts actually log out and OIDC failures surface their error instead of looping straight back to the IdP While the redirect is in flight we show 'Signing in with Authentik...' plus a small 'Use password instead' link, so users on a slow or broken IdP connection aren't left staring at a spinner. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/components/auth/LoginPage.tsx | 54 +++++++++++++++++-- frontend/src/components/auth/OidcCallback.tsx | 5 ++ frontend/src/contexts/AuthContext.tsx | 5 ++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/components/auth/LoginPage.tsx index a19a3d3..b841022 100644 --- a/frontend/src/components/auth/LoginPage.tsx +++ b/frontend/src/components/auth/LoginPage.tsx @@ -23,18 +23,43 @@ export function LoginPage() { const [error, setError] = useState(null) const [loading, setLoading] = useState(false) const [oidc, setOidc] = useState(null) + // While the OIDC config loads we may auto-bounce to the IdP. Hide + // the form until we know we're staying so the user doesn't see a + // flash of password fields right before the redirect kicks in. + const [autoRedirecting, setAutoRedirecting] = useState(true) - // Ask the backend which login methods to show. Failure is silent — - // worst case the SSO button just doesn't appear and the user falls - // back to username/password. + // Ask the backend which login methods to show. If OIDC is enabled + // and the user already has an SSO session at the IdP, the natural + // flow is for them to land here, get bounced through Authentik, and + // come straight back signed in — without ever clicking a button. + // Two escape hatches: `?password=1` in the URL for explicit password + // login, and a `skipAutoSso` sessionStorage flag set by logout and + // by the OIDC callback's error branch so users don't get trapped in + // a redirect loop. useEffect(() => { let cancelled = false ;(async () => { try { const res = await api.get('/auth/config') - if (!cancelled) setOidc(res.data.oidc) + if (cancelled) return + const cfg = res.data.oidc + setOidc(cfg) + if (!cfg?.enabled) { + setAutoRedirecting(false) + return + } + const params = new URLSearchParams(window.location.search) + if ( + params.has('password') || + sessionStorage.getItem('skipAutoSso') === '1' + ) { + sessionStorage.removeItem('skipAutoSso') + setAutoRedirecting(false) + return + } + window.location.href = cfg.login_url } catch { - /* ignore — SSO button stays hidden */ + if (!cancelled) setAutoRedirecting(false) } })() return () => { @@ -57,6 +82,25 @@ export function LoginPage() { } } + if (autoRedirecting) { + return ( +
+
+
+ Signing in with {oidc?.label ?? 'identity provider'}… +
+ setAutoRedirecting(false)} + > + Use password instead + +
+
+ ) + } + return (
diff --git a/frontend/src/components/auth/OidcCallback.tsx b/frontend/src/components/auth/OidcCallback.tsx index e352d50..172beb2 100644 --- a/frontend/src/components/auth/OidcCallback.tsx +++ b/frontend/src/components/auth/OidcCallback.tsx @@ -38,12 +38,17 @@ export function OidcCallback() { const clean = () => window.history.replaceState({}, '', '/') if (errCode) { + // Don't auto-bounce back to Authentik on the next LoginPage + // mount — show the error and let the user fall back to password + // or retry deliberately. + sessionStorage.setItem('skipAutoSso', '1') setError(ERROR_MESSAGES[errCode] || 'Sign-in failed. Please try again.') clean() return } if (!accessToken || !refreshToken) { + sessionStorage.setItem('skipAutoSso', '1') setError('The identity provider did not return the expected tokens.') clean() return diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 74daa1d..82c9521 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -127,6 +127,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { ) const logout = useCallback(() => { + // Tell LoginPage to skip its OIDC auto-redirect on the next mount — + // otherwise the user clicks Logout and gets bounced straight back + // through Authentik (whose session is still valid), which feels + // like the logout did nothing. + sessionStorage.setItem('skipAutoSso', '1') clearTokens() setUser(null) }, [])