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 }); clearStoredSession({ notifyAuthExpired: true });
} }
export function isAuthSessionExpiredError(error: unknown): boolean {
return error instanceof ApiClientError && error.status === 401;
}
export function shouldRefreshAccessToken( export function shouldRefreshAccessToken(
expiresAt: number | null, expiresAt: number | null,
minRemainingMs = accessRefreshSkewMs, minRemainingMs = accessRefreshSkewMs,
@@ -193,10 +197,12 @@ export async function refreshStoredAuthSession(refreshToken = getStoredRefreshTo
throw authRequiredError(); throw authRequiredError();
} }
const requestedRefreshToken = refreshToken;
if (!storedRefreshPromise) { if (!storedRefreshPromise) {
storedRefreshPromise = publicClient storedRefreshPromise = publicClient
.post<RefreshResponse, { refresh_token: string }>("/api/auth/refresh", { .post<RefreshResponse, { refresh_token: string }>("/api/auth/refresh", {
refresh_token: refreshToken, refresh_token: requestedRefreshToken,
}) })
.then((data) => { .then((data) => {
const tokens = toAuthTokens(data); const tokens = toAuthTokens(data);
@@ -204,7 +210,24 @@ export async function refreshStoredAuthSession(refreshToken = getStoredRefreshTo
return tokens; return tokens;
}) })
.catch((error) => { .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; throw error;
}) })
.finally(() => { .finally(() => {
@@ -798,7 +821,9 @@ async function subscribeSSE<TEvent extends SSEEventShape>(
signal, signal,
}); });
} catch (error) { } catch (error) {
expireStoredSession(); if (isAuthSessionExpiredError(error)) {
expireStoredSession();
}
throw error; throw error;
} }
} }
+27 -5
View File
@@ -3,7 +3,12 @@ import { computed, ref } from "vue";
import type { LoginRequest, UserInfo } from "@geo/shared-types"; import type { LoginRequest, UserInfo } from "@geo/shared-types";
import { authApi, refreshStoredAuthSession, shouldRefreshAccessToken } from "@/lib/api"; import {
authApi,
isAuthSessionExpiredError,
refreshStoredAuthSession,
shouldRefreshAccessToken,
} from "@/lib/api";
import { import {
clearStoredSession, clearStoredSession,
readStoredSession, readStoredSession,
@@ -61,11 +66,26 @@ export const useAuthStore = defineStore("auth", () => {
try { try {
await refreshStoredAuthSession(refreshToken.value); await refreshStoredAuthSession(refreshToken.value);
} catch { } catch (error) {
clearStoredSession({ notifyAuthExpired: true }); 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> { async function refreshSessionIfNeeded(minRemainingMs = 2 * 60 * 1000): Promise<void> {
if (!refreshToken.value) { if (!refreshToken.value) {
return; return;
@@ -108,8 +128,10 @@ export const useAuthStore = defineStore("auth", () => {
const currentUser = await authApi.me(); const currentUser = await authApi.me();
setStoredUser(currentUser); setStoredUser(currentUser);
} }
} catch { } catch (error) {
clearStoredSession({ notifyAuthExpired: true }); if (isAuthSessionExpiredError(error)) {
clearStoredSession({ notifyAuthExpired: true });
}
} }
initialized.value = true; initialized.value = true;
+1 -1
View File
@@ -94,7 +94,7 @@ jwt:
# Override via JWT_SECRET env var in .env # Override via JWT_SECRET env var in .env
secret: "change-me-in-production" secret: "change-me-in-production"
access_ttl: 15m access_ttl: 15m
refresh_ttl: 168h refresh_ttl: 720h
log: log:
level: info,warn,error level: info,warn,error
+2
View File
@@ -14,6 +14,8 @@ x-app-env: &app-env
LLM_API_KEY: ${LLM_API_KEY} LLM_API_KEY: ${LLM_API_KEY}
SILICONFLOW_API_KEY: ${SILICONFLOW_API_KEY} SILICONFLOW_API_KEY: ${SILICONFLOW_API_KEY}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production} JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_ACCESS_TTL: ${JWT_ACCESS_TTL:-15m}
JWT_REFRESH_TTL: ${JWT_REFRESH_TTL:-720h}
OBJECT_STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin} OBJECT_STORAGE_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
OBJECT_STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} OBJECT_STORAGE_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
+5 -2
View File
@@ -133,8 +133,11 @@ export function createApiClient(options: CreateApiClientOptions = {}): ApiClient
`Bearer ${nextTokens.accessToken}`; `Bearer ${nextTokens.accessToken}`;
return client.request(originalRequest); return client.request(originalRequest);
} catch (refreshError) { } catch (refreshError) {
auth.clearTokens(); const normalizedRefreshError = normalizeError(refreshError);
throw normalizeError(refreshError); if (normalizedRefreshError.status === 401) {
auth.clearTokens();
}
throw normalizedRefreshError;
} }
} }
+2
View File
@@ -127,6 +127,8 @@ object_storage:
jwt: jwt:
secret: "your-local-secret" secret: "your-local-secret"
access_ttl: 15m
refresh_ttl: 720h
llm: llm:
provider: ark provider: ark
+1 -1
View File
@@ -137,7 +137,7 @@ cache:
jwt: jwt:
secret: "dev-secret-change-in-production" secret: "dev-secret-change-in-production"
access_ttl: 15m access_ttl: 15m
refresh_ttl: 168h refresh_ttl: 720h
log: log:
level: debug,info,warn,error level: debug,info,warn,error
+22
View File
@@ -376,6 +376,15 @@ func applyEnvOverrides(cfg *Config) {
return return
} }
if secret, ok := lookupNonEmptyEnv("JWT_SECRET"); ok {
cfg.JWT.Secret = secret
}
if ttl, ok := lookupDurationEnv("JWT_ACCESS_TTL"); ok {
cfg.JWT.AccessTTL = ttl
}
if ttl, ok := lookupDurationEnv("JWT_REFRESH_TTL"); ok {
cfg.JWT.RefreshTTL = ttl
}
if apiKey, ok := lookupNonEmptyEnv("LLM_API_KEY"); ok { if apiKey, ok := lookupNonEmptyEnv("LLM_API_KEY"); ok {
cfg.LLM.APIKey = apiKey cfg.LLM.APIKey = apiKey
} }
@@ -784,6 +793,19 @@ func lookupBoolEnv(key string) (bool, bool) {
} }
} }
func lookupDurationEnv(key string) (time.Duration, bool) {
value, ok := lookupNonEmptyEnv(key)
if !ok {
return 0, false
}
duration, err := time.ParseDuration(value)
if err != nil {
return 0, false
}
return duration, true
}
func maxInt(value, fallback int) int { func maxInt(value, fallback int) int {
if value < fallback { if value < fallback {
return fallback return fallback
@@ -166,6 +166,33 @@ scheduler:
} }
} }
func TestLoadPrefersEnvJWTSettings(t *testing.T) {
t.Setenv("JWT_SECRET", "env-jwt-secret")
t.Setenv("JWT_ACCESS_TTL", "20m")
t.Setenv("JWT_REFRESH_TTL", "720h")
configPath := writeTestConfig(t, `
jwt:
secret: config-jwt-secret
access_ttl: 15m
refresh_ttl: 168h
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.JWT.Secret != "env-jwt-secret" {
t.Fatalf("expected env jwt secret, got %q", cfg.JWT.Secret)
}
if cfg.JWT.AccessTTL != 20*time.Minute {
t.Fatalf("expected env jwt access ttl 20m, got %s", cfg.JWT.AccessTTL)
}
if cfg.JWT.RefreshTTL != 720*time.Hour {
t.Fatalf("expected env jwt refresh ttl 720h, got %s", cfg.JWT.RefreshTTL)
}
}
func TestLoadAppliesMembershipDefaults(t *testing.T) { func TestLoadAppliesMembershipDefaults(t *testing.T) {
configPath := writeTestConfig(t, ` configPath := writeTestConfig(t, `
membership: {} membership: {}