fix(auth): preserve intentional logout across browser return
Keep a bounded per-tab logout intent and broadcast it to already open tabs so back navigation after a normal logout is not mistaken for renewal failure. Preserve real refresh and session expiry signals, and keep IdP navigation reachable when local cleanup fails.
This commit is contained in:
@@ -3,12 +3,17 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
import { useAccessStore } from "@/store/accessStore";
|
import { useAccessStore } from "@/store/accessStore";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import {
|
||||||
|
clearIntentionalLogout,
|
||||||
|
markIntentionalLogout,
|
||||||
|
} from "@/lib/logoutIntent";
|
||||||
import { App } from "./RefineContext";
|
import { App } from "./RefineContext";
|
||||||
|
|
||||||
let mockSessionState: {
|
let mockSessionState: {
|
||||||
data: {
|
data: {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
user: { id: string; name: string };
|
user: { id: string; name: string };
|
||||||
|
error?: "RefreshAccessTokenError" | "SessionExpired";
|
||||||
} | null;
|
} | null;
|
||||||
status: "authenticated" | "unauthenticated";
|
status: "authenticated" | "unauthenticated";
|
||||||
} = {
|
} = {
|
||||||
@@ -70,6 +75,7 @@ describe("RefineContext access authentication", () => {
|
|||||||
const originalRequest = global.Request;
|
const originalRequest = global.Request;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
clearIntentionalLogout();
|
||||||
mockPathname = "/network-simulation";
|
mockPathname = "/network-simulation";
|
||||||
mockProjectProviderBlocks = false;
|
mockProjectProviderBlocks = false;
|
||||||
mockSessionState = {
|
mockSessionState = {
|
||||||
@@ -210,4 +216,63 @@ describe("RefineContext access authentication", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not treat returning to a protected route after logout as session expiry", async () => {
|
||||||
|
markIntentionalLogout();
|
||||||
|
mockSessionState = { data: null, status: "unauthenticated" };
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: "refresh_failed",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<App>
|
||||||
|
<div>应用内容</div>
|
||||||
|
</App>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not hide a real refresh failure after an intentional logout", async () => {
|
||||||
|
markIntentionalLogout();
|
||||||
|
mockSessionState = {
|
||||||
|
data: {
|
||||||
|
accessToken: "expired-access-token",
|
||||||
|
user: { id: "user-1", name: "Test User" },
|
||||||
|
error: "RefreshAccessTokenError",
|
||||||
|
},
|
||||||
|
status: "authenticated",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<App>
|
||||||
|
<div>应用内容</div>
|
||||||
|
</App>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: "refresh_failed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSessionState = { data: null, status: "unauthenticated" };
|
||||||
|
rerender(
|
||||||
|
<App>
|
||||||
|
<div>应用内容</div>
|
||||||
|
</App>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(useAuthStore.getState().sessionExpired).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import { useAccessStore } from "@/store/accessStore";
|
|||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
|
import { completeLogout, reportLogoutAudit } from "@/lib/logoutFlow";
|
||||||
|
import {
|
||||||
|
clearIntentionalLogout,
|
||||||
|
isIntentionalLogout,
|
||||||
|
} from "@/lib/logoutIntent";
|
||||||
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
|
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
|
||||||
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
@@ -88,15 +92,17 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.error === "SessionExpired") {
|
if (data?.error === "SessionExpired") {
|
||||||
|
clearIntentionalLogout();
|
||||||
markSessionExpired("session_max_age");
|
markSessionExpired("session_max_age");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data?.error === "RefreshAccessTokenError") {
|
if (data?.error === "RefreshAccessTokenError") {
|
||||||
|
clearIntentionalLogout();
|
||||||
markSessionExpired("refresh_failed");
|
markSessionExpired("refresh_failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (status === "unauthenticated") {
|
if (status === "unauthenticated") {
|
||||||
if (to === "/login") {
|
if (to === "/login" || isIntentionalLogout()) {
|
||||||
clearSessionExpired();
|
clearSessionExpired();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -104,6 +110,7 @@ export const App = (props: React.PropsWithChildren<AppProps>) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (status === "authenticated") {
|
if (status === "authenticated") {
|
||||||
|
clearIntentionalLogout();
|
||||||
clearSessionExpired();
|
clearSessionExpired();
|
||||||
}
|
}
|
||||||
}, [clearSessionExpired, data?.error, markSessionExpired, status, to]);
|
}, [clearSessionExpired, data?.error, markSessionExpired, status, to]);
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { completeLogout, reportLogoutAudit } from "./logoutFlow";
|
import { completeLogout, reportLogoutAudit } from "./logoutFlow";
|
||||||
|
import {
|
||||||
|
clearIntentionalLogout,
|
||||||
|
isIntentionalLogout,
|
||||||
|
} from "./logoutIntent";
|
||||||
|
|
||||||
describe("reportLogoutAudit", () => {
|
describe("reportLogoutAudit", () => {
|
||||||
it("starts an authenticated keepalive request", async () => {
|
it("starts an authenticated keepalive request", async () => {
|
||||||
@@ -28,6 +32,10 @@ describe("reportLogoutAudit", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("completeLogout", () => {
|
describe("completeLogout", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearIntentionalLogout();
|
||||||
|
});
|
||||||
|
|
||||||
it("navigates immediately without waiting for audit reporting", async () => {
|
it("navigates immediately without waiting for audit reporting", async () => {
|
||||||
const reportAudit = jest.fn(
|
const reportAudit = jest.fn(
|
||||||
() => new Promise<unknown>(() => undefined),
|
() => new Promise<unknown>(() => undefined),
|
||||||
@@ -44,6 +52,7 @@ describe("completeLogout", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(reportAudit).toHaveBeenCalledTimes(1);
|
expect(reportAudit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(isIntentionalLogout()).toBe(true);
|
||||||
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
||||||
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
||||||
await expect(result).resolves.toEqual({ success: true });
|
await expect(result).resolves.toEqual({ success: true });
|
||||||
@@ -79,4 +88,20 @@ describe("completeLogout", () => {
|
|||||||
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
expect(clearLocalState).toHaveBeenCalledTimes(1);
|
||||||
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("still navigates when local cleanup throws", async () => {
|
||||||
|
const navigate = jest.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
completeLogout({
|
||||||
|
reportAudit: jest.fn().mockResolvedValue(undefined),
|
||||||
|
clearLocalState: () => {
|
||||||
|
throw new DOMException("Storage unavailable", "SecurityError");
|
||||||
|
},
|
||||||
|
navigate,
|
||||||
|
}),
|
||||||
|
).resolves.toEqual({ success: true });
|
||||||
|
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { markIntentionalLogout } from "@/lib/logoutIntent";
|
||||||
|
|
||||||
const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout";
|
const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout";
|
||||||
|
|
||||||
type CompleteLogoutOptions = {
|
type CompleteLogoutOptions = {
|
||||||
@@ -41,7 +43,12 @@ export const completeLogout = async ({
|
|||||||
// Synchronous reporting errors must not block logout either.
|
// Synchronous reporting errors must not block logout either.
|
||||||
}
|
}
|
||||||
|
|
||||||
clearLocalState();
|
markIntentionalLogout();
|
||||||
|
try {
|
||||||
|
clearLocalState();
|
||||||
|
} catch {
|
||||||
|
// Browser storage cleanup failures must not prevent the IdP logout.
|
||||||
|
}
|
||||||
navigate(KEYCLOAK_LOGOUT_PATH);
|
navigate(KEYCLOAK_LOGOUT_PATH);
|
||||||
return { success: true as const };
|
return { success: true as const };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
clearIntentionalLogout,
|
||||||
|
isIntentionalLogout,
|
||||||
|
markIntentionalLogout,
|
||||||
|
} from "./logoutIntent";
|
||||||
|
|
||||||
|
describe("intentional logout marker", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
clearIntentionalLogout();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores a timestamp only for the current tab", () => {
|
||||||
|
jest.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||||
|
|
||||||
|
markIntentionalLogout();
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem("tjwater:intentional-logout")).toBe(
|
||||||
|
"1700000000000",
|
||||||
|
);
|
||||||
|
expect(localStorage.getItem("tjwater:intentional-logout")).toBeNull();
|
||||||
|
expect(isIntentionalLogout()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an intentional logout event from another open tab", () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new StorageEvent("storage", {
|
||||||
|
key: "tjwater:intentional-logout-event",
|
||||||
|
newValue: JSON.stringify({
|
||||||
|
type: "mark",
|
||||||
|
markedAt: Date.now(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isIntentionalLogout()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not broadcast a clear event when the current tab has no marker", () => {
|
||||||
|
clearIntentionalLogout();
|
||||||
|
const setItem = jest.spyOn(Storage.prototype, "setItem");
|
||||||
|
|
||||||
|
expect(isIntentionalLogout()).toBe(false);
|
||||||
|
expect(setItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expires a stale marker", () => {
|
||||||
|
const now = 1_700_000_000_000;
|
||||||
|
const nowSpy = jest.spyOn(Date, "now").mockReturnValue(now);
|
||||||
|
markIntentionalLogout();
|
||||||
|
|
||||||
|
nowSpy.mockReturnValue(now + 30 * 60 * 1000 + 1);
|
||||||
|
|
||||||
|
expect(isIntentionalLogout()).toBe(false);
|
||||||
|
expect(sessionStorage.getItem("tjwater:intentional-logout")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
const INTENTIONAL_LOGOUT_STORAGE_KEY = "tjwater:intentional-logout";
|
||||||
|
const INTENTIONAL_LOGOUT_EVENT_KEY = "tjwater:intentional-logout-event";
|
||||||
|
const INTENTIONAL_LOGOUT_TTL_MS = 30 * 60 * 1000;
|
||||||
|
let memoryMarkedAt: number | null = null;
|
||||||
|
|
||||||
|
const withStorage = (
|
||||||
|
storageName: "localStorage" | "sessionStorage",
|
||||||
|
callback: (storage: Storage) => void,
|
||||||
|
) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
callback(window[storageName]);
|
||||||
|
} catch {
|
||||||
|
// Logout must still proceed when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setLocalMarker = (markedAt: number | null) => {
|
||||||
|
memoryMarkedAt = markedAt;
|
||||||
|
withStorage("sessionStorage", (storage) => {
|
||||||
|
if (markedAt === null) {
|
||||||
|
storage.removeItem(INTENTIONAL_LOGOUT_STORAGE_KEY);
|
||||||
|
} else {
|
||||||
|
storage.setItem(INTENTIONAL_LOGOUT_STORAGE_KEY, String(markedAt));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const broadcastIntent = (
|
||||||
|
payload: { type: "mark"; markedAt: number } | { type: "clear" },
|
||||||
|
) => {
|
||||||
|
withStorage("localStorage", (storage) => {
|
||||||
|
storage.setItem(INTENTIONAL_LOGOUT_EVENT_KEY, JSON.stringify(payload));
|
||||||
|
storage.removeItem(INTENTIONAL_LOGOUT_EVENT_KEY);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("storage", (event) => {
|
||||||
|
if (event.key !== INTENTIONAL_LOGOUT_EVENT_KEY || !event.newValue) return;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(event.newValue) as {
|
||||||
|
type?: unknown;
|
||||||
|
markedAt?: unknown;
|
||||||
|
};
|
||||||
|
if (payload.type === "mark" && typeof payload.markedAt === "number") {
|
||||||
|
setLocalMarker(payload.markedAt);
|
||||||
|
} else if (payload.type === "clear") {
|
||||||
|
setLocalMarker(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed cross-tab events.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const markIntentionalLogout = () => {
|
||||||
|
const markedAt = Date.now();
|
||||||
|
setLocalMarker(markedAt);
|
||||||
|
broadcastIntent({ type: "mark", markedAt });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearIntentionalLogout = () => {
|
||||||
|
setLocalMarker(null);
|
||||||
|
broadcastIntent({ type: "clear" });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isIntentionalLogout = () => {
|
||||||
|
let markedAt = memoryMarkedAt;
|
||||||
|
withStorage("sessionStorage", (storage) => {
|
||||||
|
const stored = Number(storage.getItem(INTENTIONAL_LOGOUT_STORAGE_KEY));
|
||||||
|
if (Number.isFinite(stored) && stored > 0) markedAt = stored;
|
||||||
|
});
|
||||||
|
if (markedAt === null) return false;
|
||||||
|
const age = Date.now() - markedAt;
|
||||||
|
if (age < 0 || age > INTENTIONAL_LOGOUT_TTL_MS) {
|
||||||
|
setLocalMarker(null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
clearIntentionalLogout,
|
||||||
|
markIntentionalLogout,
|
||||||
|
} from "@/lib/logoutIntent";
|
||||||
|
|
||||||
|
import { useAuthStore } from "./authStore";
|
||||||
|
|
||||||
|
describe("authStore intentional logout boundary", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearIntentionalLogout();
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: "access-token",
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearIntentionalLogout();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores late expiry signals after an intentional logout", () => {
|
||||||
|
markIntentionalLogout();
|
||||||
|
|
||||||
|
useAuthStore.getState().markSessionExpired("unauthorized");
|
||||||
|
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps real refresh failures visible after an intentional logout", () => {
|
||||||
|
markIntentionalLogout();
|
||||||
|
|
||||||
|
useAuthStore.getState().markSessionExpired("refresh_failed");
|
||||||
|
|
||||||
|
expect(useAuthStore.getState()).toMatchObject({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: "refresh_failed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-5
@@ -1,5 +1,7 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import { isIntentionalLogout } from "@/lib/logoutIntent";
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
sessionExpired: boolean;
|
sessionExpired: boolean;
|
||||||
@@ -16,11 +18,21 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
sessionExpired: false,
|
sessionExpired: false,
|
||||||
sessionExpiryReason: null,
|
sessionExpiryReason: null,
|
||||||
setAccessToken: (token) => set({ accessToken: token }),
|
setAccessToken: (token) => set({ accessToken: token }),
|
||||||
markSessionExpired: (reason) => set({
|
markSessionExpired: (reason) => {
|
||||||
accessToken: null,
|
if (reason === "unauthorized" && isIntentionalLogout()) {
|
||||||
sessionExpired: true,
|
set({
|
||||||
sessionExpiryReason: reason,
|
accessToken: null,
|
||||||
}),
|
sessionExpired: false,
|
||||||
|
sessionExpiryReason: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
accessToken: null,
|
||||||
|
sessionExpired: true,
|
||||||
|
sessionExpiryReason: reason,
|
||||||
|
});
|
||||||
|
},
|
||||||
clearSessionExpired: () => set({
|
clearSessionExpired: () => set({
|
||||||
sessionExpired: false,
|
sessionExpired: false,
|
||||||
sessionExpiryReason: null,
|
sessionExpiryReason: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user