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
|
## [0.1.1] - 2026-04-XX
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Logout users from OIDC provider when logging out of the app.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user