From a967e5d211126da8d5c9da2f41ce78c862156da5 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Mon, 15 Jun 2026 20:57:21 +0330 Subject: [PATCH] =?UTF-8?q?fix(admin):=20keep=20admin=20panel=20logged=20i?= =?UTF-8?q?n=20=E2=80=94=20refresh=20the=20token=20instead=20of=20dying=20?= =?UTF-8?q?at=207=20days?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prod diag showed every /api/admin/* call returning 401 with "IDX10223: token expired, ValidTo 06/09" — the admin access token was 6 days dead and nothing renewed it, so cafes/tickets/integrations/settings all loaded empty. The admin web (unlike the café dashboard) had NO refresh logic at all: it only ever sent the access token, and its 401 handler early-returned on any error code before the login redirect, so the admin wasn't even bounced to login — pages just showed no data. Client (admin-client.ts): add a silent refresh-on-401 mirroring the dashboard — one shared in-flight POST /api/admin/auth/refresh for a burst of 401s, replay the original request on success, force-logout only on a definitive 4xx, and ride out a transient failure (API restarting during deploy) without logging out. Backend (AdminAuthService): make refresh non-rotating + sliding (reuse the presented refresh token and re-store it) instead of revoke-and-mint, so the dashboard's many concurrent refreshes don't race the rotated token — same fix already applied to the main API. Also bump admin tokens 7d/30d → 30d/365d to match the main API, so the session is long-lived even before the first refresh round-trip. tsc clean; Admin.API builds clean. Co-Authored-By: Claude Opus 4.8 --- .../Services/AdminAuthService.cs | 14 ++- src/Meezi.Admin.API/appsettings.json | 4 +- web/admin/src/lib/api/admin-client.ts | 100 ++++++++++++++++-- 3 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/Meezi.Admin.API/Services/AdminAuthService.cs b/src/Meezi.Admin.API/Services/AdminAuthService.cs index b1f60ed..430b91c 100644 --- a/src/Meezi.Admin.API/Services/AdminAuthService.cs +++ b/src/Meezi.Admin.API/Services/AdminAuthService.cs @@ -145,8 +145,11 @@ public class AdminAuthService : IAdminAuthService if (admin is null) return (false, null, "NOT_FOUND", "Admin no longer exists."); - await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken); - var tokens = await IssueTokensAsync(admin, cancellationToken); + // Non-rotating sliding refresh: reuse the presented token (re-stored to + // slide its TTL) instead of revoking + minting a new one. Rotation here + // raced across the admin dashboard's many concurrent calls and logged + // the admin out; reuse makes concurrent refreshes idempotent. + var tokens = await IssueTokensAsync(admin, cancellationToken, existingRefreshToken: request.RefreshToken); return (true, tokens, null, null); } @@ -195,10 +198,13 @@ public class AdminAuthService : IAdminAuthService private async Task IssueTokensAsync( Core.Entities.SystemAdmin admin, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string? existingRefreshToken = null) { var accessToken = _jwtTokenService.CreateAdminAccessToken(admin); - var refreshToken = _jwtTokenService.CreateRefreshToken(); + // Mint a fresh token only on a real login (existingRefreshToken == null); + // a refresh reuses + re-stores the presented token to slide its TTL. + var refreshToken = existingRefreshToken ?? _jwtTokenService.CreateRefreshToken(); var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30); await _refreshTokenStore.StoreAsync( diff --git a/src/Meezi.Admin.API/appsettings.json b/src/Meezi.Admin.API/appsettings.json index 9d22704..1b91229 100644 --- a/src/Meezi.Admin.API/appsettings.json +++ b/src/Meezi.Admin.API/appsettings.json @@ -14,8 +14,8 @@ "Key": "meezi-dev-secret-key-min-32-chars!!", "Issuer": "meezi", "Audience": "meezi-admin", - "AccessTokenExpiryDays": 7, - "RefreshTokenExpiryDays": 30 + "AccessTokenExpiryDays": 30, + "RefreshTokenExpiryDays": 365 }, "Cors": { "Origins": [ diff --git a/web/admin/src/lib/api/admin-client.ts b/web/admin/src/lib/api/admin-client.ts index 640ffbb..5ef4b59 100644 --- a/web/admin/src/lib/api/admin-client.ts +++ b/web/admin/src/lib/api/admin-client.ts @@ -1,5 +1,9 @@ -import axios, { type AxiosError } from "axios"; -import type { ApiResponse } from "./types"; +import axios, { + type AxiosError, + type InternalAxiosRequestConfig, +} from "axios"; +import type { ApiResponse, AuthTokenResponse } from "./types"; +import { useAdminAuthStore } from "@/lib/stores/admin-auth.store"; const baseURL = process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "http://localhost:5081"; @@ -17,20 +21,96 @@ adminApi.interceptors.request.use((config) => { return config; }); +/** 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 admin API being briefly down during a deploy. */ +type RefreshResult = { token: string | null; sessionInvalid: boolean }; + +/** + * Shared in-flight refresh so a burst of concurrent 401s (the admin dashboard + * loads cafes, tickets, integrations, settings… at once) triggers exactly one + * POST /api/admin/auth/refresh — otherwise they race and all but the first send + * a now-rotated refresh token and get logged out. + */ +let refreshPromise: Promise | null = null; + +async function refreshAdminToken(): Promise { + if (typeof window === "undefined") return { token: null, sessionInvalid: false }; + const refreshToken = localStorage.getItem("meezi_admin_refresh_token"); + if (!refreshToken) return { token: null, sessionInvalid: true }; + try { + // Bare axios (not `adminApi`) to avoid recursing through this interceptor. + const { data } = await axios.post>( + `${baseURL}/api/admin/auth/refresh`, + { refreshToken }, + { headers: { "Content-Type": "application/json" } } + ); + if (data.success && data.data) { + useAdminAuthStore.getState().setAuth(data.data); + return { token: data.data.accessToken, sessionInvalid: false }; + } + 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 (API restarting mid-deploy) is transient — + // do NOT log the admin out for it. + const status = (err as AxiosError)?.response?.status; + const sessionInvalid = status === 400 || status === 401 || status === 403; + return { token: null, sessionInvalid }; + } +} + +function forceAdminLogout(): void { + if (typeof window === "undefined") return; + localStorage.removeItem("meezi_admin_access_token"); + localStorage.removeItem("meezi_admin_refresh_token"); + localStorage.removeItem("meezi_admin_auth"); + const locale = window.location.pathname.split("/")[1] ?? "fa"; + window.location.href = `/${locale}/admin/login`; +} + adminApi.interceptors.response.use( (response) => response, - (error: AxiosError>) => { + 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/admin/auth/") ?? false; + + // Expired access token → silently refresh once, then replay the request. + // The 7-day admin access token used to just expire with no renewal, so + // every page stopped loading data until a manual re-login. + if ( + status === 401 && + original && + !original._retry && + typeof window !== "undefined" && + !onAuthPath + ) { + original._retry = true; + refreshPromise ??= refreshAdminToken().finally(() => { + refreshPromise = null; + }); + const { token, sessionInvalid } = await refreshPromise; + if (token) { + original.headers.Authorization = `Bearer ${token}`; + return adminApi(original); + } + // Couldn't refresh. Log out ONLY if the session is definitively invalid; + // a transient failure (API redeploying) just rejects so it can retry. + if (sessionInvalid) forceAdminLogout(); + return Promise.reject( + apiError?.code + ? new AdminApiClientError(apiError.code, apiError.message) + : error + ); + } + if (apiError?.code) { return Promise.reject(new AdminApiClientError(apiError.code, apiError.message)); } - if (error.response?.status === 401 && typeof window !== "undefined") { - localStorage.removeItem("meezi_admin_access_token"); - localStorage.removeItem("meezi_admin_refresh_token"); - localStorage.removeItem("meezi_admin_auth"); - const locale = window.location.pathname.split("/")[1] ?? "fa"; - window.location.href = `/${locale}/admin/login`; - } return Promise.reject(error); } );