From 47fcf1fe08c755d57413a25ff958c21fbfafd30c Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:57:27 +0200 Subject: [PATCH] Logout users from OIDC provider when logging out of the app --- CHANGELOG.md | 1 + src/components/layout/TopBar.tsx | 8 +++++++- src/pages/OAuthCallback.tsx | 10 ++++++++-- src/services/auth/oauth.ts | 25 ++++++++++++++++++++++++- src/stores/authStore.ts | 15 +++++++++++++-- 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6a8ab5..0942981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. This projec ## [0.1.1] - 2026-04-XX ### Added +- Logout users from OIDC provider when logging out of the app. ### Changed diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 5b5cbba..7e31032 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -24,6 +24,7 @@ import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout'; import { useUIStore } from '@/stores/uiStore'; import { useAuthStore } from '@/stores/authStore'; +import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth'; import { useState } from 'react'; import { useAccountStore } from '@/stores/accountStore'; import { useSchemaStore } from '@/stores/schemaStore'; @@ -140,8 +141,13 @@ export function TopBar() { { + const endSessionEndpoint = useAuthStore.getState().endSessionEndpoint; logout(); - navigate('/login'); + if (endSessionEndpoint) { + window.location.href = buildEndSessionUrl(endSessionEndpoint, getPostLogoutRedirectUri()); + } else { + navigate('/login'); + } }} > diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx index 692bca0..3b99f79 100644 --- a/src/pages/OAuthCallback.tsx +++ b/src/pages/OAuthCallback.tsx @@ -35,7 +35,7 @@ export default function OAuthCallback() { throw new Error(t('oauth.missingParams', 'Missing authorization code or state parameter')); } - const { codeVerifier, tokenEndpoint, state, returnUrl } = getStoredOAuthData(); + const { codeVerifier, tokenEndpoint, state, returnUrl, endSessionEndpoint } = getStoredOAuthData(); if (!state || state !== stateParam) { throw new Error(t('oauth.stateMismatch', 'State parameter mismatch. Please try logging in again.')); @@ -50,7 +50,13 @@ export default function OAuthCallback() { useAuthStore .getState() - .setTokens(tokenResponse.access_token, tokenResponse.refresh_token, tokenResponse.expires_in, tokenEndpoint); + .setTokens( + tokenResponse.access_token, + tokenResponse.refresh_token, + tokenResponse.expires_in, + tokenEndpoint, + endSessionEndpoint, + ); clearStoredOAuthData(); diff --git a/src/services/auth/oauth.ts b/src/services/auth/oauth.ts index a7203d2..78c8369 100644 --- a/src/services/auth/oauth.ts +++ b/src/services/auth/oauth.ts @@ -16,6 +16,7 @@ const SESSION_PREFIX = 'stalwart-oauth-'; interface DiscoveryResponse { authorization_endpoint: string; token_endpoint: string; + end_session_endpoint?: string; } export async function discover(username: string): Promise { @@ -123,7 +124,7 @@ function getRedirectUri(): string { } export async function startAuthFlow(username: string, returnUrl?: string | null): Promise { - const { authorization_endpoint, token_endpoint } = await discover(username); + const { authorization_endpoint, token_endpoint, end_session_endpoint } = await discover(username); const codeVerifier = generateCodeVerifier(); const { challenge: codeChallenge, method: codeChallengeMethod } = await generateCodeChallenge(codeVerifier); @@ -143,6 +144,11 @@ export async function startAuthFlow(username: string, returnUrl?: string | null) sessionStorage.setItem(`${SESSION_PREFIX}token_endpoint`, token_endpoint); sessionStorage.setItem(`${SESSION_PREFIX}state`, state); sessionStorage.setItem(`${SESSION_PREFIX}return_url`, safeReturnUrl); + if (end_session_endpoint) { + sessionStorage.setItem(`${SESSION_PREFIX}end_session_endpoint`, end_session_endpoint); + } else { + sessionStorage.removeItem(`${SESSION_PREFIX}end_session_endpoint`); + } const params = new URLSearchParams({ response_type: 'code', @@ -152,6 +158,7 @@ export async function startAuthFlow(username: string, returnUrl?: string | null) code_challenge_method: codeChallengeMethod, state, login_hint: username, + prompt: 'login', }); if (SCOPES && SCOPES.length > 0) { @@ -167,6 +174,7 @@ export function getStoredOAuthData() { tokenEndpoint: sessionStorage.getItem(`${SESSION_PREFIX}token_endpoint`), state: sessionStorage.getItem(`${SESSION_PREFIX}state`), returnUrl: sessionStorage.getItem(`${SESSION_PREFIX}return_url`), + endSessionEndpoint: sessionStorage.getItem(`${SESSION_PREFIX}end_session_endpoint`), }; } @@ -175,8 +183,23 @@ export function clearStoredOAuthData(): void { sessionStorage.removeItem(`${SESSION_PREFIX}token_endpoint`); sessionStorage.removeItem(`${SESSION_PREFIX}state`); sessionStorage.removeItem(`${SESSION_PREFIX}return_url`); + sessionStorage.removeItem(`${SESSION_PREFIX}end_session_endpoint`); } export function getOAuthRedirectUri(): string { return getRedirectUri(); } + +export function getPostLogoutRedirectUri(): string { + const basePath = getBasePath(); + return `${window.location.origin}${basePath}/login`; +} + +export function buildEndSessionUrl(endSessionEndpoint: string, postLogoutRedirectUri: string): string { + const params = new URLSearchParams({ + client_id: CLIENT_ID, + post_logout_redirect_uri: postLogoutRedirectUri, + }); + const sep = endSessionEndpoint.includes('?') ? '&' : '?'; + return `${endSessionEndpoint}${sep}${params.toString()}`; +} diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index 74089f9..8b9d58d 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -17,6 +17,7 @@ interface AuthState { refreshToken: string | null; tokenExpiresAt: number | null; tokenEndpoint: string | null; + endSessionEndpoint: string | null; accounts: Record; primaryAccountId: string | null; activeAccountId: string | null; @@ -24,7 +25,13 @@ interface AuthState { maxObjectsInGet: number; maxObjectsInSet: number; - setTokens: (access: string, refresh: string, expiresIn: number, tokenEndpoint: string) => void; + setTokens: ( + access: string, + refresh: string, + expiresIn: number, + tokenEndpoint: string, + endSessionEndpoint?: string | null, + ) => void; setSession: ( accounts: Record, primaryAccountId: string, @@ -45,6 +52,7 @@ export const useAuthStore = create()( refreshToken: null, tokenExpiresAt: null, tokenEndpoint: null, + endSessionEndpoint: null, accounts: {}, primaryAccountId: null, activeAccountId: null, @@ -52,12 +60,13 @@ export const useAuthStore = create()( maxObjectsInGet: 500, maxObjectsInSet: 500, - setTokens: (access, refresh, expiresIn, tokenEndpoint) => { + setTokens: (access, refresh, expiresIn, tokenEndpoint, endSessionEndpoint) => { set({ accessToken: access, refreshToken: refresh, tokenExpiresAt: Date.now() + expiresIn * 1000, tokenEndpoint, + endSessionEndpoint: endSessionEndpoint ?? null, }); }, @@ -85,6 +94,7 @@ export const useAuthStore = create()( refreshToken: null, tokenExpiresAt: null, tokenEndpoint: null, + endSessionEndpoint: null, accounts: {}, primaryAccountId: null, activeAccountId: null, @@ -123,6 +133,7 @@ export const useAuthStore = create()( refreshToken: state.refreshToken, tokenExpiresAt: state.tokenExpiresAt, tokenEndpoint: state.tokenEndpoint, + endSessionEndpoint: state.endSessionEndpoint, }) as AuthState, }, ),