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
This commit is contained in:
surfingbytes 2026-04-30 10:13:38 +00:00
parent 066791ec00
commit 774bf257a2
2 changed files with 47 additions and 4 deletions

View File

@ -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
}

View File

@ -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) {