Il sintomo
Su baccalaureat.sn, il flusso Google OAuth sembrava funzionare: popup Google, consenso, redirect verso il sito, l'utente appare connesso... poi, uno o due secondi dopo, ritorno allo stato disconnesso. Nessun errore visibile, nessun messaggio. Il tipo di bug intermittente che non si riesce nemmeno a riprodurre sistematicamente in locale.
Mettere gli occhi sulla sequenza
Con la scheda rete e Redux DevTools aperti fianco a fianco, la cronologia reale emerge:
1. POST /user/google-auth → 200, token JWT reçu
2. dispatch(setUser) → isLoggedIn: true, UI connectée
3. dispatch(checkToken) → lancé au mount d'un autre composant
4. checkToken.rejected → le token n'était pas encore écrit dans localStorage
5. reducer rejected → localStorage.clear() + isLoggedIn: false ← l'éjection
Una race di stato: il thunk checkToken, attivato al mount da un componente vicino, leggeva il localStorage prima che il flusso Google vi avesse scritto il token. Partiva quindi in rejected... e il mio reducer trattava ogni rifiuto come una sessione non valida:
// AVANT : le rejet efface tout, même quand on vient de se connecter
.addCase(checkToken.rejected, (state) => {
state.isLoggedIn = false;
state.user = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
});Il secondo colpevole: l'interceptor che si morde la coda
Una volta corretto il primo bug, persisteva un caso residuo. La causa: il mio interceptor axios di refresh globale intercettava tutte le risposte 401 — comprese quelle di /user/google-auth stessa. Quando Google restituiva un codice scaduto (utente lento sulla schermata di consenso), l'interceptor tentava un refresh con un token inesistente, falliva ed eseguiva la sua routine di pulizia: svuotamento del localStorage e redirect verso /auth.
// AVANT : l'intercepteur traite la route d'auth comme n'importe quelle route protégée
api.interceptors.response.use(null, async (error) => {
if (error.response?.status === 401) {
return attemptRefreshAndRetry(error); // absurde sur /user/google-auth
}
return Promise.reject(error);
});La correzione
Due guardie, una per colpevole. Il reducer non distrugge più una sessione già stabilita:
// APRÈS : un rejet de checkToken ne casse pas une session active
.addCase(checkToken.rejected, (state) => {
if (state.isLoggedIn) {
return; // session déjà validée par un autre flux (Google) : on ne touche à rien
}
state.isLoggedIn = false;
state.user = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
});E l'interceptor esclude esplicitamente le route di autenticazione dalla sua logica di refresh:
// APRÈS : liste blanche des routes que le refresh ne doit jamais toucher
const AUTH_ROUTES = ['/user/login', '/user/google-auth', '/user/refresh-token'];
api.interceptors.response.use(null, async (error) => {
const url = error.config?.url ?? '';
const isAuthRoute = AUTH_ROUTES.some((route) => url.includes(route));
if (error.response?.status === 401 && !isAuthRoute) {
return attemptRefreshAndRetry(error);
}
return Promise.reject(error); // les routes d'auth gèrent leurs propres erreurs
});Cosa ne ricavo
- Un reducer rejected che svuota la sessione deve sempre chiedersi: «questo rifiuto prova davvero che la sessione è invalida?» Un semplice problema di timing non è una sessione morta.
- Un interceptor di refresh globale deve escludere le route di autenticazione stesse, altrimenti trasforma un fallimento di login in un loop di pulizia.
- Redux DevTools + scheda rete fianco a fianco: è la cronologia incrociata dei due che ha rivelato la race, nessuno dei due da solo bastava.
- I bug «intermittenti» di autenticazione sono quasi sempre race tra più flussi che scrivono lo stesso stato.