diff --git a/web/src/lib/auth-context.tsx b/web/src/lib/auth-context.tsx index 21331c2..c7583f2 100644 --- a/web/src/lib/auth-context.tsx +++ b/web/src/lib/auth-context.tsx @@ -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; diff --git a/web/src/lib/platform-sync.ts b/web/src/lib/platform-sync.ts index f64381a..0b9cd13 100644 --- a/web/src/lib/platform-sync.ts +++ b/web/src/lib/platform-sync.ts @@ -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 | 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 { + 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( path: string, method: string, @@ -100,6 +163,14 @@ async function apiRequest( 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(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); } }