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.