mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: implement concurrent-safe token refreshing with session-aware error handling
This commit is contained in:
@@ -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?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
11
libs/auth/vite.config.mts
Normal 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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user