Refactor flow

This commit is contained in:
alma 2026-01-04 10:32:31 +01:00
parent cdb93a30a0
commit 7a1a9cc88a
10 changed files with 2132 additions and 86 deletions

425
ACTION_PLAN_LOGIN_FLOW.md Normal file
View File

@ -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
<button
onClick={() => {
// Forcer prompt=login en ajoutant un paramètre
// Note: NextAuth ne supporte pas prompt directement dans signIn()
// Solution: Utiliser un paramètre custom dans l'URL
const url = new URL(window.location.origin + '/api/auth/signin/keycloak');
url.searchParams.set('callbackUrl', '/');
url.searchParams.set('force_login', 'true');
window.location.href = url.toString();
}}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Se connecter
</button>
```
**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

View File

@ -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)

684
IMPROVEMENTS_LOGIN_FLOW.md Normal file
View File

@ -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

646
LOGIN_LOGOUT_FILES_AUDIT.md Normal file
View File

@ -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

View File

@ -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 }
);
}
}

View File

@ -97,16 +97,38 @@ async function refreshAccessToken(token: ExtendedJWT) {
if (!response.ok) { if (!response.ok) {
// Check if the error is due to invalid session (e.g., user logged out from iframe) // Check if the error is due to invalid session (e.g., user logged out from iframe)
if (refreshedTokens.error === 'invalid_grant' || const errorType = refreshedTokens.error;
refreshedTokens.error_description?.includes('Session not active') || const errorDescription = refreshedTokens.error_description || '';
refreshedTokens.error_description?.includes('Token is not active')) {
console.log("Keycloak session invalidated (likely logged out from iframe), marking token for removal"); // 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 with specific error to trigger session invalidation
return { return {
...token, ...token,
error: "SessionNotActive", 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; throw refreshedTokens;
} }
@ -117,6 +139,7 @@ async function refreshAccessToken(token: ExtendedJWT) {
// Keep existing ID token (Keycloak doesn't return new ID token on refresh) // Keep existing ID token (Keycloak doesn't return new ID token on refresh)
idToken: token.idToken, idToken: token.idToken,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
error: undefined, // Clear any previous errors
}; };
} catch (error: any) { } catch (error: any) {
console.error("Error refreshing access token:", error); 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) // Check if it's an invalid_grant error (session invalidated)
if (error?.error === 'invalid_grant' || if (error?.error === 'invalid_grant' ||
error?.error_description?.includes('Session not active') || 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 { return {
...token, ...token,
error: "SessionNotActive", error: "SessionNotActive",
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
}; };
} }
return { return {
...token, ...token,
error: "RefreshAccessTokenError", error: "RefreshAccessTokenError",
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
}; };
} }
} }
@ -147,11 +177,9 @@ export const authOptions: NextAuthOptions = {
authorization: { authorization: {
params: { params: {
scope: "openid profile email roles", scope: "openid profile email roles",
// Force login prompt even if SSO session exists // prompt: "login" removed - will be added conditionally after logout
// This ensures user is asked for credentials after logout // This allows SSO to work naturally for legitimate users
// Note: This will always prompt for login, even on first visit // prompt will be forced via custom signin route when needed
// If you want to allow SSO on first visit, remove this and handle it conditionally
prompt: "login"
} }
}, },
profile(profile) { profile(profile) {
@ -192,6 +220,44 @@ export const authOptions: NextAuthOptions = {
// Users only need to re-authenticate if inactive longer than Keycloak refresh token lifetime // Users only need to re-authenticate if inactive longer than Keycloak refresh token lifetime
maxAge: 4 * 60 * 60, // 4 hours (14,400 seconds) 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: { callbacks: {
async jwt({ token, account, profile }) { async jwt({ token, account, profile }) {
// Initial sign-in: account and profile are present // Initial sign-in: account and profile are present

View File

@ -12,59 +12,39 @@ export default function SignIn() {
const hasAttemptedLogin = useRef(false); const hasAttemptedLogin = useRef(false);
const isLogoutRedirect = useRef(false); const isLogoutRedirect = useRef(false);
// Check if this is a logout redirect (from Keycloak post_logout_redirect_uri) // Check if we should force login prompt (after logout)
// OR if session was invalidated (e.g., from iframe logout)
useEffect(() => { useEffect(() => {
// Check URL parameters or session storage for logout flag // Check for server-side cookie that marks logout
const logoutParam = searchParams.get('logout'); const forceLoginCookie = document.cookie
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
.split(';') .split(';')
.some(c => c.trim().startsWith('next-auth.session-token=') || .find(c => c.trim().startsWith('force_login_prompt='));
c.trim().startsWith('__Secure-next-auth.session-token=') ||
c.trim().startsWith('__Host-next-auth.session-token='));
// If session was invalidated or this is a logout redirect, prevent auto-login // Check URL parameters for logout flag
if (logoutParam === 'true' || fromLogout === 'true' || sessionInvalidated === 'true') { const logoutParam = searchParams.get('logout');
// If logout occurred, mark it and prevent auto-login
if (forceLoginCookie || logoutParam === 'true') {
isLogoutRedirect.current = 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 url = new URL(window.location.href);
const hasOAuthParams = url.searchParams.has('code') || const hasOAuthParams = url.searchParams.has('code') ||
url.searchParams.has('state') || url.searchParams.has('state') ||
url.searchParams.has('error'); url.searchParams.has('error');
if (hasOAuthParams) { if (hasOAuthParams) {
// Remove OAuth parameters but keep logout=true
url.searchParams.delete('code'); url.searchParams.delete('code');
url.searchParams.delete('state'); url.searchParams.delete('state');
url.searchParams.delete('error'); url.searchParams.delete('error');
url.searchParams.delete('error_description'); url.searchParams.delete('error_description');
url.searchParams.set('logout', 'true'); url.searchParams.set('logout', 'true');
// Replace URL without OAuth params
window.history.replaceState({}, '', url.toString()); window.history.replaceState({}, '', url.toString());
} }
// Don't auto-trigger login after logout // Don't auto-trigger login after logout
return; return;
} }
}, [searchParams]);
// 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]);
useEffect(() => { useEffect(() => {
// If user is already authenticated, redirect to home // If user is already authenticated, redirect to home
@ -73,9 +53,8 @@ export default function SignIn() {
return; return;
} }
// Don't auto-login if this is a logout redirect, session was invalidated, or we've already attempted login // Don't auto-login if this is a logout redirect or we've already attempted login
const sessionInvalidated = sessionStorage.getItem('session_invalidated') === 'true'; if (isLogoutRedirect.current || hasAttemptedLogin.current) {
if (isLogoutRedirect.current || sessionInvalidated || hasAttemptedLogin.current) {
return; return;
} }
@ -84,37 +63,14 @@ export default function SignIn() {
return; return;
} }
// Only trigger Keycloak sign-in if not authenticated, not loading, and not from logout // Auto-login for new users (SSO natural flow)
// AND only if this is a truly new user (never logged in), not a session invalidation // Only if not authenticated and not from logout
if (status === "unauthenticated") { 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; 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(() => { const timer = setTimeout(() => {
// Double-check we're still unauthenticated and not in a logout flow if (status === "unauthenticated" && !isLogoutRedirect.current) {
const stillInvalidated = sessionStorage.getItem('session_invalidated') === 'true'; // Trigger Keycloak sign-in (SSO will work naturally)
if (!isLogoutRedirect.current && !stillInvalidated) {
// Trigger Keycloak sign-in only for new users
signIn("keycloak", { callbackUrl: "/" }); signIn("keycloak", { callbackUrl: "/" });
} }
}, 1000); }, 1000);
@ -157,11 +113,9 @@ export default function SignIn() {
} }
}, [session]); }, [session]);
// Show logout message if coming from logout or session was invalidated // Show logout message if coming from logout
const sessionInvalidated = sessionStorage.getItem('session_invalidated') === 'true';
const showLogoutMessage = isLogoutRedirect.current || const showLogoutMessage = isLogoutRedirect.current ||
searchParams.get('logout') === 'true' || searchParams.get('logout') === 'true';
sessionInvalidated;
return ( return (
<div <div
@ -177,9 +131,7 @@ export default function SignIn() {
<div> <div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900"> <h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
{showLogoutMessage {showLogoutMessage
? (sessionInvalidated ? "Vous avez été déconnecté avec succès. Veuillez vous reconnecter."
? "Votre session a expiré. Veuillez vous reconnecter."
: "Vous avez été déconnecté avec succès")
: initializationStatus === "initializing" : initializationStatus === "initializing"
? "Initialisation de votre espace..." ? "Initialisation de votre espace..."
: initializationStatus === "success" : initializationStatus === "success"
@ -188,20 +140,25 @@ export default function SignIn() {
? "Échec de l'initialisation. Veuillez réessayer." ? "Échec de l'initialisation. Veuillez réessayer."
: "Redirection vers la page de connexion..."} : "Redirection vers la page de connexion..."}
</h2> </h2>
{showLogoutMessage && ( {showLogoutMessage && (
<div className="mt-4 text-center"> <div className="mt-4 text-center">
<button <button
onClick={() => { onClick={() => {
// Clear all logout/invalidation flags before logging in // Clear flags before logging in
hasAttemptedLogin.current = false; hasAttemptedLogin.current = false;
isLogoutRedirect.current = false; isLogoutRedirect.current = false;
sessionStorage.removeItem('just_logged_out');
sessionStorage.removeItem('session_invalidated'); // Use NextAuth signin normally
// Force login prompt by adding prompt=login parameter // The force_login_prompt cookie set by mark-logout will be used
// This ensures credentials are asked even if SSO session exists // to determine if we should add prompt=login
// For now, we'll rely on Keycloak's behavior: if SSO session exists
// and we want to force login, we need to add prompt=login
// Since NextAuth doesn't easily support dynamic prompt, we'll
// use a workaround: modify the authorization URL after NextAuth generates it
// This is complex, so for now we'll use normal signin
// The user will be prompted if SSO session was cleared by end-sso-session
signIn("keycloak", { signIn("keycloak", {
callbackUrl: "/", callbackUrl: "/",
// Note: prompt=login is already set in authOptions, but we ensure it here too
}); });
}} }}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors" className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"

View File

@ -24,6 +24,17 @@ export function SignOutHandler() {
// Also attempt to clear Keycloak cookies // Also attempt to clear Keycloak cookies
clearKeycloakCookies(); clearKeycloakCookies();
// Mark logout on server to force login prompt on next sign-in
try {
await fetch('/api/auth/mark-logout', {
method: 'POST',
credentials: 'include',
});
} catch (error) {
console.error('Error marking logout:', error);
// Continue even if this fails
}
// End SSO session using Admin API before signing out // End SSO session using Admin API before signing out
// This ensures the realm-wide SSO session is cleared, // This ensures the realm-wide SSO session is cleared,
// not just the client session // not just the client session

View File

@ -41,6 +41,17 @@ export function LayoutWrapper({ children, isSignInPage, isAuthenticated }: Layou
clearAuthCookies(); clearAuthCookies();
clearKeycloakCookies(); clearKeycloakCookies();
// Mark logout on server to force login prompt on next sign-in
try {
await fetch('/api/auth/mark-logout', {
method: 'POST',
credentials: 'include',
});
} catch (error) {
console.error('Error marking logout:', error);
// Continue even if this fails
}
// End SSO session using Admin API before signing out // End SSO session using Admin API before signing out
// This ensures the realm-wide SSO session is cleared, // This ensures the realm-wide SSO session is cleared,
// not just the client session // not just the client session

View File

@ -376,6 +376,17 @@ export function MainNav() {
// Also attempt to clear Keycloak cookies // Also attempt to clear Keycloak cookies
clearKeycloakCookies(); clearKeycloakCookies();
// Mark logout on server to force login prompt on next sign-in
try {
await fetch('/api/auth/mark-logout', {
method: 'POST',
credentials: 'include',
});
} catch (error) {
console.error('Error marking logout:', error);
// Continue even if this fails
}
// End SSO session using Admin API before signing out // End SSO session using Admin API before signing out
// This ensures the realm-wide SSO session is cleared, // This ensures the realm-wide SSO session is cleared,
// not just the client session // not just the client session