feat(auth): auto-redirect to Authentik when OIDC enabled
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) <noreply@anthropic.com>
This commit is contained in:
@@ -23,18 +23,43 @@ export function LoginPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [oidc, setOidc] = useState<OidcConfig | null>(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<AuthConfig>('/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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||
<div className="w-full max-w-sm space-y-3 rounded-lg border border-border bg-surface p-8 text-center shadow-xl">
|
||||
<div className="text-text-muted">
|
||||
Signing in with {oidc?.label ?? 'identity provider'}…
|
||||
</div>
|
||||
<a
|
||||
href="?password=1"
|
||||
className="inline-block text-xs text-text-muted underline hover:text-text"
|
||||
onClick={() => setAutoRedirecting(false)}
|
||||
>
|
||||
Use password instead
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg px-4">
|
||||
<div className="w-full max-w-sm space-y-5 rounded-lg border border-border bg-surface p-8 shadow-xl">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}, [])
|
||||
|
||||
Reference in New Issue
Block a user