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:
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user