NeahNew/IMPROVEMENTS_LOGIN_FLOW.md
2026-01-04 10:32:31 +01:00

20 KiB

Améliorations du Flow de Connexion - Recommandations

📋 Vue d'ensemble

Ce document propose des améliorations concrètes pour corriger et optimiser le flow de connexion/déconnexion du dashboard Next.js avec NextAuth et Keycloak.


🎯 Problèmes Identifiés et Solutions

Problème 1 : prompt=login toujours actif - Empêche SSO naturel

Situation actuelle :

// app/api/auth/options.ts ligne 154
authorization: {
  params: {
    scope: "openid profile email roles",
    prompt: "login" // ⚠️ TOUJOURS actif
  }
}

Impact :

  • L'utilisateur doit toujours saisir ses identifiants, même lors de la première visite
  • Empêche l'expérience SSO naturelle
  • Mauvaise UX pour les utilisateurs légitimes

Solution recommandée : Gérer prompt=login conditionnellement

// app/api/auth/options.ts
authorization: {
  params: {
    scope: "openid profile email roles",
    // Ne pas forcer prompt=login par défaut
    // prompt: "login" // ❌ À SUPPRIMER
  }
}

ET : Ajouter prompt=login uniquement après un logout explicite

// Dans signIn() après logout
signIn("keycloak", { 
  callbackUrl: "/",
  // Ajouter prompt=login uniquement si logout récent
  ...(shouldForceLogin ? { prompt: "login" } : {})
});

Problème 2 : Détection session invalide trop complexe et fragile

Situation actuelle :

  • Logique complexe dans app/signin/page.tsx (lignes 17-67)
  • Vérification multiple de cookies, sessionStorage, URL params
  • Race conditions possibles
  • Auto-login peut se déclencher incorrectement

Solution recommandée : Simplifier avec un flag serveur

// app/api/auth/end-sso-session/route.ts
export async function POST(request: NextRequest) {
  // ... code existant ...
  
  // Après logout réussi, créer un cookie de flag
  const response = NextResponse.json({ success: true });
  response.cookies.set('force_login_prompt', 'true', {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    maxAge: 300 // 5 minutes
  });
  
  return response;
}

Option B : Utiliser un paramètre d'état dans l'URL Keycloak

// Dans signout-handler.tsx, ajouter un paramètre state
const keycloakLogoutUrl = new URL(
  `${keycloakIssuer}/protocol/openid-connect/logout`
);
keycloakLogoutUrl.searchParams.append('state', 'force_login');
// Keycloak renverra ce state dans le redirect

Simplification de signin/page.tsx :

// Vérifier le cookie serveur au lieu de logique complexe
const forceLoginCookie = document.cookie
  .split(';')
  .find(c => c.trim().startsWith('force_login_prompt='));

if (forceLoginCookie) {
  // Supprimer le cookie
  document.cookie = 'force_login_prompt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
  // Forcer prompt=login
  signIn("keycloak", { 
    callbackUrl: "/",
    // Ajouter prompt via custom params si possible
  });
}

Problème 3 : Cookies Keycloak non supprimables (domaine différent)

Situation actuelle :

  • clearKeycloakCookies() échoue si Keycloak est sur un domaine différent
  • Les cookies Keycloak persistent après logout
  • Session SSO peut persister

Solutions recommandées :

Solution 1 : Utiliser Keycloak logout endpoint avec tous les paramètres

// Améliorer le logout URL dans signout-handler.tsx
const keycloakLogoutUrl = new URL(
  `${keycloakIssuer}/protocol/openid-connect/logout`
);

// Paramètres essentiels
keycloakLogoutUrl.searchParams.append('post_logout_redirect_uri', 
  window.location.origin + '/signin?logout=true');
keycloakLogoutUrl.searchParams.append('id_token_hint', idToken);

// ✅ AJOUTER ces paramètres pour forcer la suppression SSO
keycloakLogoutUrl.searchParams.append('kc_action', 'LOGOUT'); // Déjà présent
keycloakLogoutUrl.searchParams.append('logout_hint', 'true'); // Nouveau

// Si possible, utiliser le client_id pour forcer logout client
if (process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID) {
  keycloakLogoutUrl.searchParams.append('client_id', 
    process.env.NEXT_PUBLIC_KEYcloak_CLIENT_ID);
}

Solution 2 : Utiliser Admin API pour terminer TOUTES les sessions

Améliorer app/api/auth/end-sso-session/route.ts :

