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:
+102
-20
@@ -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");
|
||||
|
||||
@@ -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: "登录已过期,请重新登录",
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
|
||||
import App from "./App.vue";
|
||||
import { i18n } from "./i18n";
|
||||
import { subscribeAuthExpired } from "./lib/session";
|
||||
import { router } from "./router";
|
||||
import { pinia } from "./stores/pinia";
|
||||
import "./styles.css";
|
||||
@@ -70,6 +71,18 @@ const queryClient = new QueryClient({
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
subscribeAuthExpired(() => {
|
||||
const current = router.currentRoute.value;
|
||||
if (current.name === "login") {
|
||||
return;
|
||||
}
|
||||
|
||||
void router.replace({
|
||||
name: "login",
|
||||
query: current.fullPath ? { redirect: current.fullPath } : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const IconFont = createFromIconfontCN({
|
||||
// 需要替换为你自己真实项目的 js 地址,才能显示出你的 '#icon-line-global_undo'。
|
||||
scriptUrl: "//at.alicdn.com/t/c/font_5154382_922oh81rh1.js",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, ref } from "vue";
|
||||
|
||||
import type { LoginRequest, UserInfo } from "@geo/shared-types";
|
||||
|
||||
import { authApi } from "@/lib/api";
|
||||
import { authApi, refreshStoredAuthSession, shouldRefreshAccessToken } from "@/lib/api";
|
||||
import {
|
||||
clearStoredSession,
|
||||
readStoredSession,
|
||||
@@ -16,14 +16,79 @@ import {
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const accessToken = ref<string | null>(null);
|
||||
const refreshToken = ref<string | null>(null);
|
||||
const accessTokenExpiresAt = ref<number | null>(null);
|
||||
const user = ref<UserInfo | null>(null);
|
||||
const initialized = ref(false);
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let refreshTimer: number | null = null;
|
||||
let lifecycleRefreshRegistered = false;
|
||||
|
||||
function clearRefreshTimer(): void {
|
||||
if (refreshTimer) {
|
||||
window.clearTimeout(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefresh(): void {
|
||||
clearRefreshTimer();
|
||||
if (!refreshToken.value || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRefreshAt = accessTokenExpiresAt.value
|
||||
? accessTokenExpiresAt.value * 1000 - 2 * 60 * 1000
|
||||
: Date.now() + 30 * 1000;
|
||||
const delay = Math.max(30 * 1000, nextRefreshAt - Date.now());
|
||||
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
void refreshSession();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function sync(snapshot: SessionSnapshot = readStoredSession()): void {
|
||||
accessToken.value = snapshot.accessToken;
|
||||
refreshToken.value = snapshot.refreshToken;
|
||||
accessTokenExpiresAt.value = snapshot.accessTokenExpiresAt;
|
||||
user.value = snapshot.user;
|
||||
scheduleRefresh();
|
||||
}
|
||||
|
||||
async function refreshSession(): Promise<void> {
|
||||
if (!refreshToken.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshStoredAuthSession(refreshToken.value);
|
||||
} catch {
|
||||
clearStoredSession({ notifyAuthExpired: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSessionIfNeeded(minRemainingMs = 2 * 60 * 1000): Promise<void> {
|
||||
if (!refreshToken.value) {
|
||||
return;
|
||||
}
|
||||
if (!accessToken.value || shouldRefreshAccessToken(accessTokenExpiresAt.value, minRemainingMs)) {
|
||||
await refreshSession();
|
||||
}
|
||||
}
|
||||
|
||||
function registerLifecycleRefresh(): void {
|
||||
if (lifecycleRefreshRegistered || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
lifecycleRefreshRegistered = true;
|
||||
window.addEventListener("focus", () => {
|
||||
void refreshSessionIfNeeded();
|
||||
});
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void refreshSessionIfNeeded();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
@@ -32,13 +97,19 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
}
|
||||
|
||||
sync();
|
||||
if (accessToken.value) {
|
||||
try {
|
||||
registerLifecycleRefresh();
|
||||
|
||||
try {
|
||||
if (refreshToken.value) {
|
||||
await refreshSessionIfNeeded(Number.POSITIVE_INFINITY);
|
||||
}
|
||||
|
||||
if (accessToken.value) {
|
||||
const currentUser = await authApi.me();
|
||||
setStoredUser(currentUser);
|
||||
} catch {
|
||||
clearStoredSession();
|
||||
}
|
||||
} catch {
|
||||
clearStoredSession({ notifyAuthExpired: true });
|
||||
}
|
||||
|
||||
initialized.value = true;
|
||||
@@ -49,6 +120,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
setStoredTokens({
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
expiresAt: response.expires_at,
|
||||
});
|
||||
setStoredUser(response.user);
|
||||
return response.user;
|
||||
@@ -73,6 +145,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt,
|
||||
user,
|
||||
initialized,
|
||||
isAuthenticated,
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface ApiErrorEnvelope {
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
|
||||
Reference in New Issue
Block a user