fix(auth): resolve concurrent session token refresh and state syncing issues to prevent frequent logouts

This commit is contained in:
Christian Vidal Wolf
2026-05-26 12:49:22 +02:00
parent 070da5ad0f
commit 8ea63f60c8
4 changed files with 90 additions and 43 deletions
+7 -3
View File
@@ -182,13 +182,17 @@ export default function App() {
// Sync session state when localStorage is updated (e.g. by token refresh) // Sync session state when localStorage is updated (e.g. by token refresh)
useEffect(() => { useEffect(() => {
const handleStorage = (e: StorageEvent) => { const handleStorage = (e: Event) => {
if (e.key === 'craze_auth_session') { if (e.type === 'session-refreshed' || (e as StorageEvent).key === 'craze_auth_session') {
setSession(getStoredSession()); setSession(getStoredSession());
} }
}; };
window.addEventListener('storage', handleStorage); 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(() => { useEffect(() => {
+7 -9
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Users, Search, CheckCircle, XCircle, Trash2, Shield, Loader2, AlertCircle, Clock } from 'lucide-react'; import { Users, Search, CheckCircle, XCircle, Trash2, Shield, Loader2, AlertCircle, Clock } from 'lucide-react';
import { AuthSession } from '../lib/auth'; import { AuthSession } from '../lib/auth';
import { safeFetch } from '../lib/supabase';
interface User { interface User {
id: string; id: string;
@@ -27,11 +28,10 @@ export function UserManagementView({ session }: UserManagementViewProps) {
setError(null); setError(null);
setWarning(null); setWarning(null);
try { try {
const res = await fetch('/api/users-admin', { const res = await safeFetch('/api/users-admin', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json'
'Authorization': `Bearer ${session.access_token}`
}, },
body: JSON.stringify({ action: 'list' }) body: JSON.stringify({ action: 'list' })
}); });
@@ -61,11 +61,10 @@ export function UserManagementView({ session }: UserManagementViewProps) {
setError(null); setError(null);
setWarning(null); setWarning(null);
try { try {
const res = await fetch('/api/users-admin', { const res = await safeFetch('/api/users-admin', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json'
'Authorization': `Bearer ${session.access_token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
action: 'validate', action: 'validate',
@@ -104,11 +103,10 @@ export function UserManagementView({ session }: UserManagementViewProps) {
setError(null); setError(null);
setDeleteConfirmUser(null); setDeleteConfirmUser(null);
try { try {
const res = await fetch('/api/users-admin', { const res = await safeFetch('/api/users-admin', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json'
'Authorization': `Bearer ${session.access_token}`
}, },
body: JSON.stringify({ body: JSON.stringify({
action: 'delete', action: 'delete',
+45 -20
View File
@@ -72,37 +72,62 @@ export async function signIn(email: string, password: string): Promise<AuthSessi
}; };
localStorage.setItem(SESSION_KEY, JSON.stringify(session)); localStorage.setItem(SESSION_KEY, JSON.stringify(session));
window.dispatchEvent(new Event('session-refreshed'));
return session; return session;
} }
export async function refreshSession(refreshToken: string): Promise<AuthSession> { let activeRefreshPromise: Promise<AuthSession> | null = null;
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 }),
});
if (!response.ok) { export async function refreshSession(refreshToken: string): Promise<AuthSession> {
localStorage.removeItem(SESSION_KEY); if (activeRefreshPromise) {
throw new Error('Session expired. Please sign in again.'); return activeRefreshPromise;
} }
const data = await response.json(); activeRefreshPromise = (async () => {
const session: AuthSession = { try {
access_token: data.access_token, const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
refresh_token: data.refresh_token, method: 'POST',
user: { id: data.user.id, email: data.user.email }, headers: {
}; 'Content-Type': 'application/json',
'apikey': SUPABASE_ANON_KEY,
},
body: JSON.stringify({ refresh_token: refreshToken }),
});
localStorage.setItem(SESSION_KEY, JSON.stringify(session)); if (!response.ok) {
return session; 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 { export function signOut(): void {
localStorage.removeItem(SESSION_KEY); localStorage.removeItem(SESSION_KEY);
window.dispatchEvent(new Event('session-refreshed'));
} }
export function getStoredSession(): AuthSession | null { export function getStoredSession(): AuthSession | null {
+31 -11
View File
@@ -17,17 +17,37 @@ export async function safeFetch(url: string, options: RequestInit = {}): Promise
let response = await fetch(url, { ...options, headers }); let response = await fetch(url, { ...options, headers });
if (response.status === 401 && session?.refresh_token) { if (response.status === 401) {
try { const latestSession = getStoredSession();
const newSession = await refreshSession(session.refresh_token); if (latestSession) {
const newHeaders = { // If the access token in localStorage is already different (newer) than the one we used,
...options.headers, // try retrying the request with that new token first without doing a refresh.
'apikey': SUPABASE_ANON_KEY, if (latestSession.access_token !== token) {
'Authorization': `Bearer ${newSession.access_token}`, const retryHeaders = {
}; ...options.headers,
response = await fetch(url, { ...options, headers: newHeaders }); 'apikey': SUPABASE_ANON_KEY,
} catch (refreshError) { 'Authorization': `Bearer ${latestSession.access_token}`,
console.error('Session refresh failed:', refreshError); };
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);
}
}
} }
} }