From 8ea63f60c83d77f5cd99363b6b689c55ed5288ea Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Tue, 26 May 2026 12:49:22 +0200 Subject: [PATCH] fix(auth): resolve concurrent session token refresh and state syncing issues to prevent frequent logouts --- src/App.tsx | 10 +++-- src/components/UserManagementView.tsx | 16 +++---- src/lib/auth.ts | 65 ++++++++++++++++++--------- src/lib/supabase.ts | 42 ++++++++++++----- 4 files changed, 90 insertions(+), 43 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2fc0f78..b74906c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -182,13 +182,17 @@ export default function App() { // Sync session state when localStorage is updated (e.g. by token refresh) useEffect(() => { - const handleStorage = (e: StorageEvent) => { - if (e.key === 'craze_auth_session') { + const handleStorage = (e: Event) => { + if (e.type === 'session-refreshed' || (e as StorageEvent).key === 'craze_auth_session') { setSession(getStoredSession()); } }; window.addEventListener('storage', handleStorage); - return () => window.removeEventListener('storage', handleStorage); + window.addEventListener('session-refreshed', handleStorage); + return () => { + window.removeEventListener('storage', handleStorage); + window.removeEventListener('session-refreshed', handleStorage); + }; }, []); useEffect(() => { diff --git a/src/components/UserManagementView.tsx b/src/components/UserManagementView.tsx index 1404783..ab01457 100644 --- a/src/components/UserManagementView.tsx +++ b/src/components/UserManagementView.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { Users, Search, CheckCircle, XCircle, Trash2, Shield, Loader2, AlertCircle, Clock } from 'lucide-react'; import { AuthSession } from '../lib/auth'; +import { safeFetch } from '../lib/supabase'; interface User { id: string; @@ -27,11 +28,10 @@ export function UserManagementView({ session }: UserManagementViewProps) { setError(null); setWarning(null); try { - const res = await fetch('/api/users-admin', { + const res = await safeFetch('/api/users-admin', { method: 'POST', headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${session.access_token}` + 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'list' }) }); @@ -61,11 +61,10 @@ export function UserManagementView({ session }: UserManagementViewProps) { setError(null); setWarning(null); try { - const res = await fetch('/api/users-admin', { + const res = await safeFetch('/api/users-admin', { method: 'POST', headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${session.access_token}` + 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'validate', @@ -104,11 +103,10 @@ export function UserManagementView({ session }: UserManagementViewProps) { setError(null); setDeleteConfirmUser(null); try { - const res = await fetch('/api/users-admin', { + const res = await safeFetch('/api/users-admin', { method: 'POST', headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${session.access_token}` + 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete', diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d05f401..74501da 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -72,37 +72,62 @@ export async function signIn(email: string, password: string): Promise { - const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'apikey': SUPABASE_ANON_KEY, - }, - body: JSON.stringify({ refresh_token: refreshToken }), - }); +let activeRefreshPromise: Promise | null = null; - if (!response.ok) { - localStorage.removeItem(SESSION_KEY); - throw new Error('Session expired. Please sign in again.'); +export async function refreshSession(refreshToken: string): Promise { + if (activeRefreshPromise) { + return activeRefreshPromise; } - const data = await response.json(); - const session: AuthSession = { - access_token: data.access_token, - refresh_token: data.refresh_token, - user: { id: data.user.id, email: data.user.email }, - }; + activeRefreshPromise = (async () => { + try { + const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'apikey': SUPABASE_ANON_KEY, + }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); - localStorage.setItem(SESSION_KEY, JSON.stringify(session)); - return session; + if (!response.ok) { + if (response.status === 400 || response.status === 401 || response.status === 403) { + localStorage.removeItem(SESSION_KEY); + window.dispatchEvent(new Event('session-refreshed')); + throw new Error('Session expired. Please sign in again.'); + } else { + throw new Error(`Server error (${response.status}). Please try again later.`); + } + } + + const data = await response.json(); + const session: AuthSession = { + access_token: data.access_token, + refresh_token: data.refresh_token, + user: { id: data.user.id, email: data.user.email }, + }; + + localStorage.setItem(SESSION_KEY, JSON.stringify(session)); + window.dispatchEvent(new Event('session-refreshed')); + return session; + } catch (error) { + // If the error was not already thrown as "Session expired", we just propagate it. + throw error; + } finally { + activeRefreshPromise = null; + } + })(); + + return activeRefreshPromise; } export function signOut(): void { localStorage.removeItem(SESSION_KEY); + window.dispatchEvent(new Event('session-refreshed')); } export function getStoredSession(): AuthSession | null { diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts index f2653e7..5d75d62 100644 --- a/src/lib/supabase.ts +++ b/src/lib/supabase.ts @@ -17,17 +17,37 @@ export async function safeFetch(url: string, options: RequestInit = {}): Promise let response = await fetch(url, { ...options, headers }); - if (response.status === 401 && session?.refresh_token) { - try { - const newSession = await refreshSession(session.refresh_token); - const newHeaders = { - ...options.headers, - 'apikey': SUPABASE_ANON_KEY, - 'Authorization': `Bearer ${newSession.access_token}`, - }; - response = await fetch(url, { ...options, headers: newHeaders }); - } catch (refreshError) { - console.error('Session refresh failed:', refreshError); + if (response.status === 401) { + const latestSession = getStoredSession(); + if (latestSession) { + // If the access token in localStorage is already different (newer) than the one we used, + // try retrying the request with that new token first without doing a refresh. + if (latestSession.access_token !== token) { + const retryHeaders = { + ...options.headers, + 'apikey': SUPABASE_ANON_KEY, + 'Authorization': `Bearer ${latestSession.access_token}`, + }; + response = await fetch(url, { ...options, headers: retryHeaders }); + if (response.status !== 401) { + return response; + } + } + + // If we still get a 401, perform the refresh using the latest refresh token + if (latestSession.refresh_token) { + try { + const newSession = await refreshSession(latestSession.refresh_token); + const newHeaders = { + ...options.headers, + 'apikey': SUPABASE_ANON_KEY, + 'Authorization': `Bearer ${newSession.access_token}`, + }; + response = await fetch(url, { ...options, headers: newHeaders }); + } catch (refreshError) { + console.error('Session refresh failed:', refreshError); + } + } } }