fix(auth): prevent logout audit from blocking redirect

This commit is contained in:
2026-08-07 12:01:39 +08:00
parent 0dd521d8c9
commit caf18f706d
3 changed files with 145 additions and 19 deletions
+19 -19
View File
@@ -22,6 +22,7 @@ import { useAuthStore } from "@/store/authStore";
import { useAccessStore } from "@/store/accessStore"; 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 { 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";
@@ -153,25 +154,24 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
}); });
return { success: true }; return { success: true };
}, },
logout: async () => { logout: () =>
try { completeLogout({
await apiFetch(`${config.BACKEND_URL}/api/v1/audit-events`, { reportAudit: () =>
method: "POST", reportLogoutAudit({
headers: { "Content-Type": "application/json" }, endpoint: `${config.BACKEND_URL}/api/v1/audit-events`,
body: JSON.stringify({ event: "logout" }), accessToken:
projectHeaderMode: "omit", typeof data?.accessToken === "string"
skipAuthRedirect: true, ? data.accessToken
}); : useAuthStore.getState().accessToken,
} catch { }),
// Logout must still complete when audit storage is unavailable. clearLocalState: () => {
} if (data?.user?.id) {
if (data?.user?.id) { sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`); }
} clearSessionRecoveryDrafts();
clearSessionRecoveryDrafts(); },
window.location.assign("/api/auth/keycloak-logout"); navigate: (path) => window.location.assign(path),
return { success: true }; }),
},
onError: async (error) => { onError: async (error) => {
return { error }; return { error };
}, },
+79
View File
@@ -0,0 +1,79 @@
import { completeLogout, reportLogoutAudit } from "./logoutFlow";
describe("reportLogoutAudit", () => {
it("starts an authenticated keepalive request", async () => {
const fetcher = jest.fn(async () => ({ ok: true }) as Response);
await reportLogoutAudit({
endpoint: "https://server.example/api/v1/audit-events",
accessToken: "access-token",
fetcher,
});
expect(fetcher).toHaveBeenCalledTimes(1);
const [url, init] = fetcher.mock.calls[0];
expect(url).toBe("https://server.example/api/v1/audit-events");
expect(init).toMatchObject({
method: "POST",
body: JSON.stringify({ event: "logout" }),
keepalive: true,
});
expect(new Headers(init?.headers).get("Authorization")).toBe(
"Bearer access-token",
);
});
});
describe("completeLogout", () => {
it("navigates immediately without waiting for audit reporting", async () => {
const reportAudit = jest.fn(
() => new Promise<unknown>(() => undefined),
);
const clearLocalState = jest.fn();
const navigate = jest.fn();
const result = completeLogout({
reportAudit,
clearLocalState,
navigate,
});
await Promise.resolve();
expect(reportAudit).toHaveBeenCalledTimes(1);
expect(clearLocalState).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
await expect(result).resolves.toEqual({ success: true });
});
it("completes logout when audit reporting fails", async () => {
const reportAudit = jest.fn(() => Promise.reject(new Error("offline")));
const clearLocalState = jest.fn();
const navigate = jest.fn();
await expect(
completeLogout({ reportAudit, clearLocalState, navigate }),
).resolves.toEqual({ success: true });
expect(clearLocalState).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
});
it("completes logout when audit reporting throws synchronously", async () => {
const clearLocalState = jest.fn();
const navigate = jest.fn();
await expect(
completeLogout({
reportAudit: () => {
throw new Error("invalid request");
},
clearLocalState,
navigate,
}),
).resolves.toEqual({ success: true });
expect(clearLocalState).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith("/api/auth/keycloak-logout");
});
});
+47
View File
@@ -0,0 +1,47 @@
const KEYCLOAK_LOGOUT_PATH = "/api/auth/keycloak-logout";
type CompleteLogoutOptions = {
reportAudit: () => Promise<unknown>;
clearLocalState: () => void;
navigate: (path: string) => void;
};
type LogoutAuditOptions = {
endpoint: string;
accessToken?: string | null;
fetcher?: typeof fetch;
};
export const reportLogoutAudit = ({
endpoint,
accessToken,
fetcher = fetch,
}: LogoutAuditOptions) => {
const headers = new Headers({ "Content-Type": "application/json" });
if (accessToken) {
headers.set("Authorization", `Bearer ${accessToken}`);
}
return fetcher(endpoint, {
method: "POST",
headers,
body: JSON.stringify({ event: "logout" }),
keepalive: true,
});
};
export const completeLogout = async ({
reportAudit,
clearLocalState,
navigate,
}: CompleteLogoutOptions) => {
try {
void reportAudit().catch(() => undefined);
} catch {
// Synchronous reporting errors must not block logout either.
}
clearLocalState();
navigate(KEYCLOAK_LOGOUT_PATH);
return { success: true as const };
};