Logout users from OIDC provider when logging out of the app
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user