// Au lieu de logout({ id: userId }), utiliser logoutAllSessions
try {
  // Option 1 : Logout toutes les sessions de l'utilisateur
  await adminClient.users.logout({ id: userId });
  
  // Option 2 : Si disponible, utiliser une méthode plus agressive
  // Note: Vérifier la version de Keycloak Admin Client
  // Certaines versions supportent logoutAllSessions
  
  // Option 3 : Invalider les refresh tokens
  const userSessions = await adminClient.users.listSessions({ id: userId });
  for (const session of userSessions) {
    await adminClient.users.logoutSession({ 
      id: userId, 
      sessionId: session.id 
    });
  }
} catch (error) {
  // ... gestion erreur
}

Solution 3 : Configurer Keycloak pour SameSite=None (si cross-domain)

Configuration Keycloak (à faire côté Keycloak) :

Cookie SameSite: None
Cookie Secure: true
Cookie Domain: .example.com (domaine parent partagé)

Puis améliorer clearKeycloakCookies() :

// lib/session.ts
export function clearKeycloakCookies() {
  const keycloakIssuer = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER;
  if (!keycloakIssuer) return;

  try {
    const keycloakUrl = new URL(keycloakIssuer);
    const keycloakDomain = keycloakUrl.hostname;
    
    // Extraire le domaine parent si possible
    const domainParts = keycloakDomain.split('.');
    const parentDomain = domainParts.length > 2 
      ? '.' + domainParts.slice(-2).join('.') 
      : keycloakDomain;
    
    const keycloakCookieNames = [
      'KEYCLOAK_SESSION',
      'KEYCLOAK_SESSION_LEGACY',
      'KEYCLOAK_IDENTITY',
      'KEYCLOAK_IDENTITY_LEGACY',
      'AUTH_SESSION_ID',
      'KC_RESTART',
      'KC_RESTART_LEGACY'
    ];

    // Essayer avec domaine parent (pour SameSite=None)
    keycloakCookieNames.forEach(cookieName => {
      // Avec domaine parent
      document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${parentDomain}; SameSite=None; Secure;`;
      // Sans domaine (same-origin)
      document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=None; Secure;`;
      // Avec path spécifique
      document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/realms/; domain=${parentDomain}; SameSite=None; Secure;`;
    });
  } catch (error) {
    console.error('Error clearing Keycloak cookies:', error);
  }
}

Problème 4 : Race condition entre logout et auto-login

Situation actuelle :

  • Auto-login avec délai de 1 seconde
  • Peut se déclencher pendant le flow de logout
  • Flags logout_in_progress et session_invalidated peuvent être perdus

Solution recommandée : Utiliser un mécanisme plus robuste

// Créer une route API pour marquer le logout
// app/api/auth/mark-logout/route.ts
export async function POST() {
  const response = NextResponse.json({ success: true });
  response.cookies.set('logout_completed', 'true', {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    path: '/',
    maxAge: 300 // 5 minutes
  });
  return response;
}

// Dans signout-handler.tsx
await fetch('/api/auth/mark-logout', { method: 'POST' });

// Dans signin/page.tsx
const logoutCompleted = document.cookie
  .split(';')
  .some(c => c.trim().startsWith('logout_completed='));

if (logoutCompleted) {
  // Supprimer le cookie
  document.cookie = 'logout_completed=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
  // Ne pas auto-login
  return;
}

Option B : Utiliser un état dans l'URL Keycloak callback

// Lors du logout, ajouter un paramètre state
const logoutState = btoa(JSON.stringify({ 
  logout: true, 
  timestamp: Date.now() 
}));

// Keycloak renverra ce state dans le redirect
// Dans signin/page.tsx, vérifier le state
const urlParams = new URLSearchParams(window.location.search);
const state = urlParams.get('state');
if (state) {
  try {
    const stateData = JSON.parse(atob(state));
    if (stateData.logout) {
      // Ne pas auto-login
      return;
    }
  } catch (e) {
    // State invalide, ignorer
  }
}

Problème 5 : Configuration cookies NextAuth non explicite

Situation actuelle :

  • Pas de configuration explicite des cookies NextAuth
  • Utilise les valeurs par défaut
  • Pas de contrôle sur SameSite, Secure, etc.

Solution recommandée : Configurer explicitement les cookies

// app/api/auth/options.ts
export const authOptions: NextAuthOptions = {
  // ... providers ...
  
  // ✅ AJOUTER configuration explicite des cookies
  cookies: {
    sessionToken: {
      name: `next-auth.session-token`,
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
        // Domaine explicite si nécessaire
        // domain: process.env.COOKIE_DOMAIN,
      },
    },
    callbackUrl: {
      name: `next-auth.callback-url`,
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
      },
    },
    csrfToken: {
      name: `next-auth.csrf-token`,
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
      },
    },
    state: {
      name: `next-auth.state`,
      options: {
        httpOnly: true,
        sameSite: 'lax',
        path: '/',
        secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
      },
    },
  },
  
  // ... reste de la config ...
};

Avantages :

  • Contrôle total sur les cookies
  • Peut ajuster SameSite pour cross-domain si nécessaire
  • Meilleure sécurité

Problème 6 : Détection session invalide côté client uniquement

Situation actuelle :

  • Détection session invalide uniquement côté client
  • Vérification de cookies via document.cookie
  • Peut être contourné ou mal interprété

Solution recommandée : Détection serveur + client

Créer une route API pour vérifier l'état de session

// app/api/auth/session-status/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '../options';

export async function GET(request: NextRequest) {
  const session = await getServerSession(authOptions);
  
  // Vérifier si la session existe mais est invalide
  const hasSessionCookie = request.cookies.has('next-auth.session-token') ||
                          request.cookies.has('__Secure-next-auth.session-token') ||
                          request.cookies.has('__Host-next-auth.session-token');
  
  return NextResponse.json({
    hasSession: !!session,
    hasSessionCookie,
    isInvalid: hasSessionCookie && !session, // Cookie existe mais session invalide
    shouldForceLogin: request.cookies.get('force_login_prompt')?.value === 'true',
  });
}

Utiliser cette route dans signin/page.tsx

// app/signin/page.tsx
useEffect(() => {
  const checkSessionStatus = async () => {
    const response = await fetch('/api/auth/session-status');
    const status = await response.json();
    
    if (status.isInvalid) {
      // Session invalidée, ne pas auto-login
      sessionStorage.setItem('session_invalidated', 'true');
      return;
    }
    
    if (status.shouldForceLogin) {
      // Forcer prompt=login
      signIn("keycloak", { 
        callbackUrl: "/",
        // prompt: "login" via custom params si possible
      });
      return;
    }
    
    // Nouvel utilisateur, auto-login OK
    if (status === "unauthenticated" && !status.hasSessionCookie) {
      signIn("keycloak", { callbackUrl: "/" });
    }
  };
  
  checkSessionStatus();
}, []);

Problème 7 : Gestion d'erreur token refresh insuffisante

Situation actuelle :

  • refreshAccessToken() détecte SessionNotActive
  • Retourne error: "SessionNotActive"
  • Mais la gestion peut être améliorée

Solution recommandée : Améliorer la gestion d'erreur

// app/api/auth/options.ts
async function refreshAccessToken(token: ExtendedJWT) {
  try {
    const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        client_id: process.env.KEYCLOAK_CLIENT_ID!,
        client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
        grant_type: "refresh_token",
        refresh_token: token.refreshToken || '',
      }),
      method: "POST",
    });

    const refreshedTokens = await response.json();

    if (!response.ok) {
      // ✅ AMÉLIORATION : Détecter différents types d'erreurs
      const errorType = refreshedTokens.error;
      const errorDescription = refreshedTokens.error_description || '';
      
      // Session invalide (logout depuis iframe ou Keycloak)
      if (errorType === 'invalid_grant' || 
          errorDescription.includes('Session not active') ||
          errorDescription.includes('Token is not active') ||
          errorDescription.includes('Session expired')) {
        console.log("Keycloak session invalidated, marking for removal");
        return {
          ...token,
          error: "SessionNotActive",
          // ✅ Supprimer tous les tokens
          accessToken: undefined,
          refreshToken: undefined,
          idToken: undefined,
        };
      }
      
      // Refresh token expiré (inactivité prolongée)
      if (errorType === 'invalid_grant' && 
          errorDescription.includes('Refresh token expired')) {
        console.log("Refresh token expired, user needs to re-authenticate");
        return {
          ...token,
          error: "RefreshTokenExpired",
          accessToken: undefined,
          refreshToken: undefined,
          idToken: undefined,
        };
      }
      
      // Autre erreur
      throw refreshedTokens;
    }

    return {
      ...token,
      accessToken: refreshedTokens.access_token,
      refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
      idToken: token.idToken, // Keycloak ne renvoie pas de nouvel ID token
      accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
      error: undefined, // ✅ Clear any previous errors
    };
  } catch (error: any) {
    console.error("Error refreshing access token:", error);
    
    // ✅ AMÉLIORATION : Gestion d'erreur plus robuste
    if (error?.error === 'invalid_grant' || 
        error?.error_description?.includes('Session not active') ||
        error?.error_description?.includes('Token is not active')) {
      return {
        ...token,
        error: "SessionNotActive",
        accessToken: undefined,
        refreshToken: undefined,
        idToken: undefined,
      };
    }
    
    return {
      ...token,
      error: "RefreshAccessTokenError",
      accessToken: undefined,
      refreshToken: undefined,
      idToken: undefined,
    };
  }
}

🚀 Plan d'Implémentation Recommandé

Phase 1 : Corrections Critiques (Priorité Haute)

  1. Supprimer prompt=login par défaut

    • Fichier : app/api/auth/options.ts
    • Impact : Améliore l'UX pour les utilisateurs légitimes
  2. Améliorer la gestion d'erreur token refresh

    • Fichier : app/api/auth/options.ts
    • Impact : Meilleure détection des sessions invalides
  3. Configurer explicitement les cookies NextAuth

    • Fichier : app/api/auth/options.ts
    • Impact : Meilleur contrôle et sécurité

Phase 2 : Améliorations Logout (Priorité Moyenne)

  1. Créer route API pour marquer logout

    • Nouveau fichier : app/api/auth/mark-logout/route.ts
    • Modifier : components/auth/signout-handler.tsx
    • Modifier : app/signin/page.tsx
    • Impact : Élimine les race conditions
  2. Améliorer Keycloak logout URL

    • Modifier : components/auth/signout-handler.tsx
    • Modifier : components/main-nav.tsx
    • Modifier : components/layout/layout-wrapper.tsx
    • Impact : Meilleure suppression des sessions SSO
  3. Améliorer clearKeycloakCookies()

    • Modifier : lib/session.ts
    • Impact : Meilleure tentative de suppression cookies cross-domain

Phase 3 : Optimisations (Priorité Basse)

  1. Créer route API pour vérifier statut session

    • Nouveau fichier : app/api/auth/session-status/route.ts
    • Modifier : app/signin/page.tsx
    • Impact : Détection plus fiable
  2. Simplifier logique signin/page.tsx

    • Modifier : app/signin/page.tsx
    • Impact : Code plus maintenable

📝 Checklist d'Implémentation

Étape 1 : Configuration NextAuth

  • Supprimer prompt: "login" par défaut dans options.ts
  • Ajouter configuration explicite des cookies
  • Améliorer gestion d'erreur refreshAccessToken()

Étape 2 : Amélioration Logout

  • Créer route /api/auth/mark-logout
  • Modifier signout-handler.tsx pour utiliser la route
  • Améliorer Keycloak logout URL avec tous les paramètres
  • Améliorer clearKeycloakCookies() pour cross-domain

Étape 3 : Amélioration Signin

  • Créer route /api/auth/session-status
  • Simplifier logique dans signin/page.tsx
  • Utiliser cookie serveur pour détecter logout
  • Ajouter prompt=login conditionnel après logout

Étape 4 : Tests

  • Tester login normal (première visite)
  • Tester login après logout (doit demander credentials)
  • Tester logout depuis dashboard
  • Tester logout depuis iframe
  • Tester expiration session
  • Tester refresh token expiré

🔧 Configuration Keycloak Recommandée

Paramètres Client OAuth

  1. Valid Redirect URIs :

    http://localhost:3000/api/auth/callback/keycloak
    https://your-domain.com/api/auth/callback/keycloak
    
  2. Web Origins :

    http://localhost:3000
    https://your-domain.com
    
  3. Post Logout Redirect URIs :

    http://localhost:3000/signin?logout=true
    https://your-domain.com/signin?logout=true
    
  4. Access Token Lifespan : 5 minutes (recommandé)

  5. SSO Session Idle : 30 minutes

  6. SSO Session Max : 10 heures

Configuration Realm

  1. Cookies :

    • SameSite: None (si cross-domain)
    • Secure: true (si HTTPS)
    • Domain: Domaine parent partagé (si cross-domain)
  2. Session Management :

    • Enable SSO Session Idle: true
    • SSO Session Idle Timeout: 30 minutes
    • SSO Session Max Lifespan: 10 heures

📊 Métriques de Succès

Après implémentation, vérifier :

  1. Login première visite : SSO fonctionne (pas de prompt si session Keycloak existe)
  2. Login après logout : Prompt credentials demandé
  3. Logout dashboard : Toutes les sessions supprimées
  4. Logout iframe : Dashboard se déconnecte automatiquement
  5. Expiration session : Redirection vers signin propre
  6. Refresh token expiré : Redirection vers signin avec message approprié

🎯 Résumé des Améliorations

Problème Solution Priorité Fichiers Impactés
prompt=login toujours actif Gérer conditionnellement Haute options.ts, signin/page.tsx
Détection session invalide complexe Cookie serveur + route API Haute signin/page.tsx, end-sso-session/route.ts
Cookies Keycloak non supprimables Améliorer logout URL + Admin API Moyenne signout-handler.tsx, session.ts
Race condition logout/login Cookie HttpOnly pour flag Moyenne signout-handler.tsx, signin/page.tsx
Config cookies non explicite Configuration explicite Haute options.ts
Gestion erreur refresh Améliorer détection erreurs Haute options.ts

Document créé le : $(date) Dernière mise à jour : Recommandations d'amélioration du flow de connexion