feat(auth): implement refresh token flow with 401 auto-retry and dedup

This commit is contained in:
saravanakumardb1 2026-02-28 02:44:56 -08:00
parent 8ad31af72a
commit 5e8cbbf556
2 changed files with 75 additions and 0 deletions

View File

@ -10,6 +10,7 @@ import {
registerUser,
getMe,
setAuthToken,
setRefreshToken,
setSyncEnabled,
isAuthenticated as checkAuth,
type AuthUser,
@ -57,6 +58,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
const result = await loginUser(email, password);
setAuthToken(result.accessToken);
setRefreshToken(result.refreshToken);
setSyncEnabled(true);
setUser(result.user);
return true;
@ -72,6 +74,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
const result = await registerUser(email, password, displayName);
setAuthToken(result.accessToken);
setRefreshToken(result.refreshToken);
setSyncEnabled(true);
setUser(result.user);
return true;

View File

@ -57,6 +57,7 @@ export interface OfflineQueueItem {
const STORAGE_KEYS = {
authToken: 'chronomind-auth-token',
refreshToken: 'chronomind-refresh-token',
lastSync: 'chronomind-platform-last-sync',
offlineQueue: 'chronomind-offline-queue',
syncEnabled: 'chronomind-platform-sync-enabled',
@ -76,6 +77,68 @@ function getAuthToken(): string | null {
return localStorage.getItem(STORAGE_KEYS.authToken);
}
function getRefreshToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(STORAGE_KEYS.refreshToken);
}
export function setRefreshToken(token: string | null): void {
if (typeof window === 'undefined') return;
if (token) {
localStorage.setItem(STORAGE_KEYS.refreshToken, token);
} else {
localStorage.removeItem(STORAGE_KEYS.refreshToken);
}
}
let _refreshPromise: Promise<boolean> | null = null;
/**
* Attempt to refresh the access token using the stored refresh token.
* Returns true if refresh succeeded, false otherwise.
* Deduplicates concurrent refresh attempts.
*/
export async function refreshAccessToken(): Promise<boolean> {
if (_refreshPromise) return _refreshPromise;
_refreshPromise = (async () => {
const rt = getRefreshToken();
if (!rt) return false;
try {
const res = await fetch(`${getBaseUrl()}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-product-id': PRODUCT_ID,
'x-request-id': crypto.randomUUID(),
},
body: JSON.stringify({ refreshToken: rt }),
});
if (!res.ok) {
// Refresh token is invalid/expired — clear both tokens
setAuthToken(null);
setRefreshToken(null);
return false;
}
const data = await res.json() as { accessToken: string; refreshToken: string };
setAuthToken(data.accessToken);
setRefreshToken(data.refreshToken);
return true;
} catch {
return false;
}
})();
try {
return await _refreshPromise;
} finally {
_refreshPromise = null;
}
}
async function apiRequest<T>(
path: string,
method: string,
@ -100,6 +163,14 @@ async function apiRequest<T>(
throw new SyncConflictError(data);
}
// On 401, attempt a silent token refresh and retry once
if (res.status === 401 && !path.startsWith('/auth/')) {
const refreshed = await refreshAccessToken();
if (refreshed) {
return apiRequest<T>(path, method, body);
}
}
if (!res.ok) {
throw new Error(`Sync API error: ${res.status} ${res.statusText}`);
}
@ -122,6 +193,7 @@ export function setAuthToken(token: string | null): void {
localStorage.setItem(STORAGE_KEYS.authToken, token);
} else {
localStorage.removeItem(STORAGE_KEYS.authToken);
localStorage.removeItem(STORAGE_KEYS.refreshToken);
}
}