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

685 lines
20 KiB
Markdown

# 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