feat(admin-web): proactively refresh access tokens before expiry

Track access token expires_at, dedupe concurrent refreshes, retry SSE on
401, and refresh on window focus/visibility so long-lived sessions stop
hitting the login redirect mid-task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 16:48:15 +08:00
parent 4f3c39f5f5
commit 19527cdf42
6 changed files with 218 additions and 26 deletions
+102 -20
View File
@@ -115,6 +115,7 @@ import {
clearStoredSession,
getStoredAccessToken,
getStoredRefreshToken,
readStoredSession,
setStoredTokens,
} from "./session";
import {
@@ -126,8 +127,10 @@ import {
const baseURL = import.meta.env.VITE_API_BASE_URL ?? "";
const generationStreamEnabled = import.meta.env.VITE_GENERATION_STREAM_ENABLED === "true";
const accessRefreshSkewMs = 2 * 60 * 1000;
const publicClient = createApiClient({ baseURL });
let storedRefreshPromise: Promise<AuthTokens> | null = null;
export function getApiBaseURL(): string {
if (baseURL) {
@@ -157,24 +160,88 @@ export function resolveApiURL(value?: string | null): string {
return new URL(trimmed, apiBaseURL.endsWith("/") ? apiBaseURL : `${apiBaseURL}/`).toString();
}
function toAuthTokens(data: RefreshResponse): AuthTokens {
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: data.expires_at,
};
}
function authRequiredError(): ApiClientError {
return new ApiClientError({
message: "not_authenticated",
code: 40100,
status: 401,
});
}
function expireStoredSession(): void {
clearStoredSession({ notifyAuthExpired: true });
}
export function shouldRefreshAccessToken(
expiresAt: number | null,
minRemainingMs = accessRefreshSkewMs,
): boolean {
return !expiresAt || expiresAt * 1000 - Date.now() <= minRemainingMs;
}
export async function refreshStoredAuthSession(refreshToken = getStoredRefreshToken()): Promise<AuthTokens> {
if (!refreshToken) {
expireStoredSession();
throw authRequiredError();
}
if (!storedRefreshPromise) {
storedRefreshPromise = publicClient
.post<RefreshResponse, { refresh_token: string }>("/api/auth/refresh", {
refresh_token: refreshToken,
})
.then((data) => {
const tokens = toAuthTokens(data);
setStoredTokens(tokens);
return tokens;
})
.catch((error) => {
expireStoredSession();
throw error;
})
.finally(() => {
storedRefreshPromise = null;
});
}
return storedRefreshPromise;
}
async function getFreshStoredAccessToken(): Promise<string> {
const snapshot = readStoredSession();
if (snapshot.refreshToken && shouldRefreshAccessToken(snapshot.accessTokenExpiresAt)) {
return (await refreshStoredAuthSession(snapshot.refreshToken)).accessToken;
}
if (snapshot.accessToken) {
return snapshot.accessToken;
}
if (snapshot.refreshToken) {
return (await refreshStoredAuthSession(snapshot.refreshToken)).accessToken;
}
expireStoredSession();
throw authRequiredError();
}
export const apiClient = createApiClient({
baseURL,
auth: {
getAccessToken: getStoredAccessToken,
getRefreshToken: getStoredRefreshToken,
setTokens: setStoredTokens,
clearTokens: clearStoredSession,
async refresh(refreshToken: string): Promise<AuthTokens> {
const data = await publicClient.post<RefreshResponse, { refresh_token: string }>(
"/api/auth/refresh",
{ refresh_token: refreshToken },
);
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
};
},
clearTokens: expireStoredSession,
refresh: refreshStoredAuthSession,
},
});
@@ -182,6 +249,7 @@ export const authApi = {
login(payload: LoginRequest) {
return publicClient.post<LoginResponse, LoginRequest>("/api/auth/login", payload);
},
refresh: refreshStoredAuthSession,
me() {
return apiClient.get<UserInfo>("/api/auth/me");
},
@@ -710,23 +778,37 @@ async function subscribeSSE<TEvent extends SSEEventShape>(
},
signal: AbortSignal,
): Promise<void> {
const accessToken = getStoredAccessToken();
if (!accessToken) {
throw new Error("missing access token");
}
const requestHeaders = new Headers(init.headers);
requestHeaders.set("Accept", "text/event-stream");
requestHeaders.set("Authorization", `Bearer ${accessToken}`);
requestHeaders.set("Authorization", `Bearer ${await getFreshStoredAccessToken()}`);
const response = await fetch(`${baseURL}${path}`, {
let response = await fetch(`${baseURL}${path}`, {
...init,
headers: requestHeaders,
signal,
});
if (response.status === 401) {
try {
const tokens = await refreshStoredAuthSession();
requestHeaders.set("Authorization", `Bearer ${tokens.accessToken}`);
response = await fetch(`${baseURL}${path}`, {
...init,
headers: requestHeaders,
signal,
});
} catch (error) {
expireStoredSession();
throw error;
}
}
if (!response.ok) {
throw await buildSSERequestError(response);
const error = await buildSSERequestError(response);
if (response.status === 401) {
expireStoredSession();
}
throw error;
}
if (!response.body) {
throw new Error("stream response body is empty");
+1
View File
@@ -4,6 +4,7 @@ const errorMessageMap: Record<string, string> = {
invalid_credentials: "邮箱或密码错误",
no_tenant: "当前账号未关联租户",
not_authenticated: "请先登录",
missing_bearer_token: "请先登录",
invalid_access_token: "登录状态已失效",
invalid_refresh_token: "刷新令牌已失效",
refresh_session_expired: "登录已过期,请重新登录",
+23 -1
View File
@@ -3,18 +3,22 @@ import type { AuthTokens, UserInfo } from "@geo/shared-types";
export interface SessionSnapshot {
accessToken: string | null;
refreshToken: string | null;
accessTokenExpiresAt: number | null;
user: UserInfo | null;
}
type SessionListener = (snapshot: SessionSnapshot) => void;
type AuthExpiredListener = () => void;
const STORAGE_KEY = "geo.admin-web.session";
const listeners = new Set<SessionListener>();
const authExpiredListeners = new Set<AuthExpiredListener>();
function emptySession(): SessionSnapshot {
return {
accessToken: null,
refreshToken: null,
accessTokenExpiresAt: null,
user: null,
};
}
@@ -32,6 +36,10 @@ function sanitizeSession(candidate: unknown): SessionSnapshot {
return {
accessToken: typeof value.accessToken === "string" ? value.accessToken : null,
refreshToken: typeof value.refreshToken === "string" ? value.refreshToken : null,
accessTokenExpiresAt:
typeof value.accessTokenExpiresAt === "number" && Number.isFinite(value.accessTokenExpiresAt)
? value.accessTokenExpiresAt
: null,
user: value.user && typeof value.user === "object" ? (value.user as UserInfo) : null,
};
}
@@ -40,6 +48,10 @@ function notify(snapshot: SessionSnapshot): void {
listeners.forEach((listener) => listener(snapshot));
}
function notifyAuthExpired(): void {
authExpiredListeners.forEach((listener) => listener());
}
export function readStoredSession(): SessionSnapshot {
if (!hasStorage()) {
return emptySession();
@@ -75,6 +87,7 @@ export function setStoredTokens(tokens: AuthTokens): SessionSnapshot {
return updateStoredSession({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
accessTokenExpiresAt: tokens.expiresAt ?? null,
});
}
@@ -82,12 +95,15 @@ export function setStoredUser(user: UserInfo | null): SessionSnapshot {
return updateStoredSession({ user });
}
export function clearStoredSession(): SessionSnapshot {
export function clearStoredSession(options: { notifyAuthExpired?: boolean } = {}): SessionSnapshot {
if (hasStorage()) {
window.localStorage.removeItem(STORAGE_KEY);
}
const next = emptySession();
notify(next);
if (options.notifyAuthExpired) {
notifyAuthExpired();
}
return next;
}
@@ -106,3 +122,9 @@ export function subscribeStoredSession(listener: SessionListener): () => void {
};
}
export function subscribeAuthExpired(listener: AuthExpiredListener): () => void {
authExpiredListeners.add(listener);
return () => {
authExpiredListeners.delete(listener);
};
}