Le symptôme
Sur baccalaureat.sn, le parcours Google OAuth semblait fonctionner : popup Google, consentement, redirection vers le site, l'utilisateur apparaît connecté... puis, une à deux secondes plus tard, retour à l'état déconnecté. Pas d'erreur visible, pas de message. Le genre de bug intermittent qu'on n'arrive même pas à reproduire systématiquement en local.
Poser des yeux sur la séquence
Avec l'onglet réseau et Redux DevTools ouverts côte à côte, la chronologie réelle apparaît :
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
Une course d'état : le thunk checkToken, déclenché au montage par un composant voisin, lisait le localStorage avant que le flux Google n'y ait écrit le token. Il partait donc en rejected... et mon reducer traitait tout rejet comme une session invalide :
// 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');
});
Le deuxième coupable : l'intercepteur qui se mord la queue
Une fois le premier bug corrigé, un cas résiduel persistait. La cause : mon intercepteur axios de refresh global interceptait toutes les réponses 401 — y compris celles de /user/google-auth elle-même. Quand Google renvoyait un code expiré (utilisateur lent sur l'écran de consentement), l'intercepteur tentait un refresh avec un token inexistant, échouait, et exécutait sa routine de nettoyage : purge du localStorage et redirection vers /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);
});
Le correctif
Deux gardes, un par coupable. Le reducer ne détruit plus une session déjà établie :
// 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');
});
Et l'intercepteur exclut explicitement les routes d'authentification de sa logique de 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
});
Ce que je retiens
  • Un reducer rejected qui purge la session doit toujours se demander : « ce rejet prouve-t-il vraiment que la session est invalide ? » Un simple problème de timing n'est pas une session morte.
  • Un intercepteur de refresh global doit exclure les routes d'authentification elles-mêmes, sinon il transforme un échec de login en boucle de purge.
  • Redux DevTools + onglet réseau côte à côte : c'est la chronologie croisée des deux qui a révélé la course, aucun des deux seul ne suffisait.
  • Les bugs « intermittents » d'authentification sont presque toujours des courses entre plusieurs flux qui écrivent le même état.