From 774bf257a2e2e8f5717a6f1b51c438e53d22c0cf Mon Sep 17 00:00:00 2001 From: surfingbytes Date: Thu, 30 Apr 2026 10:13:38 +0000 Subject: [PATCH] feat(auth): support OIDC RP-initiated logout When a user logs out, redirect them to the configured OIDC provider's end-session endpoint with `client_id` and `post_logout_redirect_uri` query parameters so the IdP can identify the relying party and bounce the user back to the Vikunja login page. Also set a `justLoggedOut` flag in sessionStorage so the single-provider auto-redirect on the login page does not silently re-authenticate the user immediately after logout. Made-with: Cursor --- frontend/src/helpers/redirectToProvider.ts | 41 ++++++++++++++++++++-- frontend/src/views/user/Login.vue | 10 ++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/frontend/src/helpers/redirectToProvider.ts b/frontend/src/helpers/redirectToProvider.ts index 1b7513fda..f4f4344aa 100644 --- a/frontend/src/helpers/redirectToProvider.ts +++ b/frontend/src/helpers/redirectToProvider.ts @@ -24,8 +24,45 @@ export const redirectToProvider = (provider: IProvider) => { window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=${scope}&state=${state}` } +// JUST_LOGGED_OUT_KEY is read by Login.vue to short-circuit any single-provider +// auto-redirect right after a logout. Without it, an immediate bounce back to the +// IdP would silently re-authenticate the user, defeating the logout entirely. +export const JUST_LOGGED_OUT_KEY = 'justLoggedOut' + export const redirectToProviderOnLogout = (provider: IProvider) => { - if (provider.logoutUrl.length > 0) { - window.location.href = `${provider.logoutUrl}` + if (!provider.logoutUrl || provider.logoutUrl.length === 0) { + return } + + // Mark that we just logged out so Login.vue skips its auto-redirect when the + // IdP sends the user back via post_logout_redirect_uri. sessionStorage + // survives the round-trip to the IdP within the same tab. + sessionStorage.setItem(JUST_LOGGED_OUT_KEY, '1') + + let target = provider.logoutUrl + try { + const url = new URL(provider.logoutUrl) + + // client_id lets the IdP identify the relying party when no id_token_hint + // is available, so it can skip the "are you sure you want to log out?" + // prompt for known clients (Authentik does this). + if (provider.clientId) { + url.searchParams.set('client_id', provider.clientId) + } + + // post_logout_redirect_uri tells the IdP where to send the user after + // signing them out. We send them back to the frontend root, which the + // router resolves to /login. Combined with JUST_LOGGED_OUT_KEY above, + // the login page will render normally instead of auto-redirecting. + const current = parseURL(window.location.href) + const base = getFullBaseUrl() + url.searchParams.set('post_logout_redirect_uri', `${current.protocol}//${current.host}${base}`) + + target = url.toString() + } catch { + // Fall back to the raw URL if it's not parseable as an absolute URL. + // We still want logout to navigate even if the admin misconfigured it. + } + + window.location.href = target } diff --git a/frontend/src/views/user/Login.vue b/frontend/src/views/user/Login.vue index d4d9e66af..6839fb252 100644 --- a/frontend/src/views/user/Login.vue +++ b/frontend/src/views/user/Login.vue @@ -132,7 +132,7 @@ import FormCheckbox from '@/components/input/FormCheckbox.vue' import DesktopLogin from '@/views/user/DesktopLogin.vue' import {getErrorText} from '@/message' -import {redirectToProvider} from '@/helpers/redirectToProvider' +import {JUST_LOGGED_OUT_KEY, redirectToProvider} from '@/helpers/redirectToProvider' import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited' import {isDesktopApp} from '@/helpers/desktopAuth' @@ -185,7 +185,13 @@ onBeforeMount(() => { } // When local and LDAP auth are both off and there's exactly one OIDC provider, - // skip the login page and redirect straight to the provider. + // skip the login page and redirect straight to the provider — unless the user + // just logged out, in which case we'd immediately re-authenticate them. + const justLoggedOut = sessionStorage.getItem(JUST_LOGGED_OUT_KEY) !== null + if (justLoggedOut) { + sessionStorage.removeItem(JUST_LOGGED_OUT_KEY) + return + } if (!localAuthEnabled.value && !ldapAuthEnabled.value && hasOpenIdProviders.value && openidConnect.value.providers?.length === 1) {