# 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** : ```typescript // 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 ```typescript // 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 ```typescript // 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 #### Option A : Utiliser un cookie serveur pour marquer le logout ```typescript // 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 ```typescript // 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** : ```typescript // 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 ```typescript // 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` : ```typescript // 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()` : ```typescript // 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 #### Option A : Utiliser un cookie HttpOnly pour le flag ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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) 4. **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 5. **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 6. **Améliorer `clearKeycloakCookies()`** - Modifier : `lib/session.ts` - Impact : Meilleure tentative de suppression cookies cross-domain ### Phase 3 : Optimisations (Priorité Basse) 7. **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 8. **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