fix(admin): keep admin panel logged in — refresh the token instead of dying at 7 days
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m35s
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m35s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -145,8 +145,11 @@ public class AdminAuthService : IAdminAuthService
|
|||||||
if (admin is null)
|
if (admin is null)
|
||||||
return (false, null, "NOT_FOUND", "Admin no longer exists.");
|
return (false, null, "NOT_FOUND", "Admin no longer exists.");
|
||||||
|
|
||||||
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken);
|
// Non-rotating sliding refresh: reuse the presented token (re-stored to
|
||||||
var tokens = await IssueTokensAsync(admin, cancellationToken);
|
// 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);
|
return (true, tokens, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,10 +198,13 @@ public class AdminAuthService : IAdminAuthService
|
|||||||
|
|
||||||
private async Task<AuthTokenResponse> IssueTokensAsync(
|
private async Task<AuthTokenResponse> IssueTokensAsync(
|
||||||
Core.Entities.SystemAdmin admin,
|
Core.Entities.SystemAdmin admin,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
string? existingRefreshToken = null)
|
||||||
{
|
{
|
||||||
var accessToken = _jwtTokenService.CreateAdminAccessToken(admin);
|
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);
|
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
|
||||||
|
|
||||||
await _refreshTokenStore.StoreAsync(
|
await _refreshTokenStore.StoreAsync(
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
"Key": "meezi-dev-secret-key-min-32-chars!!",
|
"Key": "meezi-dev-secret-key-min-32-chars!!",
|
||||||
"Issuer": "meezi",
|
"Issuer": "meezi",
|
||||||
"Audience": "meezi-admin",
|
"Audience": "meezi-admin",
|
||||||
"AccessTokenExpiryDays": 7,
|
"AccessTokenExpiryDays": 30,
|
||||||
"RefreshTokenExpiryDays": 30
|
"RefreshTokenExpiryDays": 365
|
||||||
},
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"Origins": [
|
"Origins": [
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import axios, { type AxiosError } from "axios";
|
import axios, {
|
||||||
import type { ApiResponse } from "./types";
|
type AxiosError,
|
||||||
|
type InternalAxiosRequestConfig,
|
||||||
|
} from "axios";
|
||||||
|
import type { ApiResponse, AuthTokenResponse } from "./types";
|
||||||
|
import { useAdminAuthStore } from "@/lib/stores/admin-auth.store";
|
||||||
|
|
||||||
const baseURL =
|
const baseURL =
|
||||||
process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "http://localhost:5081";
|
process.env.NEXT_PUBLIC_ADMIN_API_URL ?? "http://localhost:5081";
|
||||||
@@ -17,19 +21,95 @@ adminApi.interceptors.request.use((config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
adminApi.interceptors.response.use(
|
/** Result of a refresh attempt. `sessionInvalid` is true ONLY when the session
|
||||||
(response) => response,
|
* is definitively gone (no/expired/revoked refresh token) — never for a
|
||||||
(error: AxiosError<ApiResponse<unknown>>) => {
|
* transient failure like the admin API being briefly down during a deploy. */
|
||||||
const apiError = error.response?.data?.error;
|
type RefreshResult = { token: string | null; sessionInvalid: boolean };
|
||||||
if (apiError?.code) {
|
|
||||||
return Promise.reject(new AdminApiClientError(apiError.code, apiError.message));
|
/**
|
||||||
|
* 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<RefreshResult> | null = null;
|
||||||
|
|
||||||
|
async function refreshAdminToken(): Promise<RefreshResult> {
|
||||||
|
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<ApiResponse<AuthTokenResponse>>(
|
||||||
|
`${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 };
|
||||||
}
|
}
|
||||||
if (error.response?.status === 401 && typeof window !== "undefined") {
|
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_access_token");
|
||||||
localStorage.removeItem("meezi_admin_refresh_token");
|
localStorage.removeItem("meezi_admin_refresh_token");
|
||||||
localStorage.removeItem("meezi_admin_auth");
|
localStorage.removeItem("meezi_admin_auth");
|
||||||
const locale = window.location.pathname.split("/")[1] ?? "fa";
|
const locale = window.location.pathname.split("/")[1] ?? "fa";
|
||||||
window.location.href = `/${locale}/admin/login`;
|
window.location.href = `/${locale}/admin/login`;
|
||||||
|
}
|
||||||
|
|
||||||
|
adminApi.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError<ApiResponse<unknown>>) => {
|
||||||
|
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));
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user