fix(auth): align session lifecycle with Keycloak

This commit is contained in:
2026-08-05 17:10:59 +08:00
parent 5d9c40e454
commit e496fbe4b7
20 changed files with 339 additions and 24 deletions
+21
View File
@@ -1,4 +1,5 @@
import { resolveRequestUrl } from "@/lib/api";
import { useAuthStore } from "@/store/authStore";
describe("resolveRequestUrl", () => {
it("does not prepend baseURL to an absolute request URL", () => {
@@ -19,3 +20,23 @@ describe("resolveRequestUrl", () => {
).toBe("http://localhost:8000/api/v1/schemes");
});
});
describe("authentication lifecycle state", () => {
beforeEach(() => {
useAuthStore.setState({
accessToken: "access-token",
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("clears only the local access token when a request becomes unauthorized", () => {
useAuthStore.getState().markSessionExpired("unauthorized");
expect(useAuthStore.getState()).toMatchObject({
accessToken: null,
sessionExpired: true,
sessionExpiryReason: "unauthorized",
});
});
});
+1 -8
View File
@@ -1,6 +1,5 @@
import axios, { AxiosHeaders, type InternalAxiosRequestConfig } from "axios";
import { config } from "@config/config";
import { signOut } from "next-auth/react";
import { useAuthStore } from "@/store/authStore";
import {
applyAuthContextHeaders,
@@ -13,8 +12,6 @@ export const api = axios.create({
baseURL: API_URL,
});
let isSigningOut = false;
export const resolveRequestUrl = (request: {
baseURL?: string;
url?: string;
@@ -63,11 +60,7 @@ api.interceptors.response.use(
},
async (error) => {
if (error?.response?.status === 401 && typeof window !== "undefined") {
useAuthStore.getState().setAccessToken(null);
if (!isSigningOut) {
isSigningOut = true;
await signOut({ redirect: true, callbackUrl: "/login" });
}
useAuthStore.getState().markSessionExpired("unauthorized");
}
return Promise.reject(error);
},
+1 -8
View File
@@ -1,12 +1,9 @@
import { signOut } from "next-auth/react";
import { useAuthStore } from "@/store/authStore";
import {
applyAuthContextHeaders,
type AuthContextHeaderOptions,
} from "@/lib/requestHeaders";
let isSigningOut = false;
const unwrapPage = async (response: Response) => {
if (
!response.headers.get("content-type")?.includes("application/json")
@@ -58,11 +55,7 @@ export const apiFetch = async (
const response = await fetch(input, requestInit);
if (response.status === 401 && typeof window !== "undefined" && !init.skipAuthRedirect) {
useAuthStore.getState().setAccessToken(null);
if (!isSigningOut) {
isSigningOut = true;
await signOut({ redirect: true, callbackUrl: "/login" });
}
useAuthStore.getState().markSessionExpired("unauthorized");
}
return unwrapPage(response);
+3
View File
@@ -42,6 +42,9 @@ export const getAccessToken = async () => {
setAccessToken(null);
}
const session = await getSession();
if (session?.error) {
return null;
}
const token = typeof session?.accessToken === "string" ? session.accessToken : null;
if (token && !isTokenExpired(token)) {
setAccessToken(token);
+41
View File
@@ -0,0 +1,41 @@
import { act, render } from "@testing-library/react";
import { useState } from "react";
import { useSessionRecoveryDraft } from "./sessionRecoveryDraft";
import { useAuthStore } from "@/store/authStore";
const DraftFixture = ({ initial }: { initial: string }) => {
const [value, setValue] = useState(initial);
useSessionRecoveryDraft("fixture", { value }, (draft) => setValue(draft.value));
return <output>{value}</output>;
};
describe("useSessionRecoveryDraft", () => {
beforeEach(() => {
sessionStorage.clear();
useAuthStore.setState({
accessToken: null,
sessionExpired: false,
sessionExpiryReason: null,
});
});
it("saves an in-progress value when authentication expires and restores it once", () => {
const first = render(<DraftFixture initial="in-progress" />);
act(() => {
useAuthStore.getState().markSessionExpired("unauthorized");
});
expect(sessionStorage.getItem("tjwater-session-recovery:fixture")).toBe(
JSON.stringify({ value: "in-progress" }),
);
first.unmount();
useAuthStore.getState().clearSessionExpired();
const restored = render(<DraftFixture initial="empty" />);
expect(restored.getByText("in-progress")).toBeInTheDocument();
expect(sessionStorage.getItem("tjwater-session-recovery:fixture")).toBeNull();
});
});
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useRef } from "react";
import { useAuthStore } from "@/store/authStore";
const STORAGE_PREFIX = "tjwater-session-recovery:";
const storageKey = (key: string) => `${STORAGE_PREFIX}${key}`;
export const clearSessionRecoveryDrafts = () => {
if (typeof window === "undefined") return;
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
const key = sessionStorage.key(index);
if (key?.startsWith(STORAGE_PREFIX)) sessionStorage.removeItem(key);
}
};
export const useSessionRecoveryDraft = <T,>(
key: string,
value: T,
restore: (value: T) => void,
) => {
const sessionExpired = useAuthStore((state) => state.sessionExpired);
const restoredRef = useRef(false);
useEffect(() => {
if (restoredRef.current || typeof window === "undefined") return;
restoredRef.current = true;
const raw = sessionStorage.getItem(storageKey(key));
if (!raw) return;
try {
restore(JSON.parse(raw) as T);
sessionStorage.removeItem(storageKey(key));
} catch {
sessionStorage.removeItem(storageKey(key));
}
}, [key, restore]);
useEffect(() => {
if (!sessionExpired || typeof window === "undefined") return;
try {
sessionStorage.setItem(storageKey(key), JSON.stringify(value));
} catch {
// Recovery is best-effort. Do not block re-authentication when storage is unavailable.
}
}, [key, sessionExpired, value]);
};