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',
+25
View File
@@ -72,10 +72,19 @@ 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;
} }
let activeRefreshPromise: Promise<AuthSession> | null = null;
export async function refreshSession(refreshToken: string): Promise<AuthSession> { export async function refreshSession(refreshToken: string): Promise<AuthSession> {
if (activeRefreshPromise) {
return activeRefreshPromise;
}
activeRefreshPromise = (async () => {
try {
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, { const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -86,8 +95,13 @@ export async function refreshSession(refreshToken: string): Promise<AuthSession>
}); });
if (!response.ok) { if (!response.ok) {
if (response.status === 400 || response.status === 401 || response.status === 403) {
localStorage.removeItem(SESSION_KEY); localStorage.removeItem(SESSION_KEY);
window.dispatchEvent(new Event('session-refreshed'));
throw new Error('Session expired. Please sign in again.'); 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 data = await response.json();
@@ -98,11 +112,22 @@ export async function refreshSession(refreshToken: string): Promise<AuthSession>
}; };
localStorage.setItem(SESSION_KEY, JSON.stringify(session)); localStorage.setItem(SESSION_KEY, JSON.stringify(session));
window.dispatchEvent(new Event('session-refreshed'));
return session; 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 {
+22 -2
View File
@@ -17,9 +17,27 @@ 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) {
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 { try {
const newSession = await refreshSession(session.refresh_token); const newSession = await refreshSession(latestSession.refresh_token);
const newHeaders = { const newHeaders = {
...options.headers, ...options.headers,
'apikey': SUPABASE_ANON_KEY, 'apikey': SUPABASE_ANON_KEY,
@@ -30,6 +48,8 @@ export async function safeFetch(url: string, options: RequestInit = {}): Promise
console.error('Session refresh failed:', refreshError); console.error('Session refresh failed:', refreshError);
} }
} }
}
}
return response; return response;
} }