feat(auth): implement refresh token flow with 401 auto-retry and dedup
This commit is contained in:
parent
8ad31af72a
commit
5e8cbbf556
@ -10,6 +10,7 @@ import {
|
|||||||
registerUser,
|
registerUser,
|
||||||
getMe,
|
getMe,
|
||||||
setAuthToken,
|
setAuthToken,
|
||||||
|
setRefreshToken,
|
||||||
setSyncEnabled,
|
setSyncEnabled,
|
||||||
isAuthenticated as checkAuth,
|
isAuthenticated as checkAuth,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
@ -57,6 +58,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const result = await loginUser(email, password);
|
const result = await loginUser(email, password);
|
||||||
setAuthToken(result.accessToken);
|
setAuthToken(result.accessToken);
|
||||||
|
setRefreshToken(result.refreshToken);
|
||||||
setSyncEnabled(true);
|
setSyncEnabled(true);
|
||||||
setUser(result.user);
|
setUser(result.user);
|
||||||
return true;
|
return true;
|
||||||
@ -72,6 +74,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const result = await registerUser(email, password, displayName);
|
const result = await registerUser(email, password, displayName);
|
||||||
setAuthToken(result.accessToken);
|
setAuthToken(result.accessToken);
|
||||||
|
setRefreshToken(result.refreshToken);
|
||||||
setSyncEnabled(true);
|
setSyncEnabled(true);
|
||||||
setUser(result.user);
|
setUser(result.user);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -57,6 +57,7 @@ export interface OfflineQueueItem {
|
|||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
authToken: 'chronomind-auth-token',
|
authToken: 'chronomind-auth-token',
|
||||||
|
refreshToken: 'chronomind-refresh-token',
|
||||||
lastSync: 'chronomind-platform-last-sync',
|
lastSync: 'chronomind-platform-last-sync',
|
||||||
offlineQueue: 'chronomind-offline-queue',
|
offlineQueue: 'chronomind-offline-queue',
|
||||||
syncEnabled: 'chronomind-platform-sync-enabled',
|
syncEnabled: 'chronomind-platform-sync-enabled',
|
||||||
@ -76,6 +77,68 @@ function getAuthToken(): string | null {
|
|||||||
return localStorage.getItem(STORAGE_KEYS.authToken);
|
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>(
|
async function apiRequest<T>(
|
||||||
path: string,
|
path: string,
|
||||||
method: string,
|
method: string,
|
||||||
@ -100,6 +163,14 @@ async function apiRequest<T>(
|
|||||||
throw new SyncConflictError(data);
|
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) {
|
if (!res.ok) {
|
||||||
throw new Error(`Sync API error: ${res.status} ${res.statusText}`);
|
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);
|
localStorage.setItem(STORAGE_KEYS.authToken, token);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(STORAGE_KEYS.authToken);
|
localStorage.removeItem(STORAGE_KEYS.authToken);
|
||||||
|
localStorage.removeItem(STORAGE_KEYS.refreshToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user