Logout users from OIDC provider when logging out of the app

This commit is contained in:
Maurus Decimus
2026-04-23 13:57:27 +02:00
parent 27ba55fbf6
commit 47fcf1fe08
5 changed files with 53 additions and 6 deletions
+1
View File
@@ -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
+6
View File
@@ -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() {
<DropdownMenuItem
onClick={() => {
const endSessionEndpoint = useAuthStore.getState().endSessionEndpoint;
logout();
if (endSessionEndpoint) {
window.location.href = buildEndSessionUrl(endSessionEndpoint, getPostLogoutRedirectUri());
} else {
navigate('/login');
}
}}
>
<LogOut className="mr-2 h-4 w-4" />
+8 -2
View File
@@ -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();
+24 -1
View File
@@ -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<DiscoveryResponse> {
@@ -123,7 +124,7 @@ function getRedirectUri(): string {
}
export async function startAuthFlow(username: string, returnUrl?: string | null): Promise<void> {
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()}`;
}
+13 -2
View File
@@ -17,6 +17,7 @@ interface AuthState {
refreshToken: string | null;
tokenExpiresAt: number | null;
tokenEndpoint: string | null;
endSessionEndpoint: string | null;
accounts: Record<string, AccountInfo>;
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<string, AccountInfo>,
primaryAccountId: string,
@@ -45,6 +52,7 @@ export const useAuthStore = create<AuthState>()(
refreshToken: null,
tokenExpiresAt: null,
tokenEndpoint: null,
endSessionEndpoint: null,
accounts: {},
primaryAccountId: null,
activeAccountId: null,
@@ -52,12 +60,13 @@ export const useAuthStore = create<AuthState>()(
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<AuthState>()(
refreshToken: null,
tokenExpiresAt: null,
tokenEndpoint: null,
endSessionEndpoint: null,
accounts: {},
primaryAccountId: null,
activeAccountId: null,
@@ -123,6 +133,7 @@ export const useAuthStore = create<AuthState>()(
refreshToken: state.refreshToken,
tokenExpiresAt: state.tokenExpiresAt,
tokenEndpoint: state.tokenEndpoint,
endSessionEndpoint: state.endSessionEndpoint,
}) as AuthState,
},
),