From 1eeb4c5cc3d3cc7a417a2499d746819c24b3c5e8 Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 19 Aug 2026 09:00:05 +0000 Subject: [PATCH] feat: implement concurrent-safe token refreshing with session-aware error handling --- .../lib/base-api/base-query-with-reauth.ts | 9 +++- .../auth/src/lib/components/AuthBootstrap.tsx | 22 +++++++++- libs/auth/src/lib/utils/refresh-token.ts | 43 ++++++++++++++++--- libs/auth/vite.config.mts | 11 +++++ 4 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 libs/auth/vite.config.mts diff --git a/libs/api/src/lib/base-api/base-query-with-reauth.ts b/libs/api/src/lib/base-api/base-query-with-reauth.ts index 9047cfb56..052a72fc1 100644 --- a/libs/api/src/lib/base-api/base-query-with-reauth.ts +++ b/libs/api/src/lib/base-api/base-query-with-reauth.ts @@ -41,8 +41,13 @@ export const baseQueryWithReauth: BaseQueryFn< try { await _onTokenExpired(); result = await baseQuery(args, api, extraOptions); - } catch { - _onAuthFailure?.(); + } 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?.(); + } } } else { _onAuthFailure?.(); diff --git a/libs/auth/src/lib/components/AuthBootstrap.tsx b/libs/auth/src/lib/components/AuthBootstrap.tsx index a44b26471..5d1f1735f 100644 --- a/libs/auth/src/lib/components/AuthBootstrap.tsx +++ b/libs/auth/src/lib/components/AuthBootstrap.tsx @@ -2,7 +2,8 @@ import { useEffect, useState, type ReactNode } from 'react'; import { useDispatch } from 'react-redux'; import { PageLoader } from '@ema-platform/ui'; 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'; const BASE_API_URL = @@ -38,10 +39,27 @@ export function AuthBootstrap({ children }: { children: ReactNode }) { } try { - const response = await fetch(`${BASE_API_URL}/auth/me`, { + let response = await fetch(`${BASE_API_URL}/auth/me`, { 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) { const user = (await response.json()) as AuthUser; // Keep the persisted session as the source of truth when it is diff --git a/libs/auth/src/lib/utils/refresh-token.ts b/libs/auth/src/lib/utils/refresh-token.ts index 8187473d4..30ec94e1a 100644 --- a/libs/auth/src/lib/utils/refresh-token.ts +++ b/libs/auth/src/lib/utils/refresh-token.ts @@ -10,21 +10,54 @@ interface RefreshResponse { refreshToken: string; } -export async function refreshAccessToken(): Promise { +/** + * 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 | 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 { + inFlight ??= runRefresh().finally(() => { + inFlight = null; + }); + return inFlight; +} + +async function runRefresh(): Promise { 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` - // 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`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, 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) { - authStorage.clear(); - throw new Error("Token refresh failed"); + throw new Error(`Token refresh failed (${response.status})`); } const data: RefreshResponse = await response.json(); diff --git a/libs/auth/vite.config.mts b/libs/auth/vite.config.mts new file mode 100644 index 000000000..04bd79f42 --- /dev/null +++ b/libs/auth/vite.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + test: { + watch: false, + globals: true, + environment: 'node', + include: ['src/**/*.spec.ts'], + reporters: ['default'], + }, +});