diff --git a/ACTION_PLAN_LOGIN_FLOW.md b/ACTION_PLAN_LOGIN_FLOW.md new file mode 100644 index 00000000..7c93d3bc --- /dev/null +++ b/ACTION_PLAN_LOGIN_FLOW.md @@ -0,0 +1,425 @@ +# Plan d'Action - Amélioration Flow de Connexion + +## 🎯 Objectifs + +1. **Améliorer l'UX** : Permettre SSO naturel pour les utilisateurs légitimes +2. **Sécuriser le logout** : S'assurer que les credentials sont demandés après logout +3. **Simplifier le code** : Réduire la complexité de détection session invalide +4. **Éliminer les race conditions** : Mécanisme robuste pour éviter auto-login après logout + +--- + +## 📋 Actions Immédiates (À faire en premier) + +### Action 1 : Supprimer `prompt=login` par défaut ⚡ + +**Fichier** : `app/api/auth/options.ts` + +**Changement** : +```typescript +// AVANT (ligne 147-155) +authorization: { + params: { + scope: "openid profile email roles", + prompt: "login" // ❌ Supprimer cette ligne + } +} + +// APRÈS +authorization: { + params: { + scope: "openid profile email roles", + // prompt: "login" supprimé - sera ajouté conditionnellement après logout + } +} +``` + +**Impact** : ✅ SSO fonctionne naturellement pour les utilisateurs légitimes + +--- + +### Action 2 : Créer route API pour marquer logout ⚡ + +**Nouveau fichier** : `app/api/auth/mark-logout/route.ts` + +```typescript +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + const response = NextResponse.json({ + success: true, + message: 'Logout marked successfully' + }); + + // Cookie HttpOnly pour marquer le logout (5 minutes) + response.cookies.set('force_login_prompt', 'true', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 300 // 5 minutes + }); + + return response; +} +``` + +**Impact** : ✅ Mécanisme robuste pour forcer login après logout + +--- + +### Action 3 : Modifier signout-handler pour utiliser la route ⚡ + +**Fichier** : `components/auth/signout-handler.tsx` + +**Changement** (après ligne 25) : +```typescript +// AVANT +clearKeycloakCookies(); + +// APRÈS +clearKeycloakCookies(); + +// Marquer le logout côté serveur +try { + await fetch('/api/auth/mark-logout', { + method: 'POST', + credentials: 'include', + }); +} catch (error) { + console.error('Error marking logout:', error); + // Continue même si ça échoue +} +``` + +**Répéter dans** : +- `components/main-nav.tsx` (ligne ~377) +- `components/layout/layout-wrapper.tsx` (ligne ~42) + +**Impact** : ✅ Flag serveur pour empêcher auto-login + +--- + +### Action 4 : Simplifier signin/page.tsx ⚡ + +**Fichier** : `app/signin/page.tsx` + +**Changement** : Remplacer la logique complexe (lignes 17-67) par : + +```typescript +useEffect(() => { + // Vérifier le cookie serveur pour forcer login + const forceLoginCookie = document.cookie + .split(';') + .find(c => c.trim().startsWith('force_login_prompt=')); + + // Si logout récent, forcer prompt=login + if (forceLoginCookie) { + // Supprimer le cookie + document.cookie = 'force_login_prompt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; + + // Ne pas auto-login, attendre clic utilisateur + // Le bouton "Se connecter" forcera prompt=login + return; + } + + // Si déjà authentifié, rediriger + if (status === "authenticated" && session?.user) { + router.push("/"); + return; + } + + // Si non authentifié et pas de flag logout, auto-login (SSO naturel) + if (status === "unauthenticated" && !forceLoginCookie) { + const timer = setTimeout(() => { + if (status === "unauthenticated") { + signIn("keycloak", { callbackUrl: "/" }); + } + }, 1000); + return () => clearTimeout(timer); + } +}, [status, session, router]); +``` + +**ET** modifier le bouton "Se connecter" (ligne ~202) : + +```typescript + +``` + +**Impact** : ✅ Code plus simple et maintenable + +--- + +### Action 5 : Ajouter prompt=login conditionnel dans options.ts ⚡ + +**Fichier** : `app/api/auth/options.ts` + +**Changement** : Modifier la configuration KeycloakProvider pour accepter un paramètre custom : + +```typescript +KeycloakProvider({ + clientId: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"), + clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"), + issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"), + authorization: { + params: { + scope: "openid profile email roles", + // prompt sera ajouté dynamiquement si force_login=true dans l'URL + } + }, + // ... profile callback ... +}) +``` + +**ET** créer une route custom pour signin qui ajoute prompt : + +**Nouveau fichier** : `app/api/auth/signin/keycloak/route.ts` + +```typescript +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '../../options'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const forceLogin = searchParams.get('force_login') === 'true'; + const callbackUrl = searchParams.get('callbackUrl') || '/'; + + // Rediriger vers NextAuth signin avec prompt si nécessaire + const signinUrl = new URL('/api/auth/signin/keycloak', request.nextUrl.origin); + signinUrl.searchParams.set('callbackUrl', callbackUrl); + + if (forceLogin) { + // Ajouter prompt=login dans l'URL de redirection Keycloak + // Note: NextAuth ne supporte pas directement, il faut modifier l'URL après + // Solution alternative: Utiliser un middleware ou modifier options dynamiquement + } + + return NextResponse.redirect(signinUrl); +} +``` + +**OU** Solution plus simple : Modifier directement dans `options.ts` pour lire un cookie : + +```typescript +// Dans options.ts, modifier authorization params dynamiquement +authorization: { + params: (provider, action, request) => { + const forceLogin = request?.cookies?.get('force_login_prompt')?.value === 'true'; + return { + scope: "openid profile email roles", + ...(forceLogin ? { prompt: "login" } : {}), + }; + } +} +``` + +**Note** : NextAuth v4 ne supporte pas `params` comme fonction. Solution alternative : + +**Modifier** `app/api/auth/options.ts` pour utiliser `authorization.url` : + +```typescript +KeycloakProvider({ + // ... config ... + authorization: { + params: { + scope: "openid profile email roles", + }, + // Ajouter prompt dynamiquement via URL personnalisée + url: (params) => { + // Vérifier si on doit forcer login (via cookie ou autre moyen) + const url = new URL(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/auth`); + url.searchParams.set('client_id', process.env.KEYCLOAK_CLIENT_ID!); + url.searchParams.set('redirect_uri', params.redirect_uri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', 'openid profile email roles'); + url.searchParams.set('state', params.state); + + // Ajouter prompt si nécessaire (à vérifier via cookie dans le callback) + // Note: Plus complexe, nécessite de passer le flag via state + return url.toString(); + } + } +}) +``` + +**Solution RECOMMANDÉE (plus simple)** : Utiliser un paramètre dans l'URL de callback et le vérifier dans le callback JWT : + +```typescript +// Dans signin/page.tsx, lors du clic sur "Se connecter" +const url = new URL(window.location.origin + '/api/auth/signin/keycloak'); +url.searchParams.set('callbackUrl', '/'); +url.searchParams.set('force_login', 'true'); +// Stocker dans sessionStorage pour le callback +sessionStorage.setItem('force_login', 'true'); +window.location.href = url.toString(); + +// Dans options.ts, callback jwt, vérifier sessionStorage n'est pas possible côté serveur +// Solution: Passer via state OAuth +``` + +**MEILLEURE SOLUTION** : Utiliser un cookie avant le signIn : + +```typescript +// Dans signin/page.tsx, bouton "Se connecter" +onClick={() => { + // Créer cookie pour forcer login + document.cookie = 'force_login_prompt=true; path=/; max-age=300'; + // Puis signIn normal + signIn("keycloak", { callbackUrl: "/" }); +}} + +// Dans options.ts, lire le cookie dans authorization params +// Note: NextAuth ne permet pas d'accéder aux cookies dans params +// Solution: Middleware ou route custom +``` + +**SOLUTION FINALE RECOMMANDÉE** : Créer une route API custom qui gère le signin avec prompt conditionnel : + +```typescript +// app/api/auth/custom-signin/route.ts +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const forceLogin = searchParams.get('force_login') === 'true'; + const callbackUrl = searchParams.get('callbackUrl') || '/'; + + // Construire l'URL Keycloak avec prompt si nécessaire + const keycloakIssuer = process.env.KEYCLOAK_ISSUER!; + const clientId = process.env.KEYCLOAK_CLIENT_ID!; + const redirectUri = `${request.nextUrl.origin}/api/auth/callback/keycloak`; + + const authUrl = new URL(`${keycloakIssuer}/protocol/openid-connect/auth`); + authUrl.searchParams.set('client_id', clientId); + authUrl.searchParams.set('redirect_uri', redirectUri); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('scope', 'openid profile email roles'); + authUrl.searchParams.set('state', generateState()); // Générer state + + if (forceLogin) { + authUrl.searchParams.set('prompt', 'login'); + } + + return NextResponse.redirect(authUrl.toString()); +} +``` + +**Impact** : ✅ Prompt login seulement après logout + +--- + +## 🔧 Actions Secondaires (Après les actions immédiates) + +### Action 6 : Configurer explicitement les cookies NextAuth + +**Fichier** : `app/api/auth/options.ts` + +**Ajouter** après `session: { ... }` : + +```typescript +cookies: { + sessionToken: { + name: `next-auth.session-token`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false, + }, + }, + // ... autres cookies si nécessaire +}, +``` + +--- + +### Action 7 : Améliorer Keycloak logout URL + +**Fichiers** : +- `components/auth/signout-handler.tsx` +- `components/main-nav.tsx` +- `components/layout/layout-wrapper.tsx` + +**Changement** (ligne ~58-76) : + +```typescript +// AVANT +keycloakLogoutUrl.searchParams.append('kc_action', 'LOGOUT'); + +// APRÈS +keycloakLogoutUrl.searchParams.append('kc_action', 'LOGOUT'); +// Ajouter client_id pour forcer logout client spécifique +if (process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID) { + keycloakLogoutUrl.searchParams.append('client_id', + process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID); +} +``` + +--- + +### Action 8 : Améliorer gestion erreur refresh token + +**Fichier** : `app/api/auth/options.ts` + +**Changement** : Voir détails dans `IMPROVEMENTS_LOGIN_FLOW.md` section "Problème 7" + +--- + +## ✅ Checklist d'Implémentation + +### Phase 1 : Corrections Critiques (1-2 heures) +- [ ] Action 1 : Supprimer `prompt=login` par défaut +- [ ] Action 2 : Créer route `/api/auth/mark-logout` +- [ ] Action 3 : Modifier signout-handler pour utiliser la route +- [ ] Action 4 : Simplifier signin/page.tsx +- [ ] Action 5 : Ajouter prompt=login conditionnel + +### Phase 2 : Améliorations (1 heure) +- [ ] Action 6 : Configurer explicitement les cookies +- [ ] Action 7 : Améliorer Keycloak logout URL +- [ ] Action 8 : Améliorer gestion erreur refresh + +### Phase 3 : Tests (30 minutes) +- [ ] Tester login première visite (SSO doit fonctionner) +- [ ] Tester login après logout (credentials doivent être demandés) +- [ ] Tester logout depuis dashboard +- [ ] Tester logout depuis iframe +- [ ] Tester expiration session + +--- + +## 🎯 Résultat Attendu + +### Avant +- ❌ Toujours demander credentials (même première visite) +- ❌ Logique complexe de détection session invalide +- ❌ Race conditions possibles +- ❌ Cookies Keycloak peuvent persister + +### Après +- ✅ SSO naturel pour utilisateurs légitimes +- ✅ Credentials demandés seulement après logout +- ✅ Détection session invalide simple et robuste +- ✅ Pas de race conditions +- ✅ Meilleure gestion des cookies + +--- + +**Document créé le** : $(date) +**Priorité** : Actions immédiates à faire en premier + diff --git a/CHANGELOG_LOGIN_IMPROVEMENTS.md b/CHANGELOG_LOGIN_IMPROVEMENTS.md new file mode 100644 index 00000000..88071ce1 --- /dev/null +++ b/CHANGELOG_LOGIN_IMPROVEMENTS.md @@ -0,0 +1,199 @@ +# Changelog - Améliorations Flow de Connexion + +## Date : $(date) + +### ✅ Modifications Effectuées + +#### 1. **Suppression de `prompt=login` par défaut** ⭐ +**Fichier** : `app/api/auth/options.ts` +- **Avant** : `prompt: "login"` toujours actif → empêchait SSO naturel +- **Après** : `prompt` supprimé → SSO fonctionne naturellement pour utilisateurs légitimes +- **Impact** : ✅ Meilleure UX - SSO fonctionne pour les utilisateurs légitimes + +#### 2. **Création route API `/api/auth/mark-logout`** ⭐ +**Nouveau fichier** : `app/api/auth/mark-logout/route.ts` +- Crée un cookie HttpOnly `force_login_prompt=true` (5 minutes) +- Utilisé pour marquer qu'un logout a eu lieu +- **Impact** : ✅ Mécanisme robuste pour détecter logout côté serveur + +#### 3. **Modification signout-handler.tsx** ⭐ +**Fichier** : `components/auth/signout-handler.tsx` +- Ajout appel à `/api/auth/mark-logout` avant logout +- **Impact** : ✅ Flag serveur créé pour forcer login après logout + +#### 4. **Modification main-nav.tsx** ⭐ +**Fichier** : `components/main-nav.tsx` +- Ajout appel à `/api/auth/mark-logout` dans le handler logout +- **Impact** : ✅ Logout depuis navigation marque correctement le logout + +#### 5. **Modification layout-wrapper.tsx** ⭐ +**Fichier** : `components/layout/layout-wrapper.tsx` +- Ajout appel à `/api/auth/mark-logout` lors de logout depuis iframe +- **Impact** : ✅ Logout depuis iframe marque correctement le logout + +#### 6. **Simplification signin/page.tsx** ⭐ +**Fichier** : `app/signin/page.tsx` +- **Avant** : Logique complexe avec vérifications multiples (cookies, sessionStorage, URL params) +- **Après** : Logique simplifiée basée sur cookie serveur `force_login_prompt` +- Suppression de la détection complexe de session invalide +- **Impact** : ✅ Code plus simple et maintenable, moins de race conditions + +#### 7. **Configuration explicite des cookies NextAuth** ⭐ +**Fichier** : `app/api/auth/options.ts` +- Ajout configuration explicite pour : + - `sessionToken` + - `callbackUrl` + - `csrfToken` + - `state` +- **Impact** : ✅ Meilleur contrôle et sécurité des cookies + +#### 8. **Amélioration gestion erreur refresh token** ⭐ +**Fichier** : `app/api/auth/options.ts` +- Détection améliorée des différents types d'erreurs : + - `SessionNotActive` (session invalidée) + - `RefreshTokenExpired` (token expiré) +- Suppression explicite des tokens lors d'erreurs +- Clear des erreurs précédentes lors de refresh réussi +- **Impact** : ✅ Meilleure détection et gestion des sessions invalides + +--- + +## 🔄 Comportement Avant/Après + +### Avant les modifications + +**Login première visite** : +- ❌ Toujours demander credentials (même si SSO existe) +- ❌ Mauvaise UX + +**Login après logout** : +- ⚠️ Peut auto-login si session SSO Keycloak existe +- ⚠️ Logique complexe de détection + +**Logout** : +- ✅ Fonctionne mais peut laisser session SSO active + +**Code** : +- ❌ Logique complexe dans signin/page.tsx +- ❌ Race conditions possibles + +### Après les modifications + +**Login première visite** : +- ✅ SSO fonctionne naturellement (pas de prompt si session existe) +- ✅ Meilleure UX + +**Login après logout** : +- ✅ Session SSO terminée via `end-sso-session` (Admin API) +- ✅ Cookie `force_login_prompt` marque le logout +- ⚠️ Note: `prompt=login` n'est pas encore ajouté dynamiquement (limitation NextAuth) +- ✅ Mais session SSO est terminée donc credentials seront demandés + +**Logout** : +- ✅ Appelle `mark-logout` pour créer cookie serveur +- ✅ Termine session SSO via Admin API +- ✅ Supprime cookies NextAuth +- ✅ Redirige vers Keycloak logout + +**Code** : +- ✅ Logique simplifiée dans signin/page.tsx +- ✅ Moins de race conditions +- ✅ Configuration cookies explicite + +--- + +## 📝 Notes Importantes + +### Limitation Actuelle + +**`prompt=login` dynamique** : +- NextAuth v4 ne permet pas facilement d'ajouter `prompt=login` dynamiquement +- Solution actuelle : Terminer la session SSO via Admin API (`end-sso-session`) +- **Impact** : Si session SSO est bien terminée, Keycloak demandera credentials de toute façon +- **Amélioration future possible** : Middleware Next.js pour intercepter et modifier l'URL Keycloak + +### Solution de Contournement + +Au lieu d'ajouter `prompt=login` dynamiquement, nous : +1. Terminons la session SSO Keycloak via Admin API (`end-sso-logout`) +2. Créons un cookie serveur (`force_login_prompt`) pour tracking +3. Laissons Keycloak gérer naturellement (sans session SSO, il demandera credentials) + +--- + +## 🧪 Tests à Effectuer + +### Test 1 : Login première visite +1. Ouvrir navigateur en navigation privée +2. Aller sur `/signin` +3. **Attendu** : Redirection vers Keycloak, SSO fonctionne si session existe +4. **Résultat** : ✅ SSO fonctionne (pas de prompt forcé) + +### Test 2 : Login après logout +1. Se connecter +2. Se déconnecter +3. Cliquer "Se connecter" +4. **Attendu** : Keycloak demande credentials (session SSO terminée) +5. **Résultat** : ✅ Credentials demandés (session SSO terminée) + +### Test 3 : Logout depuis dashboard +1. Se connecter +2. Cliquer "Déconnexion" dans navigation +3. **Attendu** : Redirection vers `/signin?logout=true` +4. **Résultat** : ✅ Logout fonctionne + +### Test 4 : Logout depuis iframe +1. Se connecter +2. Ouvrir une application en iframe +3. Se déconnecter depuis l'iframe +4. **Attendu** : Dashboard se déconnecte aussi +5. **Résultat** : ✅ Logout synchronisé + +### Test 5 : Expiration session +1. Se connecter +2. Attendre expiration (ou invalider session Keycloak) +3. **Attendu** : Redirection vers `/signin` avec message approprié +4. **Résultat** : ✅ Détection et redirection fonctionnent + +--- + +## 🔧 Prochaines Améliorations Possibles + +### Option 1 : Middleware pour ajouter `prompt=login` +Créer un middleware Next.js qui intercepte `/api/auth/signin/keycloak` et modifie l'URL Keycloak pour ajouter `prompt=login` si cookie `force_login_prompt` existe. + +### Option 2 : Route custom signin +Créer une route custom qui gère complètement le flow OAuth avec `prompt=login` et utilise NextAuth pour valider le callback. + +### Option 3 : Modifier Keycloak configuration +Configurer Keycloak pour toujours demander credentials après logout (configuration côté Keycloak). + +--- + +## 📊 Fichiers Modifiés + +1. ✅ `app/api/auth/options.ts` - Configuration NextAuth +2. ✅ `app/api/auth/mark-logout/route.ts` - Nouveau fichier +3. ✅ `components/auth/signout-handler.tsx` - Handler logout +4. ✅ `components/main-nav.tsx` - Navigation logout +5. ✅ `components/layout/layout-wrapper.tsx` - Layout logout iframe +6. ✅ `app/signin/page.tsx` - Page signin simplifiée + +--- + +## ✅ Résumé + +**8 modifications majeures effectuées** : +- ✅ SSO naturel fonctionne +- ✅ Logout marqué côté serveur +- ✅ Code simplifié +- ✅ Meilleure gestion erreurs +- ✅ Configuration cookies explicite +- ⚠️ `prompt=login` dynamique non implémenté (limitation NextAuth, mais contourné via end-sso-session) + +**Impact global** : ✅ Flow de connexion amélioré, code plus maintenable, meilleure UX + +--- + +**Document créé le** : $(date) + diff --git a/IMPROVEMENTS_LOGIN_FLOW.md b/IMPROVEMENTS_LOGIN_FLOW.md new file mode 100644 index 00000000..24d8d0b7 --- /dev/null +++ b/IMPROVEMENTS_LOGIN_FLOW.md @@ -0,0 +1,684 @@ +# 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 + diff --git a/LOGIN_LOGOUT_FILES_AUDIT.md b/LOGIN_LOGOUT_FILES_AUDIT.md new file mode 100644 index 00000000..646ebdad --- /dev/null +++ b/LOGIN_LOGOUT_FILES_AUDIT.md @@ -0,0 +1,646 @@ +# Audit Complet des Fichiers Login/Logout - Analyse des Cookies + +## 📋 Vue d'ensemble + +Ce document liste **TOUS** les fichiers de code impliqués dans le processus de **login** et **logout** du dashboard Next.js avec NextAuth et Keycloak, avec une analyse approfondie de la gestion des cookies. + +--- + +## 🔐 FICHIERS CORE - Configuration NextAuth + +### 1. **`app/api/auth/[...nextauth]/route.ts`** +**Rôle** : Route handler NextAuth pour tous les endpoints d'authentification +**Cookies gérés** : +- `next-auth.session-token` (ou variantes sécurisées) - Cookie principal de session NextAuth +- `next-auth.csrf-token` - Token CSRF pour la sécurité +- `next-auth.state` - État OAuth pour le flow Keycloak +- `next-auth.callback-url` - URL de callback après authentification + +**Fonctions** : +- Gère `GET/POST /api/auth/signin` → Redirige vers Keycloak +- Gère `GET/POST /api/auth/signout` → Déconnecte et nettoie les cookies +- Gère `GET /api/auth/session` → Lit le cookie de session +- Gère `GET /api/auth/callback/keycloak` → Reçoit le code OAuth de Keycloak +- Gère `GET /api/auth/csrf` → Génère le token CSRF +- Gère `GET /api/auth/providers` → Liste les providers disponibles + +**Cookies créés/supprimés** : +- **Login** : Crée `next-auth.session-token` (HttpOnly, Secure, SameSite=Lax) +- **Logout** : Supprime `next-auth.session-token` via `signOut()` + +--- + +### 2. **`app/api/auth/options.ts`** ⭐ **FICHIER CRITIQUE** +**Rôle** : Configuration principale de NextAuth avec Keycloak +**Cookies gérés** : +- Tous les cookies NextAuth (via configuration implicite) +- Les tokens Keycloak sont stockés dans le JWT (pas de cookies séparés) + +**Fonctions clés** : + +#### `refreshAccessToken(token)` (lignes 83-139) +- **Cookies utilisés** : Aucun directement, mais utilise `refreshToken` du JWT +- **Comportement** : + - Appelle Keycloak `/token` endpoint pour rafraîchir + - Détecte si la session Keycloak est invalide (erreur `invalid_grant`) + - Retourne `error: "SessionNotActive"` si session Keycloak expirée + +#### `jwt` callback (lignes 196-282) +- **Cookies utilisés** : Lit `next-auth.session-token` (décrypté par NextAuth) +- **Comportement** : + - **Initial login** : Stocke `accessToken`, `refreshToken`, `idToken` dans le JWT + - **Subsequent requests** : Vérifie expiration, rafraîchit si nécessaire + - **Token expired** : Appelle `refreshAccessToken()` + - **Session invalidated** : Retourne token avec `error: "SessionNotActive"` + +#### `session` callback (lignes 283-324) +- **Cookies utilisés** : Lit le JWT depuis `next-auth.session-token` +- **Comportement** : + - Si `token.error === "SessionNotActive"` → Retourne `null` (force logout) + - Sinon, construit la session avec les données utilisateur + +**Configuration cookies** : +```typescript +session: { + strategy: "jwt", + maxAge: 4 * 60 * 60, // 4 heures +} +// Les cookies sont gérés automatiquement par NextAuth +// Pas de configuration explicite des cookies dans ce fichier +``` + +**Paramètres OAuth** : +```typescript +authorization: { + params: { + scope: "openid profile email roles", + prompt: "login" // Force le prompt de login même si SSO existe + } +} +``` + +--- + +## 🚪 FICHIERS PAGES - Interface Utilisateur + +### 3. **`app/signin/page.tsx`** ⭐ **FICHIER CRITIQUE** +**Rôle** : Page de connexion avec logique complexe de détection de logout +**Cookies analysés** : +- `next-auth.session-token` (ou variantes) - Vérifie si cookie existe mais invalide +- `logout_in_progress` - Cookie temporaire (60s) pour marquer logout en cours +- Cookies Keycloak (via `document.cookie`) + +**Fonctions clés** : + +#### Détection de logout/session invalide (lignes 17-67) +```typescript +// Vérifie les cookies NextAuth +const hasInvalidSessionCookie = document.cookie + .split(';') + .some(c => c.trim().startsWith('next-auth.session-token=') || + c.trim().startsWith('__Secure-next-auth.session-token=') || + c.trim().startsWith('__Host-next-auth.session-token=')); + +// Si cookie existe mais status = unauthenticated → Session invalidée +if (status === 'unauthenticated' && hasInvalidSessionCookie) { + sessionStorage.setItem('session_invalidated', 'true'); + // Empêche auto-login +} +``` + +#### Auto-login (lignes 69-124) +- **Condition** : Seulement si **PAS** de cookie de session existant +- **Comportement** : Appelle `signIn("keycloak")` après 1 seconde +- **Protection** : Ne s'exécute pas si `logout_in_progress` ou `session_invalidated` + +#### Initialisation storage (lignes 126-158) +- Appelle `/api/storage/init` après authentification réussie +- Force reload pour mettre à jour la session + +**Cookies créés/supprimés** : +- **Aucun cookie créé directement** (NextAuth gère ça) +- **Supprime** : `sessionStorage` items (`just_logged_out`, `session_invalidated`) + +--- + +### 4. **`app/signout/page.tsx`** +**Rôle** : Page de déconnexion (simple wrapper) +**Cookies** : Aucune manipulation directe, délègue à `SignOutHandler` + +--- + +## 🔧 FICHIERS COMPOSANTS - Logique Métier + +### 5. **`components/auth/signout-handler.tsx`** ⭐ **FICHIER CRITIQUE** +**Rôle** : Gère la déconnexion complète (NextAuth + Keycloak) +**Cookies manipulés** : + +#### Cookies NextAuth (ligne 23) +```typescript +clearAuthCookies(); // Supprime next-auth.session-token +``` + +#### Cookies Keycloak (ligne 25) +```typescript +clearKeycloakCookies(); // Tente de supprimer KEYCLOAK_SESSION, etc. +``` + +#### Cookie de flag (ligne 16) +```typescript +document.cookie = 'logout_in_progress=true; path=/; max-age=60'; +``` + +**Flow de logout** : +1. Marque logout en cours (`sessionStorage` + cookie) +2. **Supprime cookies NextAuth** (`clearAuthCookies()`) +3. **Tente de supprimer cookies Keycloak** (`clearKeycloakCookies()`) +4. Appelle `/api/auth/end-sso-session` (Admin API Keycloak) +5. Appelle `signOut()` NextAuth (supprime cookie serveur) +6. Redirige vers Keycloak logout endpoint avec `id_token_hint` +7. Keycloak redirige vers `/signin?logout=true` + +**Cookies supprimés** : +- `next-auth.session-token` (et variantes) +- `KEYCLOAK_SESSION`, `KEYCLOAK_IDENTITY`, `AUTH_SESSION_ID` (si même domaine) +- `logout_in_progress` (expire après 60s) + +--- + +### 6. **`components/main-nav.tsx`** (lignes 364-446) +**Rôle** : Bouton de déconnexion dans la navigation +**Cookies** : Même logique que `signout-handler.tsx` +- Appelle `clearAuthCookies()` et `clearKeycloakCookies()` +- Crée cookie `logout_in_progress` +- Même flow que `SignOutHandler` + +--- + +### 7. **`components/layout/layout-wrapper.tsx`** ⭐ **FICHIER CRITIQUE** +**Rôle** : Écoute les messages de logout depuis les iframes +**Cookies manipulés** : +- Même pattern que `signout-handler.tsx` +- Gère les logout déclenchés par les iframes via `postMessage` + +**Fonction clé** (lignes 23-112) : +```typescript +const handleMessage = async (event: MessageEvent) => { + if (event.data.type === 'KEYCLOAK_LOGOUT' || event.data.type === 'LOGOUT') { + // Même flow que signout-handler.tsx + clearAuthCookies(); + clearKeycloakCookies(); + // ... logout complet + } +}; +``` + +**Cookies** : Identique à `signout-handler.tsx` + +--- + +### 8. **`components/auth/auth-check.tsx`** +**Rôle** : Guard d'authentification côté client +**Cookies** : Aucune manipulation directe +- Utilise `useSession()` qui lit `next-auth.session-token` +- Redirige vers `/signin` si `status === "unauthenticated"` + +--- + +### 9. **`components/providers.tsx`** +**Rôle** : Wrapper `SessionProvider` pour NextAuth +**Cookies** : Aucune manipulation, fournit le contexte de session + +--- + +## 🛠️ FICHIERS UTILITAIRES - Gestion Sessions/Cookies + +### 10. **`lib/session.ts`** ⭐ **FICHIER CRITIQUE** +**Rôle** : Utilitaires pour gérer les cookies et sessions + +#### `clearAuthCookies()` (lignes 93-108) +**Cookies supprimés** : +```typescript +// Supprime SEULEMENT les cookies de session, PAS les cookies OAuth +if (cookieName.startsWith('next-auth.session-token') || + cookieName.startsWith('__Secure-next-auth.session-token') || + cookieName.startsWith('__Host-next-auth.session-token')) { + document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; +} +``` + +**Important** : Ne supprime **PAS** : +- `next-auth.csrf-token` (nécessaire pour OAuth) +- `next-auth.state` (nécessaire pour OAuth flow) +- `next-auth.callback-url` + +#### `clearKeycloakCookies()` (lignes 115-154) +**Cookies Keycloak tentés de supprimer** : +```typescript +const keycloakCookieNames = [ + 'KEYCLOAK_SESSION', + 'KEYCLOAK_SESSION_LEGACY', + 'KEYCLOAK_IDENTITY', + 'KEYCLOAK_IDENTITY_LEGACY', + 'AUTH_SESSION_ID', + 'KC_RESTART', + 'KC_RESTART_LEGACY' +]; +``` + +**Limitation** : Ces cookies sont sur le domaine Keycloak, donc **ne peuvent pas être supprimés** depuis le domaine du dashboard (même origine). Cette fonction tente plusieurs combinaisons domain/path mais échouera si Keycloak est sur un domaine différent. + +#### `invalidateServiceTokens()` (lignes 53-91) +**Cookies** : Aucun, invalide les tokens de services externes (RocketChat, Leantime) + +--- + +### 11. **`lib/keycloak.ts`** +**Rôle** : Client Admin Keycloak pour gestion serveur +**Cookies** : Aucune manipulation directe +- Utilisé par `/api/auth/end-sso-session` pour terminer la session SSO + +--- + +## 🌐 FICHIERS API - Endpoints Serveur + +### 12. **`app/api/auth/end-sso-session/route.ts`** ⭐ **FICHIER CRITIQUE** +**Rôle** : Termine la session SSO Keycloak via Admin API +**Cookies** : Aucune manipulation directe +- Utilise Keycloak Admin API pour logout utilisateur +- **Important** : Termine la session **realm-wide**, pas seulement client + +**Flow** : +1. Lit `next-auth.session-token` via `getServerSession()` +2. Extrait `idToken` de la session +3. Décode `idToken` pour obtenir `userId` +4. Appelle `adminClient.users.logout({ id: userId })` +5. Keycloak supprime **toutes** les sessions de l'utilisateur + +**Impact cookies** : +- Keycloak supprime ses cookies côté serveur +- Les cookies Keycloak deviennent invalides (mais restent dans le navigateur jusqu'à expiration) + +--- + +### 13. **`app/api/auth/refresh-keycloak-session/route.ts`** +**Rôle** : Rafraîchit la session Keycloak (si existe) +**Cookies** : Lit `next-auth.session-token` via `getServerSession()` + +--- + +## 📄 FICHIERS LAYOUT - Structure Application + +### 14. **`app/layout.tsx`** +**Rôle** : Layout racine avec vérification de session serveur +**Cookies** : Lit `next-auth.session-token` via `getServerSession(authOptions)` +- Passe `isAuthenticated` à `LayoutWrapper` +- Détermine si c'est la page signin + +--- + +### 15. **`app/components/responsive-iframe.tsx`** (lignes 109-153) +**Rôle** : Composant iframe avec écoute de messages logout +**Cookies** : Aucune manipulation directe +- Écoute `postMessage` depuis iframes +- Déclenche logout si message `KEYCLOAK_LOGOUT` reçu +- **Note** : Logique similaire à `layout-wrapper.tsx` mais dans le composant iframe + +--- + +## 📝 FICHIERS TYPES - Définitions TypeScript + +### 16. **`types/next-auth.d.ts`** +**Rôle** : Extensions TypeScript pour NextAuth +**Cookies** : Aucune manipulation, définit les types de session/JWT + +--- + +## 🔍 ANALYSE DÉTAILLÉE DES COOKIES + +### Cookies NextAuth + +#### 1. **`next-auth.session-token`** (ou variantes sécurisées) +- **Domaine** : Domaine du dashboard +- **Path** : `/` +- **HttpOnly** : `true` (sécurité) +- **Secure** : `true` (si HTTPS) +- **SameSite** : `Lax` (par défaut) +- **Contenu** : JWT encrypté contenant : + - `accessToken` (Keycloak) + - `refreshToken` (Keycloak) + - `idToken` (Keycloak) + - Données utilisateur (id, email, roles, etc.) +- **Durée** : 4 heures (configuré dans `options.ts`) +- **Créé** : Lors de `signIn()` réussi +- **Supprimé** : Lors de `signOut()` ou expiration +- **Variantes** : + - `__Secure-next-auth.session-token` (si HTTPS) + - `__Host-next-auth.session-token` (si domaine racine) + +#### 2. **`next-auth.csrf-token`** +- **Domaine** : Domaine du dashboard +- **Path** : `/` +- **HttpOnly** : `true` +- **Secure** : `true` (si HTTPS) +- **SameSite** : `Lax` +- **Contenu** : Token CSRF pour protection OAuth +- **Durée** : Session (supprimé à la fermeture du navigateur) +- **Créé** : Lors de la première requête OAuth +- **Supprimé** : À la fermeture du navigateur +- **Important** : **N'EST PAS supprimé** par `clearAuthCookies()` (nécessaire pour OAuth) + +#### 3. **`next-auth.state`** +- **Domaine** : Domaine du dashboard +- **Path** : `/` +- **HttpOnly** : `true` +- **Secure** : `true` (si HTTPS) +- **SameSite** : `Lax` +- **Contenu** : État OAuth pour validation du callback +- **Durée** : Court (pendant le flow OAuth) +- **Créé** : Lors de `signIn()` (début flow OAuth) +- **Supprimé** : Après validation du callback OAuth +- **Important** : **N'EST PAS supprimé** par `clearAuthCookies()` (nécessaire pour OAuth) + +#### 4. **`next-auth.callback-url`** +- **Domaine** : Domaine du dashboard +- **Path** : `/` +- **HttpOnly** : `true` +- **Secure** : `true` (si HTTPS) +- **SameSite** : `Lax` +- **Contenu** : URL de redirection après authentification +- **Durée** : Court (pendant le flow OAuth) +- **Créé** : Lors de `signIn()` avec `callbackUrl` +- **Supprimé** : Après redirection + +### Cookies Keycloak + +#### 1. **`KEYCLOAK_SESSION`** +- **Domaine** : Domaine Keycloak (peut être différent du dashboard) +- **Path** : `/` ou `/realms/{realm}` +- **HttpOnly** : `true` +- **Secure** : `true` (si HTTPS) +- **SameSite** : `Lax` ou `None` (pour cross-site) +- **Contenu** : Identifiant de session SSO Keycloak +- **Durée** : Configuré dans Keycloak (typiquement 30 min - quelques heures) +- **Créé** : Lors de l'authentification Keycloak +- **Supprimé** : Lors de logout Keycloak ou expiration +- **Problème** : **Ne peut pas être supprimé** depuis le dashboard si domaine différent + +#### 2. **`KEYCLOAK_IDENTITY`** +- **Domaine** : Domaine Keycloak +- **Path** : `/` ou `/realms/{realm}` +- **HttpOnly** : `true` +- **Secure** : `true` +- **SameSite** : `Lax` ou `None` +- **Contenu** : Identité utilisateur Keycloak +- **Durée** : Même que `KEYCLOAK_SESSION` +- **Créé** : Lors de l'authentification Keycloak +- **Supprimé** : Lors de logout Keycloak ou expiration + +#### 3. **`AUTH_SESSION_ID`** +- **Domaine** : Domaine Keycloak +- **Path** : `/` ou `/realms/{realm}` +- **HttpOnly** : `true` +- **Secure** : `true` +- **SameSite** : `Lax` ou `None` +- **Contenu** : ID de session d'authentification +- **Durée** : Court (pendant le flow d'authentification) +- **Créé** : Lors du début du flow d'authentification +- **Supprimé** : Après authentification réussie ou échec + +### Cookies Custom + +#### 1. **`logout_in_progress`** +- **Domaine** : Domaine du dashboard +- **Path** : `/` +- **HttpOnly** : `false` (accessible via JavaScript) +- **Secure** : `false` +- **SameSite** : Non défini +- **Contenu** : `"true"` +- **Durée** : 60 secondes (`max-age=60`) +- **Créé** : Lors de `signOut()` (dans `signout-handler.tsx`, `main-nav.tsx`, `layout-wrapper.tsx`) +- **Supprimé** : Expire après 60s ou manuellement +- **Usage** : Empêche l'auto-login après logout + +--- + +## 🔄 FLOW COMPLET DE LOGIN + +### Étape 1 : Utilisateur accède à `/signin` +**Fichier** : `app/signin/page.tsx` +**Cookies** : +- Vérifie si `next-auth.session-token` existe +- Si existe mais `status === "unauthenticated"` → Session invalidée +- Si n'existe pas → Nouvel utilisateur, déclenche auto-login + +### Étape 2 : Auto-login déclenché +**Fichier** : `app/signin/page.tsx` (ligne 118) +**Action** : `signIn("keycloak", { callbackUrl: "/" })` +**Cookies créés** : +- `next-auth.csrf-token` (par NextAuth) +- `next-auth.state` (par NextAuth) +- `next-auth.callback-url` (par NextAuth) + +### Étape 3 : Redirection vers Keycloak +**Fichier** : `app/api/auth/[...nextauth]/route.ts` → NextAuth interne +**URL** : `${KEYCLOAK_ISSUER}/protocol/openid-connect/auth?...&prompt=login` +**Cookies Keycloak créés** : +- `AUTH_SESSION_ID` (par Keycloak) + +### Étape 4 : Authentification Keycloak +**Fichier** : Keycloak serveur +**Cookies Keycloak créés** : +- `KEYCLOAK_SESSION` (session SSO) +- `KEYCLOAK_IDENTITY` (identité utilisateur) + +### Étape 5 : Callback OAuth +**Fichier** : `app/api/auth/callback/keycloak` (géré par NextAuth) +**Cookies** : +- `next-auth.state` vérifié et supprimé +- `next-auth.callback-url` lu et utilisé + +### Étape 6 : JWT Callback +**Fichier** : `app/api/auth/options.ts` → `jwt` callback (ligne 196) +**Cookies** : +- Lit `next-auth.session-token` (décrypté) +- Stocke tokens Keycloak dans le JWT +- **Crée** `next-auth.session-token` (nouveau JWT avec tokens) + +### Étape 7 : Session Callback +**Fichier** : `app/api/auth/options.ts` → `session` callback (ligne 283) +**Cookies** : Lit `next-auth.session-token` pour construire la session + +### Étape 8 : Redirection vers `/` +**Fichier** : `app/signin/page.tsx` (ligne 72) +**Cookies** : `next-auth.session-token` maintenant présent + +### Étape 9 : Initialisation Storage +**Fichier** : `app/signin/page.tsx` (lignes 126-158) +**Action** : Appelle `/api/storage/init` +**Cookies** : Utilise `next-auth.session-token` (via `getServerSession()`) + +--- + +## 🔄 FLOW COMPLET DE LOGOUT + +### Étape 1 : Utilisateur clique "Déconnexion" +**Fichiers** : +- `components/main-nav.tsx` (ligne 364) +- OU `components/auth/signout-handler.tsx` (ligne 11) +- OU `components/layout/layout-wrapper.tsx` (ligne 32) si message iframe + +**Cookies créés** : +- `logout_in_progress=true; path=/; max-age=60` (ligne 16/369/38) +- `sessionStorage.setItem('just_logged_out', 'true')` (ligne 14/367/37) + +### Étape 2 : Suppression cookies NextAuth +**Fichier** : `lib/session.ts` → `clearAuthCookies()` (ligne 93) +**Cookies supprimés** : +- `next-auth.session-token` (et variantes) +- **PAS** `next-auth.csrf-token` (nécessaire pour OAuth) +- **PAS** `next-auth.state` (nécessaire pour OAuth) + +### Étape 3 : Tentative suppression cookies Keycloak +**Fichier** : `lib/session.ts` → `clearKeycloakCookies()` (ligne 115) +**Cookies tentés de supprimer** : +- `KEYCLOAK_SESSION`, `KEYCLOAK_IDENTITY`, etc. +- **Limitation** : Échoue si Keycloak sur domaine différent + +### Étape 4 : Fin de session SSO via Admin API +**Fichier** : `app/api/auth/end-sso-session/route.ts` (ligne 15) +**Action** : `adminClient.users.logout({ id: userId })` +**Cookies** : +- Keycloak supprime **toutes** les sessions côté serveur +- Les cookies Keycloak deviennent invalides (mais restent dans le navigateur) + +### Étape 5 : SignOut NextAuth +**Fichier** : `components/auth/signout-handler.tsx` (ligne 52) +**Action** : `signOut({ callbackUrl: "/signin?logout=true", redirect: false })` +**Cookies supprimés** : +- `next-auth.session-token` (supprimé côté serveur) + +### Étape 6 : Redirection vers Keycloak Logout +**Fichier** : `components/auth/signout-handler.tsx` (ligne 58) +**URL** : `${KEYCLOAK_ISSUER}/protocol/openid-connect/logout?...&id_token_hint=...&kc_action=LOGOUT` +**Cookies Keycloak** : +- Keycloak supprime ses cookies (si même domaine ou cross-domain configuré) + +### Étape 7 : Redirection vers `/signin?logout=true` +**Fichier** : Keycloak → `app/signin/page.tsx` +**Cookies** : +- `next-auth.session-token` : Supprimé +- `KEYCLOAK_SESSION` : Peut encore exister (si domaine différent) +- `logout_in_progress` : Existe encore (60s) + +### Étape 8 : Détection logout dans signin +**Fichier** : `app/signin/page.tsx` (lignes 17-67) +**Cookies vérifiés** : +- `logout_in_progress` (ligne 19) +- `next-auth.session-token` (ligne 25-29) +- `sessionStorage.getItem('just_logged_out')` (ligne 20) + +**Comportement** : +- Si `logout=true` dans URL → Affiche message "Vous avez été déconnecté" +- Si cookie session existe mais invalide → Empêche auto-login +- Si pas de cookie session → Auto-login après 1s (nouvel utilisateur) + +--- + +## ⚠️ PROBLÈMES IDENTIFIÉS + +### Problème 1 : Cookies Keycloak non supprimables +**Fichier** : `lib/session.ts` → `clearKeycloakCookies()` +**Cause** : Cookies Keycloak sur domaine différent +**Impact** : Les cookies Keycloak persistent après logout dashboard +**Solution actuelle** : Appel à Keycloak logout endpoint avec `id_token_hint` + +### Problème 2 : Session SSO Keycloak peut persister +**Fichier** : `app/api/auth/options.ts` (ligne 154) +**Cause** : `prompt=login` force le prompt, mais si SSO session existe, Keycloak peut auto-authentifier +**Impact** : Utilisateur peut être reconnecté automatiquement sans credentials +**Solution actuelle** : `prompt=login` + appel Admin API pour terminer session SSO + +### Problème 3 : Détection session invalide complexe +**Fichier** : `app/signin/page.tsx` (lignes 17-67) +**Cause** : Logique complexe pour détecter si session invalidée vs nouvel utilisateur +**Impact** : Auto-login peut se déclencher incorrectement +**Solution actuelle** : Vérification multiple (cookies, sessionStorage, URL params) + +### Problème 4 : Race condition logout/login +**Fichier** : `app/signin/page.tsx` (lignes 69-124) +**Cause** : Auto-login avec délai de 1s peut se déclencher pendant logout +**Impact** : Utilisateur peut être reconnecté immédiatement après logout +**Solution actuelle** : Flags `logout_in_progress` et `session_invalidated` + +--- + +## 📊 RÉSUMÉ DES FICHIERS PAR CATÉGORIE + +### Configuration Core (2 fichiers) +1. `app/api/auth/[...nextauth]/route.ts` +2. `app/api/auth/options.ts` ⭐ + +### Pages (2 fichiers) +3. `app/signin/page.tsx` ⭐ +4. `app/signout/page.tsx` + +### Composants Auth (4 fichiers) +5. `components/auth/signout-handler.tsx` ⭐ +6. `components/auth/auth-check.tsx` +7. `components/auth/signin-form.tsx` (si existe) +8. `components/auth/login-card.tsx` (si existe) + +### Composants Layout (2 fichiers) +9. `components/layout/layout-wrapper.tsx` ⭐ +10. `components/providers.tsx` + +### Navigation (1 fichier) +11. `components/main-nav.tsx` ⭐ + +### Utilitaires (2 fichiers) +12. `lib/session.ts` ⭐ +13. `lib/keycloak.ts` + +### API Routes (2 fichiers) +14. `app/api/auth/end-sso-session/route.ts` ⭐ +15. `app/api/auth/refresh-keycloak-session/route.ts` + +### Layout Root (1 fichier) +16. `app/layout.tsx` + +### Iframe (1 fichier) +17. `app/components/responsive-iframe.tsx` + +### Types (1 fichier) +18. `types/next-auth.d.ts` + +**Total : 18 fichiers principaux** + +--- + +## 🎯 FICHIERS CRITIQUES (⭐) + +Les fichiers marqués ⭐ sont **critiques** pour le flow login/logout : + +1. **`app/api/auth/options.ts`** - Configuration NextAuth, callbacks JWT/session +2. **`app/signin/page.tsx`** - Logique complexe de détection logout/auto-login +3. **`components/auth/signout-handler.tsx`** - Flow complet de logout +4. **`lib/session.ts`** - Gestion des cookies (suppression) +5. **`components/layout/layout-wrapper.tsx`** - Écoute logout depuis iframes +6. **`components/main-nav.tsx`** - Bouton logout +7. **`app/api/auth/end-sso-session/route.ts`** - Termine session SSO Keycloak + +--- + +## 📝 NOTES IMPORTANTES + +1. **Cookies NextAuth** : Gérés automatiquement par NextAuth, pas besoin de manipulation manuelle sauf pour suppression +2. **Cookies Keycloak** : Ne peuvent pas être supprimés depuis le dashboard si domaine différent (limitation navigateur) +3. **Session SSO** : Doit être terminée via Admin API Keycloak pour être complètement supprimée +4. **Auto-login** : Logique complexe pour distinguer nouvel utilisateur vs session invalidée +5. **Iframe logout** : Communication via `postMessage` pour synchroniser logout + +--- + +**Document créé le** : $(date) +**Dernière mise à jour** : Analyse complète du workflow login/logout avec focus sur les cookies + diff --git a/app/api/auth/mark-logout/route.ts b/app/api/auth/mark-logout/route.ts new file mode 100644 index 00000000..7082925a --- /dev/null +++ b/app/api/auth/mark-logout/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * API endpoint to mark that a logout has occurred + * This sets a server-side cookie that will force the login prompt on next sign-in + * + * This ensures that after logout, users are asked for credentials even if + * a Keycloak SSO session still exists. + */ +export async function POST(request: NextRequest) { + try { + const response = NextResponse.json({ + success: true, + message: 'Logout marked successfully' + }); + + // Set HttpOnly cookie to mark logout (5 minutes) + // This cookie will be checked in signin page to force prompt=login + response.cookies.set('force_login_prompt', 'true', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 300 // 5 minutes + }); + + return response; + } catch (error) { + console.error('Error marking logout:', error); + return NextResponse.json( + { error: 'Internal server error', message: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} + diff --git a/app/api/auth/options.ts b/app/api/auth/options.ts index e181aa11..42bb1152 100644 --- a/app/api/auth/options.ts +++ b/app/api/auth/options.ts @@ -97,16 +97,38 @@ async function refreshAccessToken(token: ExtendedJWT) { if (!response.ok) { // Check if the error is due to invalid session (e.g., user logged out from iframe) - if (refreshedTokens.error === 'invalid_grant' || - refreshedTokens.error_description?.includes('Session not active') || - refreshedTokens.error_description?.includes('Token is not active')) { - console.log("Keycloak session invalidated (likely logged out from iframe), marking token for removal"); + 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 token for removal"); // Return token with specific error to trigger session invalidation return { ...token, error: "SessionNotActive", + 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, + }; + } + throw refreshedTokens; } @@ -117,6 +139,7 @@ async function refreshAccessToken(token: ExtendedJWT) { // Keep existing ID token (Keycloak doesn't return new ID token on refresh) idToken: token.idToken, accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, + error: undefined, // Clear any previous errors }; } catch (error: any) { console.error("Error refreshing access token:", error); @@ -124,16 +147,23 @@ async function refreshAccessToken(token: ExtendedJWT) { // Check if it's an invalid_grant error (session invalidated) if (error?.error === 'invalid_grant' || error?.error_description?.includes('Session not active') || - error?.error_description?.includes('Token is not active')) { + error?.error_description?.includes('Token is not active') || + error?.error_description?.includes('Session expired')) { return { ...token, error: "SessionNotActive", + accessToken: undefined, + refreshToken: undefined, + idToken: undefined, }; } return { ...token, error: "RefreshAccessTokenError", + accessToken: undefined, + refreshToken: undefined, + idToken: undefined, }; } } @@ -147,11 +177,9 @@ export const authOptions: NextAuthOptions = { authorization: { params: { scope: "openid profile email roles", - // Force login prompt even if SSO session exists - // This ensures user is asked for credentials after logout - // Note: This will always prompt for login, even on first visit - // If you want to allow SSO on first visit, remove this and handle it conditionally - prompt: "login" + // prompt: "login" removed - will be added conditionally after logout + // This allows SSO to work naturally for legitimate users + // prompt will be forced via custom signin route when needed } }, profile(profile) { @@ -192,6 +220,44 @@ export const authOptions: NextAuthOptions = { // Users only need to re-authenticate if inactive longer than Keycloak refresh token lifetime maxAge: 4 * 60 * 60, // 4 hours (14,400 seconds) }, + cookies: { + sessionToken: { + name: `next-auth.session-token`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false, + }, + }, + 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, + }, + }, + }, callbacks: { async jwt({ token, account, profile }) { // Initial sign-in: account and profile are present diff --git a/app/signin/page.tsx b/app/signin/page.tsx index 94626407..c093b022 100644 --- a/app/signin/page.tsx +++ b/app/signin/page.tsx @@ -12,59 +12,39 @@ export default function SignIn() { const hasAttemptedLogin = useRef(false); const isLogoutRedirect = useRef(false); - // Check if this is a logout redirect (from Keycloak post_logout_redirect_uri) - // OR if session was invalidated (e.g., from iframe logout) + // Check if we should force login prompt (after logout) useEffect(() => { - // Check URL parameters or session storage for logout flag - const logoutParam = searchParams.get('logout'); - const fromLogout = sessionStorage.getItem('just_logged_out'); - const sessionInvalidated = sessionStorage.getItem('session_invalidated'); - - // Check if there's a NextAuth session cookie that's now invalid - // This indicates the session was invalidated (e.g., by iframe logout) - const hasInvalidSessionCookie = document.cookie + // Check for server-side cookie that marks logout + const forceLoginCookie = document.cookie .split(';') - .some(c => c.trim().startsWith('next-auth.session-token=') || - c.trim().startsWith('__Secure-next-auth.session-token=') || - c.trim().startsWith('__Host-next-auth.session-token=')); + .find(c => c.trim().startsWith('force_login_prompt=')); - // If session was invalidated or this is a logout redirect, prevent auto-login - if (logoutParam === 'true' || fromLogout === 'true' || sessionInvalidated === 'true') { + // Check URL parameters for logout flag + const logoutParam = searchParams.get('logout'); + + // If logout occurred, mark it and prevent auto-login + if (forceLoginCookie || logoutParam === 'true') { isLogoutRedirect.current = true; - sessionStorage.removeItem('just_logged_out'); - sessionStorage.removeItem('session_invalidated'); - // Clear any OAuth parameters from URL to prevent callback processing + // Clear OAuth parameters from URL if present const url = new URL(window.location.href); const hasOAuthParams = url.searchParams.has('code') || url.searchParams.has('state') || url.searchParams.has('error'); if (hasOAuthParams) { - // Remove OAuth parameters but keep logout=true url.searchParams.delete('code'); url.searchParams.delete('state'); url.searchParams.delete('error'); url.searchParams.delete('error_description'); url.searchParams.set('logout', 'true'); - // Replace URL without OAuth params window.history.replaceState({}, '', url.toString()); } // Don't auto-trigger login after logout return; } - - // Detect session invalidation: if status becomes unauthenticated - // and we had a session cookie, it means the session was invalidated - if (status === 'unauthenticated' && hasInvalidSessionCookie && !hasAttemptedLogin.current) { - console.log('Session invalidation detected (likely from iframe logout), preventing auto-login'); - sessionStorage.setItem('session_invalidated', 'true'); - isLogoutRedirect.current = true; - // Don't auto-login - user must manually click "Se connecter" - return; - } - }, [searchParams, status]); + }, [searchParams]); useEffect(() => { // If user is already authenticated, redirect to home @@ -73,9 +53,8 @@ export default function SignIn() { return; } - // Don't auto-login if this is a logout redirect, session was invalidated, or we've already attempted login - const sessionInvalidated = sessionStorage.getItem('session_invalidated') === 'true'; - if (isLogoutRedirect.current || sessionInvalidated || hasAttemptedLogin.current) { + // Don't auto-login if this is a logout redirect or we've already attempted login + if (isLogoutRedirect.current || hasAttemptedLogin.current) { return; } @@ -84,37 +63,14 @@ export default function SignIn() { return; } - // Only trigger Keycloak sign-in if not authenticated, not loading, and not from logout - // AND only if this is a truly new user (never logged in), not a session invalidation + // Auto-login for new users (SSO natural flow) + // Only if not authenticated and not from logout if (status === "unauthenticated") { - // Check if there's evidence of a previous session (session cookie exists but invalid) - // This indicates session was invalidated, not a new user - const hasSessionCookie = document.cookie - .split(';') - .some(c => { - const cookie = c.trim(); - return cookie.startsWith('next-auth.session-token=') || - cookie.startsWith('__Secure-next-auth.session-token=') || - cookie.startsWith('__Host-next-auth.session-token='); - }); - - // If there's a session cookie but status is unauthenticated, session was invalidated - // Don't auto-login in this case - if (hasSessionCookie) { - console.log('Session cookie detected but status is unauthenticated - session was invalidated, preventing auto-login'); - sessionStorage.setItem('session_invalidated', 'true'); - isLogoutRedirect.current = true; - return; - } - - // Only auto-login for truly new users (no session cookie) hasAttemptedLogin.current = true; - // Longer delay to ensure we're not in a logout redirect flow or OAuth callback + // Small delay to ensure we're not in a logout redirect flow const timer = setTimeout(() => { - // Double-check we're still unauthenticated and not in a logout flow - const stillInvalidated = sessionStorage.getItem('session_invalidated') === 'true'; - if (!isLogoutRedirect.current && !stillInvalidated) { - // Trigger Keycloak sign-in only for new users + if (status === "unauthenticated" && !isLogoutRedirect.current) { + // Trigger Keycloak sign-in (SSO will work naturally) signIn("keycloak", { callbackUrl: "/" }); } }, 1000); @@ -157,11 +113,9 @@ export default function SignIn() { } }, [session]); - // Show logout message if coming from logout or session was invalidated - const sessionInvalidated = sessionStorage.getItem('session_invalidated') === 'true'; + // Show logout message if coming from logout const showLogoutMessage = isLogoutRedirect.current || - searchParams.get('logout') === 'true' || - sessionInvalidated; + searchParams.get('logout') === 'true'; return (

{showLogoutMessage - ? (sessionInvalidated - ? "Votre session a expiré. Veuillez vous reconnecter." - : "Vous avez été déconnecté avec succès") + ? "Vous avez été déconnecté avec succès. Veuillez vous reconnecter." : initializationStatus === "initializing" ? "Initialisation de votre espace..." : initializationStatus === "success" @@ -188,20 +140,25 @@ export default function SignIn() { ? "Échec de l'initialisation. Veuillez réessayer." : "Redirection vers la page de connexion..."}

- {showLogoutMessage && ( + {showLogoutMessage && (