feat(auth): extend refresh TTL and keep session on transient errors

Bumps refresh token lifetime to 720h with env overrides, and stops
clearing the local session for non-401 refresh failures so users aren't
logged out by transient network blips. Adds a 60s retry timer when a
refresh fails for non-auth reasons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 18:09:01 +08:00
parent 7e78b031d7
commit d1f2a14477
9 changed files with 115 additions and 12 deletions
+28 -3
View File
@@ -180,6 +180,10 @@ function expireStoredSession(): void {
clearStoredSession({ notifyAuthExpired: true });
}
export function isAuthSessionExpiredError(error: unknown): boolean {
return error instanceof ApiClientError && error.status === 401;
}
export function shouldRefreshAccessToken(
expiresAt: number | null,
minRemainingMs = accessRefreshSkewMs,
@@ -193,10 +197,12 @@ export async function refreshStoredAuthSession(refreshToken = getStoredRefreshTo
throw authRequiredError();
}
const requestedRefreshToken = refreshToken;
if (!storedRefreshPromise) {
storedRefreshPromise = publicClient
.post<RefreshResponse, { refresh_token: string }>("/api/auth/refresh", {
refresh_token: refreshToken,
refresh_token: requestedRefreshToken,
})
.then((data) => {
const tokens = toAuthTokens(data);
@@ -204,7 +210,24 @@ export async function refreshStoredAuthSession(refreshToken = getStoredRefreshTo
return tokens;
})
.catch((error) => {
expireStoredSession();
if (isAuthSessionExpiredError(error)) {
const current = readStoredSession();
if (
current.accessToken &&
current.refreshToken &&
current.refreshToken !== requestedRefreshToken
) {
const tokens = {
accessToken: current.accessToken,
refreshToken: current.refreshToken,
expiresAt: current.accessTokenExpiresAt ?? undefined,
};
setStoredTokens(tokens);
return tokens;
}
expireStoredSession();
}
throw error;
})
.finally(() => {
@@ -798,7 +821,9 @@ async function subscribeSSE<TEvent extends SSEEventShape>(
signal,
});
} catch (error) {
expireStoredSession();
if (isAuthSessionExpiredError(error)) {
expireStoredSession();
}
throw error;
}
}
+27 -5
View File
@@ -3,7 +3,12 @@ import { computed, ref } from "vue";
import type { LoginRequest, UserInfo } from "@geo/shared-types";
import { authApi, refreshStoredAuthSession, shouldRefreshAccessToken } from "@/lib/api";
import {
authApi,
isAuthSessionExpiredError,
refreshStoredAuthSession,
shouldRefreshAccessToken,
} from "@/lib/api";
import {
clearStoredSession,
readStoredSession,
@@ -61,11 +66,26 @@ export const useAuthStore = defineStore("auth", () => {
try {
await refreshStoredAuthSession(refreshToken.value);
} catch {
clearStoredSession({ notifyAuthExpired: true });
} catch (error) {
if (isAuthSessionExpiredError(error)) {
clearStoredSession({ notifyAuthExpired: true });
return;
}
scheduleRefreshRetry();
}
}
function scheduleRefreshRetry(): void {
clearRefreshTimer();
if (!refreshToken.value || typeof window === "undefined") {
return;
}
refreshTimer = window.setTimeout(() => {
void refreshSession();
}, 60 * 1000);
}
async function refreshSessionIfNeeded(minRemainingMs = 2 * 60 * 1000): Promise<void> {
if (!refreshToken.value) {
return;
@@ -108,8 +128,10 @@ export const useAuthStore = defineStore("auth", () => {
const currentUser = await authApi.me();
setStoredUser(currentUser);
}
} catch {
clearStoredSession({ notifyAuthExpired: true });
} catch (error) {
if (isAuthSessionExpiredError(error)) {
clearStoredSession({ notifyAuthExpired: true });
}
}
initialized.value = true;