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
+20 -3
View File
@@ -17,10 +17,12 @@ import { ColorModeContextProvider } from "@contexts/color-mode";
import { dataProvider } from "@providers/data-provider";
import { ProjectProvider } from "@/contexts/ProjectContext";
import { RoutePermissionGuard } from "@/components/auth/RoutePermissionGuard";
import { SessionExpiryDialog } from "@/components/auth/SessionExpiryDialog";
import { useAuthStore } from "@/store/authStore";
import { useAccessStore } from "@/store/accessStore";
import { useProjectStore } from "@/store/projectStore";
import { apiFetch } from "@/lib/apiFetch";
import { clearSessionRecoveryDrafts } from "@/lib/sessionRecoveryDraft";
import { permissionCodes, resourcePermissions } from "@/lib/permissions";
import { config } from "@config/config";
import { useAppNotificationProvider } from "@/providers/notification-provider/useAppNotificationProvider";
@@ -57,6 +59,8 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
const { data, status } = useSession();
const to = usePathname();
const setAccessToken = useAuthStore((state) => state.setAccessToken);
const markSessionExpired = useAuthStore((state) => state.markSessionExpired);
const clearSessionExpired = useAuthStore((state) => state.clearSessionExpired);
const currentProjectId = useProjectStore((state) => state.currentProjectId);
const permissions = useAccessStore((state) => state.permissions);
const setAccessContext = useAccessStore((state) => state.setContext);
@@ -70,6 +74,20 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
);
}, [data?.accessToken, setAccessToken]);
useEffect(() => {
if (data?.error === "SessionExpired") {
markSessionExpired("session_max_age");
return;
}
if (data?.error === "RefreshAccessTokenError") {
markSessionExpired("refresh_failed");
return;
}
if (status === "authenticated") {
clearSessionExpired();
}
}, [clearSessionExpired, data?.error, markSessionExpired, status]);
useEffect(() => {
if (status !== "authenticated") {
resetAccess();
@@ -150,13 +168,11 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
if (data?.user?.id) {
sessionStorage.removeItem(`tjwater-login-audit:${data.user.id}`);
}
clearSessionRecoveryDrafts();
window.location.assign("/api/auth/keycloak-logout");
return { success: true };
},
onError: async (error) => {
if (error.response?.status === 401) {
return { logout: true };
}
return { error };
},
check: async () =>
@@ -351,6 +367,7 @@ const App = (props: React.PropsWithChildren<AppProps>) => {
warnWhenUnsavedChanges: true,
}}
>
<SessionExpiryDialog expiresAt={data?.sessionExpiresAt} />
<RoutePermissionGuard>{props.children}</RoutePermissionGuard>
<RefineKbar />
</Refine>
+29 -1
View File
@@ -3,6 +3,9 @@ import { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak";
import Avatar from "@assets/avatar/avatar-small.jpeg";
const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60;
const ACCESS_TOKEN_REFRESH_SKEW_MS = 30_000;
type KeycloakTokenResponse = {
access_token: string;
expires_in: number;
@@ -50,6 +53,7 @@ const refreshAccessToken = async (token: JWT): Promise<JWT> => {
return {
...token,
accessToken: refreshed.access_token,
accessTokenIssuedAt: Date.now(),
accessTokenExpires: Date.now() + refreshed.expires_in * 1000,
refreshToken: refreshed.refresh_token ?? token.refreshToken,
error: undefined,
@@ -88,8 +92,10 @@ const authOptions: NextAuthOptions = {
}
if (account) {
token.sessionExpiresAt = Date.now() + SESSION_MAX_AGE_SECONDS * 1000;
if (account.access_token) {
token.accessToken = account.access_token;
token.accessTokenIssuedAt = Date.now();
}
if (account.refresh_token) {
token.refreshToken = account.refresh_token;
@@ -104,7 +110,19 @@ const authOptions: NextAuthOptions = {
return token;
}
if (typeof token.accessTokenExpires === "number" && Date.now() < token.accessTokenExpires - 30_000) {
if (
typeof token.sessionExpiresAt === "number" &&
Date.now() >= token.sessionExpiresAt
) {
return { ...token, error: "SessionExpired" };
}
const accessTokenIsFresh =
typeof token.accessTokenExpires === "number" &&
typeof token.accessTokenIssuedAt === "number" &&
Date.now() < token.accessTokenExpires - ACCESS_TOKEN_REFRESH_SKEW_MS;
if (accessTokenIsFresh) {
return token;
}
@@ -123,9 +141,19 @@ const authOptions: NextAuthOptions = {
if (token.error) {
session.error = token.error;
}
if (typeof token.sessionExpiresAt === "number") {
session.sessionExpiresAt = token.sessionExpiresAt;
}
return session;
},
},
session: {
strategy: "jwt",
maxAge: SESSION_MAX_AGE_SECONDS,
},
jwt: {
maxAge: SESSION_MAX_AGE_SECONDS,
},
};
export default authOptions;