diff --git a/src/components/ProfileButton.tsx b/src/components/ProfileButton.tsx index bdaabcc..6bd2634 100644 --- a/src/components/ProfileButton.tsx +++ b/src/components/ProfileButton.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from "react"; import { Tooltip } from "@mui/material"; -import { getLoginUrl, getPostLogoutUrl } from "@/utils/keycloak"; +import { getLoginUrl, prepareKeycloakLogout } from "@/utils/keycloak"; const GUEST_AVATAR_PATH = "/assets/img/guest.png"; @@ -99,10 +99,7 @@ const ProfileButton: React.FC = () => { }; const handleLogout = () => { - // Clear tokens from sessionStorage - sessionStorage.removeItem("kc_token"); - sessionStorage.removeItem("kc_refresh_token"); - sessionStorage.removeItem("kc_id_token"); + const logoutUrl = prepareKeycloakLogout(); // Reset avatar setAvatarUrl(GUEST_AVATAR_PATH); @@ -114,7 +111,7 @@ const ProfileButton: React.FC = () => { // Notify other components about token changes window.dispatchEvent(new Event("tokensUpdated")); - window.location.href = getPostLogoutUrl(); + window.location.href = logoutUrl; }; const toggleDropdown = () => { diff --git a/src/utils/keycloak.test.ts b/src/utils/keycloak.test.ts new file mode 100644 index 0000000..95d2a9c --- /dev/null +++ b/src/utils/keycloak.test.ts @@ -0,0 +1,36 @@ +import { getPostLogoutUrl, prepareKeycloakLogout } from "./keycloak"; + +describe("Keycloak logout", () => { + beforeEach(() => { + sessionStorage.clear(); + localStorage.clear(); + }); + + afterEach(() => { + sessionStorage.clear(); + localStorage.clear(); + }); + + it("captures the ID token hint before removing session and legacy tokens", () => { + sessionStorage.setItem("kc_id_token", "session-id-token"); + sessionStorage.setItem("kc_token", "session-access-token"); + sessionStorage.setItem("kc_refresh_token", "session-refresh-token"); + localStorage.setItem("kc_token", "legacy-access-token"); + localStorage.setItem("kc_refresh_token", "legacy-refresh-token"); + + const url = new URL(prepareKeycloakLogout()); + expect(url.searchParams.get("id_token_hint")).toBe("session-id-token"); + + for (const storage of [sessionStorage, localStorage]) { + expect(storage.getItem("kc_token")).toBeNull(); + expect(storage.getItem("kc_refresh_token")).toBeNull(); + expect(storage.getItem("kc_id_token")).toBeNull(); + } + }); + + it("uses a legacy localStorage ID token when sessionStorage has none", () => { + localStorage.setItem("kc_id_token", "legacy-id-token"); + const url = new URL(getPostLogoutUrl()); + expect(url.searchParams.get("id_token_hint")).toBe("legacy-id-token"); + }); +}); diff --git a/src/utils/keycloak.ts b/src/utils/keycloak.ts index 55db931..83010bc 100644 --- a/src/utils/keycloak.ts +++ b/src/utils/keycloak.ts @@ -53,7 +53,22 @@ export function getPostLogoutUrl(): string { const params = new URLSearchParams({ post_logout_redirect_uri: typeof window !== "undefined" ? window.location.origin : "", - id_token_hint: sessionStorage.getItem("kc_id_token") || "", + id_token_hint: + sessionStorage.getItem("kc_id_token") || + localStorage.getItem("kc_id_token") || + "", }); return `${config.url}/realms/${config.realm}/protocol/openid-connect/logout?${params.toString()}`; } + +// Preserve the logout hint before removing credentials from both storage locations. +// Some older sessions still have tokens in localStorage; never leave those usable. +export function prepareKeycloakLogout(): string { + const logoutUrl = getPostLogoutUrl(); + for (const storage of [sessionStorage, localStorage]) { + storage.removeItem("kc_token"); + storage.removeItem("kc_refresh_token"); + storage.removeItem("kc_id_token"); + } + return logoutUrl; +}