diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index 1c55605..2a1ad2a 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -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("/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( signal, }); } catch (error) { - expireStoredSession(); + if (isAuthSessionExpiredError(error)) { + expireStoredSession(); + } throw error; } } diff --git a/apps/admin-web/src/stores/auth.ts b/apps/admin-web/src/stores/auth.ts index 20b686d..8b060b4 100644 --- a/apps/admin-web/src/stores/auth.ts +++ b/apps/admin-web/src/stores/auth.ts @@ -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 { 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; diff --git a/deploy/config.yaml b/deploy/config.yaml index 14d5615..d39a998 100644 --- a/deploy/config.yaml +++ b/deploy/config.yaml @@ -94,7 +94,7 @@ jwt: # Override via JWT_SECRET env var in .env secret: "change-me-in-production" access_ttl: 15m - refresh_ttl: 168h + refresh_ttl: 720h log: level: info,warn,error diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml index 34f1475..dff8d44 100644 --- a/deploy/docker-compose.yaml +++ b/deploy/docker-compose.yaml @@ -14,6 +14,8 @@ x-app-env: &app-env LLM_API_KEY: ${LLM_API_KEY} SILICONFLOW_API_KEY: ${SILICONFLOW_API_KEY} 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_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin} diff --git a/packages/http-client/src/index.ts b/packages/http-client/src/index.ts index 048581f..30cf4ba 100644 --- a/packages/http-client/src/index.ts +++ b/packages/http-client/src/index.ts @@ -133,8 +133,11 @@ export function createApiClient(options: CreateApiClientOptions = {}): ApiClient `Bearer ${nextTokens.accessToken}`; return client.request(originalRequest); } catch (refreshError) { - auth.clearTokens(); - throw normalizeError(refreshError); + const normalizedRefreshError = normalizeError(refreshError); + if (normalizedRefreshError.status === 401) { + auth.clearTokens(); + } + throw normalizedRefreshError; } } diff --git a/server/configs/config.local.yaml.example b/server/configs/config.local.yaml.example index 80e891e..9177c4e 100644 --- a/server/configs/config.local.yaml.example +++ b/server/configs/config.local.yaml.example @@ -127,6 +127,8 @@ object_storage: jwt: secret: "your-local-secret" + access_ttl: 15m + refresh_ttl: 720h llm: provider: ark diff --git a/server/configs/config.yaml b/server/configs/config.yaml index b2ed809..7c4f594 100644 --- a/server/configs/config.yaml +++ b/server/configs/config.yaml @@ -137,7 +137,7 @@ cache: jwt: secret: "dev-secret-change-in-production" access_ttl: 15m - refresh_ttl: 168h + refresh_ttl: 720h log: level: debug,info,warn,error diff --git a/server/internal/shared/config/config.go b/server/internal/shared/config/config.go index 82fddac..7fd9ae5 100644 --- a/server/internal/shared/config/config.go +++ b/server/internal/shared/config/config.go @@ -376,6 +376,15 @@ func applyEnvOverrides(cfg *Config) { 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 { 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 { if value < fallback { return fallback diff --git a/server/internal/shared/config/config_test.go b/server/internal/shared/config/config_test.go index 59a0924..71f36aa 100644 --- a/server/internal/shared/config/config_test.go +++ b/server/internal/shared/config/config_test.go @@ -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) { configPath := writeTestConfig(t, ` membership: {}