From 82d1cf8e9eb128add4a53fac3438fcb830c7762d Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Mon, 15 Jun 2026 20:42:38 +0330 Subject: [PATCH] fix(auth): stop logging users out on every deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostic on prod confirmed the backend keeps sessions valid across deploys (stable 64-char JWT key, 30-day access tokens, 62 refresh tokens persisting in Redis with appendonly; redis/db never restart on deploy). The forced logout was client-side: 1. The axios refresh path treated ANY refresh failure as "session gone" and nuked the tokens. During the ~30s API restart window of a deploy, the refresh POST gets a 502/timeout (transient) → user kicked to /login. Now refresh distinguishes a definitive 4xx (truly invalid/expired refresh → log out) from a transient network/5xx failure (reject + keep the session; retry later). Refresh tokens are opaque Redis GUIDs, so they survive even a key rotation — the only thing that was breaking sessions was this over-eager logout. 2. PWA service worker served a stale app shell after an update, pointing at JS chunks the new build replaced. Added skipWaiting + clientsClaim + cleanupOutdatedCaches and a NetworkFirst handler for navigations so the HTML and its chunk refs always match the live deploy; hashed static stays CacheFirst. Net: a normal update no longer logs anyone out. tsc clean. Co-Authored-By: Claude Opus 4.8 --- web/dashboard/next.config.ts | 20 +++++++- web/dashboard/src/lib/api/client.ts | 77 +++++++++++++++++++---------- 2 files changed, 70 insertions(+), 27 deletions(-) diff --git a/web/dashboard/next.config.ts b/web/dashboard/next.config.ts index f2ddb4b..81b634b 100644 --- a/web/dashboard/next.config.ts +++ b/web/dashboard/next.config.ts @@ -12,8 +12,26 @@ const withPWA = withPWAInit({ disable: process.env.NODE_ENV === "development", workboxOptions: { disableDevLogs: true, + // Pick up a new deploy promptly and never serve a stale shell that points + // at JS chunks the new build replaced (which looked like being logged out + // after every update). + skipWaiting: true, + clientsClaim: true, + cleanupOutdatedCaches: true, runtimeCaching: [ - // App shell: cache-first, very long TTL + // HTML navigations: always try the network first so the document and its + // chunk references match the currently-deployed build; fall back to cache + // only when offline. + { + urlPattern: ({ request }: { request: Request }) => request.mode === "navigate", + handler: "NetworkFirst", + options: { + cacheName: "pages", + networkTimeoutSeconds: 5, + expiration: { maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }, + }, + }, + // Hashed static chunks are immutable per build — cache-first is safe and fast. { urlPattern: /\/_next\/static\//, handler: "CacheFirst", diff --git a/web/dashboard/src/lib/api/client.ts b/web/dashboard/src/lib/api/client.ts index 85b50ac..89de57a 100644 --- a/web/dashboard/src/lib/api/client.ts +++ b/web/dashboard/src/lib/api/client.ts @@ -36,12 +36,17 @@ api.interceptors.request.use((config) => { * Shared in-flight refresh promise so that a burst of concurrent 401s triggers * exactly one POST /api/auth/refresh instead of one per failed request. */ -let refreshPromise: Promise | null = null; +let refreshPromise: Promise | null = null; -async function refreshAccessToken(): Promise { - if (typeof window === "undefined") return null; +/** Result of a refresh attempt. `sessionInvalid` is true ONLY when the session + * is definitively gone (no/expired/revoked refresh token) — never for a + * transient failure like the API being briefly down during a deploy. */ +type RefreshResult = { token: string | null; sessionInvalid: boolean }; + +async function refreshAccessToken(): Promise { + if (typeof window === "undefined") return { token: null, sessionInvalid: false }; const refreshToken = localStorage.getItem("meezi_refresh_token"); - if (!refreshToken) return null; + if (!refreshToken) return { token: null, sessionInvalid: true }; try { // Bare axios call (not `api`) to avoid recursing through this interceptor. const { data } = await axios.post>( @@ -49,21 +54,44 @@ async function refreshAccessToken(): Promise { { refreshToken }, { headers: { "Content-Type": "application/json" } } ); - if (!data.success || !data.data) return null; - useAuthStore.getState().setAuth(data.data); - return data.data.accessToken; - } catch { - return null; + if (data.success && data.data) { + useAuthStore.getState().setAuth(data.data); + return { token: data.data.accessToken, sessionInvalid: false }; + } + // Server answered but rejected the token → session truly invalid. + return { token: null, sessionInvalid: true }; + } catch (err) { + // Only a definitive 4xx from /auth/refresh means the session is gone. A + // network error, timeout, or 5xx (e.g. the API restarting mid-deploy) is + // transient — do NOT log the user out for it. + const status = (err as AxiosError)?.response?.status; + const sessionInvalid = status === 400 || status === 401 || status === 403; + return { token: null, sessionInvalid }; } } +function forceLogout(): void { + if (typeof window === "undefined") return; + const path = window.location.pathname; + const isPublicGuest = path.startsWith("/q"); + const isAdmin = path.includes("/admin"); + if (isPublicGuest || isAdmin) return; + localStorage.removeItem("meezi_access_token"); + localStorage.removeItem("meezi_refresh_token"); + localStorage.removeItem("meezi_auth"); + const locale = path.split("/")[1] ?? "fa"; + window.location.href = `/${locale}/login`; +} + api.interceptors.response.use( (response) => response, async (error: AxiosError>) => { const status = error.response?.status; + const apiError = error.response?.data?.error; const original = error.config as | (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined; + const onAuthPath = original?.url?.includes("/api/auth/") ?? false; // Expired access token → try a one-time refresh, then replay the request. if ( @@ -71,35 +99,32 @@ api.interceptors.response.use( original && !original._retry && typeof window !== "undefined" && - !original.url?.includes("/api/auth/") + !onAuthPath ) { original._retry = true; refreshPromise ??= refreshAccessToken().finally(() => { refreshPromise = null; }); - const newToken = await refreshPromise; - if (newToken) { - original.headers.Authorization = `Bearer ${newToken}`; + const { token, sessionInvalid } = await refreshPromise; + if (token) { + original.headers.Authorization = `Bearer ${token}`; return api(original); } + // Refresh couldn't get a new token. Log out ONLY if the session is + // definitively invalid — a transient failure (API redeploying, network + // blip) just rejects so the request can be retried, keeping the user + // logged in across deploys. + if (sessionInvalid) forceLogout(); + return Promise.reject( + apiError?.code + ? new ApiClientError(apiError.code, apiError.message, undefined, status) + : error + ); } - const apiError = error.response?.data?.error; if (apiError?.code) { return Promise.reject(new ApiClientError(apiError.code, apiError.message, undefined, status)); } - if (status === 401 && typeof window !== "undefined") { - const path = window.location.pathname; - const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q"); - const isAdmin = path.includes("/admin"); - if (!isPublicGuest && !isAdmin) { - localStorage.removeItem("meezi_access_token"); - localStorage.removeItem("meezi_refresh_token"); - localStorage.removeItem("meezi_auth"); - const locale = path.split("/")[1] ?? "fa"; - window.location.href = `/${locale}/login`; - } - } return Promise.reject(error); } );