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 ## [0.1.1] - 2026-04-XX
### Added ### Added
- Logout users from OIDC provider when logging out of the app.
### Changed ### Changed
+7 -1
View File
@@ -24,6 +24,7 @@ import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout'; import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
import { useUIStore } from '@/stores/uiStore'; import { useUIStore } from '@/stores/uiStore';
import { useAuthStore } from '@/stores/authStore'; import { useAuthStore } from '@/stores/authStore';
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
import { useState } from 'react'; import { useState } from 'react';
import { useAccountStore } from '@/stores/accountStore'; import { useAccountStore } from '@/stores/accountStore';
import { useSchemaStore } from '@/stores/schemaStore'; import { useSchemaStore } from '@/stores/schemaStore';
@@ -140,8 +141,13 @@ export function TopBar() {
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
const endSessionEndpoint = useAuthStore.getState().endSessionEndpoint;
logout(); logout();
navigate('/login'); if (endSessionEndpoint) {
window.location.href = buildEndSessionUrl(endSessionEndpoint, getPostLogoutRedirectUri());
} else {
navigate('/login');
}
}} }}
> >
<LogOut className="mr-2 h-4 w-4" /> <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')); 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) { if (!state || state !== stateParam) {
throw new Error(t('oauth.stateMismatch', 'State parameter mismatch. Please try logging in again.')); throw new Error(t('oauth.stateMismatch', 'State parameter mismatch. Please try logging in again.'));
@@ -50,7 +50,13 @@ export default function OAuthCallback() {
useAuthStore useAuthStore
.getState() .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(); clearStoredOAuthData();
+24 -1
View File
@@ -16,6 +16,7 @@ const SESSION_PREFIX = 'stalwart-oauth-';
interface DiscoveryResponse { interface DiscoveryResponse {
authorization_endpoint: string; authorization_endpoint: string;
token_endpoint: string; token_endpoint: string;
end_session_endpoint?: string;
} }
export async function discover(username: string): Promise<DiscoveryResponse> { 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> { 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 codeVerifier = generateCodeVerifier();
const { challenge: codeChallenge, method: codeChallengeMethod } = await generateCodeChallenge(codeVerifier); 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}token_endpoint`, token_endpoint);
sessionStorage.setItem(`${SESSION_PREFIX}state`, state); sessionStorage.setItem(`${SESSION_PREFIX}state`, state);
sessionStorage.setItem(`${SESSION_PREFIX}return_url`, safeReturnUrl); 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({ const params = new URLSearchParams({
response_type: 'code', response_type: 'code',
@@ -152,6 +158,7 @@ export async function startAuthFlow(username: string, returnUrl?: string | null)
code_challenge_method: codeChallengeMethod, code_challenge_method: codeChallengeMethod,
state, state,
login_hint: username, login_hint: username,
prompt: 'login',
}); });
if (SCOPES && SCOPES.length > 0) { if (SCOPES && SCOPES.length > 0) {
@@ -167,6 +174,7 @@ export function getStoredOAuthData() {
tokenEndpoint: sessionStorage.getItem(`${SESSION_PREFIX}token_endpoint`), tokenEndpoint: sessionStorage.getItem(`${SESSION_PREFIX}token_endpoint`),
state: sessionStorage.getItem(`${SESSION_PREFIX}state`), state: sessionStorage.getItem(`${SESSION_PREFIX}state`),
returnUrl: sessionStorage.getItem(`${SESSION_PREFIX}return_url`), 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}token_endpoint`);
sessionStorage.removeItem(`${SESSION_PREFIX}state`); sessionStorage.removeItem(`${SESSION_PREFIX}state`);
sessionStorage.removeItem(`${SESSION_PREFIX}return_url`); sessionStorage.removeItem(`${SESSION_PREFIX}return_url`);
sessionStorage.removeItem(`${SESSION_PREFIX}end_session_endpoint`);
} }
export function getOAuthRedirectUri(): string { export function getOAuthRedirectUri(): string {
return getRedirectUri(); 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; refreshToken: string | null;
tokenExpiresAt: number | null; tokenExpiresAt: number | null;
tokenEndpoint: string | null; tokenEndpoint: string | null;
endSessionEndpoint: string | null;
accounts: Record<string, AccountInfo>; accounts: Record<string, AccountInfo>;
primaryAccountId: string | null; primaryAccountId: string | null;
activeAccountId: string | null; activeAccountId: string | null;
@@ -24,7 +25,13 @@ interface AuthState {
maxObjectsInGet: number; maxObjectsInGet: number;
maxObjectsInSet: 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: ( setSession: (
accounts: Record<string, AccountInfo>, accounts: Record<string, AccountInfo>,
primaryAccountId: string, primaryAccountId: string,
@@ -45,6 +52,7 @@ export const useAuthStore = create<AuthState>()(
refreshToken: null, refreshToken: null,
tokenExpiresAt: null, tokenExpiresAt: null,
tokenEndpoint: null, tokenEndpoint: null,
endSessionEndpoint: null,
accounts: {}, accounts: {},
primaryAccountId: null, primaryAccountId: null,
activeAccountId: null, activeAccountId: null,
@@ -52,12 +60,13 @@ export const useAuthStore = create<AuthState>()(
maxObjectsInGet: 500, maxObjectsInGet: 500,
maxObjectsInSet: 500, maxObjectsInSet: 500,
setTokens: (access, refresh, expiresIn, tokenEndpoint) => { setTokens: (access, refresh, expiresIn, tokenEndpoint, endSessionEndpoint) => {
set({ set({
accessToken: access, accessToken: access,
refreshToken: refresh, refreshToken: refresh,
tokenExpiresAt: Date.now() + expiresIn * 1000, tokenExpiresAt: Date.now() + expiresIn * 1000,
tokenEndpoint, tokenEndpoint,
endSessionEndpoint: endSessionEndpoint ?? null,
}); });
}, },
@@ -85,6 +94,7 @@ export const useAuthStore = create<AuthState>()(
refreshToken: null, refreshToken: null,
tokenExpiresAt: null, tokenExpiresAt: null,
tokenEndpoint: null, tokenEndpoint: null,
endSessionEndpoint: null,
accounts: {}, accounts: {},
primaryAccountId: null, primaryAccountId: null,
activeAccountId: null, activeAccountId: null,
@@ -123,6 +133,7 @@ export const useAuthStore = create<AuthState>()(
refreshToken: state.refreshToken, refreshToken: state.refreshToken,
tokenExpiresAt: state.tokenExpiresAt, tokenExpiresAt: state.tokenExpiresAt,
tokenEndpoint: state.tokenEndpoint, tokenEndpoint: state.tokenEndpoint,
endSessionEndpoint: state.endSessionEndpoint,
}) as AuthState, }) as AuthState,
}, },
), ),