feat: implement concurrent-safe token refreshing with session-aware error handling

This commit is contained in:
estifanos
2026-08-19 09:00:05 +00:00
parent b860e57227
commit 1eeb4c5cc3
4 changed files with 76 additions and 9 deletions

View File

@@ -41,9 +41,14 @@ export const baseQueryWithReauth: BaseQueryFn<
try { try {
await _onTokenExpired(); await _onTokenExpired();
result = await baseQuery(args, api, extraOptions); result = await baseQuery(args, api, extraOptions);
} catch { } catch (err) {
// Only a rejected refresh token ends the session. A network blip or a
// 5xx leaves the original 401 for the screen to report, rather than
// throwing the user out of a session that is still valid.
if ((err as { sessionExpired?: boolean })?.sessionExpired) {
_onAuthFailure?.(); _onAuthFailure?.();
} }
}
} else { } else {
_onAuthFailure?.(); _onAuthFailure?.();
} }

View File

@@ -2,7 +2,8 @@ import { useEffect, useState, type ReactNode } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { PageLoader } from '@ema-platform/ui'; import { PageLoader } from '@ema-platform/ui';
import { authStorage } from '../utils/auth-storage'; import { authStorage } from '../utils/auth-storage';
import { hydrateAuth, logout, setUser } from '../store/auth.slice'; import { hydrateAuth, logout, setToken, setUser } from '../store/auth.slice';
import { refreshAccessToken } from '../utils/refresh-token';
import type { AuthUser } from '../types/auth.types'; import type { AuthUser } from '../types/auth.types';
const BASE_API_URL = const BASE_API_URL =
@@ -38,10 +39,27 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
} }
try { try {
const response = await fetch(`${BASE_API_URL}/auth/me`, { let response = await fetch(`${BASE_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
// An expired access token is the normal state after a day away — spend
// the refresh token before deciding the session is over. Without this
// a lapsed token logs the user out on load even though the credential
// to renew it is sitting right next to it in storage.
if (response.status === 401 || response.status === 403) {
try {
const fresh = await refreshAccessToken();
dispatch(setToken(fresh));
response = await fetch(`${BASE_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${fresh}` },
});
} catch {
// No refresh token, or the server rejected it — fall through to
// the logout below.
}
}
if (response.ok) { if (response.ok) {
const user = (await response.json()) as AuthUser; const user = (await response.json()) as AuthUser;
// Keep the persisted session as the source of truth when it is // Keep the persisted session as the source of truth when it is

View File

@@ -10,21 +10,54 @@ interface RefreshResponse {
refreshToken: string; refreshToken: string;
} }
export async function refreshAccessToken(): Promise<string> { /**
* Marks the one failure that actually ends a session: the server rejecting the
* refresh token. Read structurally by the API layer's 401 handler — a shared
* error class would mean `libs/api` importing `libs/auth`, which already
* imports `libs/api`.
*/
const sessionExpired = (message: string) =>
Object.assign(new Error(message), { sessionExpired: true });
let inFlight: Promise<string> | null = null;
/**
* Concurrent 401s must share one refresh. A page fires several requests at
* once; without this each one POSTs the same refresh token, the server rotates
* on the first and rejects the rest, and the losers tear down the session the
* winner just renewed.
*/
export function refreshAccessToken(): Promise<string> {
inFlight ??= runRefresh().finally(() => {
inFlight = null;
});
return inFlight;
}
async function runRefresh(): Promise<string> {
const refreshToken = authStorage.getRefreshToken(); const refreshToken = authStorage.getRefreshToken();
if (!refreshToken) throw new Error("No refresh token available"); if (!refreshToken) throw sessionExpired("No refresh token available");
// The IAM service exposes this as `refresh-token`; posting to `/auth/refresh` // The IAM service exposes this as `refresh-token`; posting to `/auth/refresh`
// 404s, which the catch below turns into a silent logout. // 404s, which the caller would turn into a silent logout.
const response = await fetch(`${BASE_API_URL}/auth/refresh-token`, { const response = await fetch(`${BASE_API_URL}/auth/refresh-token`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }), body: JSON.stringify({ refreshToken }),
}); });
if (
response.status === 400 ||
response.status === 401 ||
response.status === 403
) {
throw sessionExpired("Refresh token rejected");
}
// Anything else is the API having a bad minute — a 502, a proxy timeout. The
// session is still valid, so leave it alone and let the screen report it.
if (!response.ok) { if (!response.ok) {
authStorage.clear(); throw new Error(`Token refresh failed (${response.status})`);
throw new Error("Token refresh failed");
} }
const data: RefreshResponse = await response.json(); const data: RefreshResponse = await response.json();

11
libs/auth/vite.config.mts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
export default defineConfig({
test: {
watch: false,
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts'],
reporters: ['default'],
},
});