20 KiB
Améliorations du Flow de Connexion - Recommandations
📋 Vue d'ensemble
Ce document propose des améliorations concrètes pour corriger et optimiser le flow de connexion/déconnexion du dashboard Next.js avec NextAuth et Keycloak.
🎯 Problèmes Identifiés et Solutions
Problème 1 : prompt=login toujours actif - Empêche SSO naturel
Situation actuelle :
// app/api/auth/options.ts ligne 154
authorization: {
params: {
scope: "openid profile email roles",
prompt: "login" // ⚠️ TOUJOURS actif
}
}
Impact :
- ❌ L'utilisateur doit toujours saisir ses identifiants, même lors de la première visite
- ❌ Empêche l'expérience SSO naturelle
- ❌ Mauvaise UX pour les utilisateurs légitimes
Solution recommandée : Gérer prompt=login conditionnellement
// app/api/auth/options.ts
authorization: {
params: {
scope: "openid profile email roles",
// Ne pas forcer prompt=login par défaut
// prompt: "login" // ❌ À SUPPRIMER
}
}
ET : Ajouter prompt=login uniquement après un logout explicite
// Dans signIn() après logout
signIn("keycloak", {
callbackUrl: "/",
// Ajouter prompt=login uniquement si logout récent
...(shouldForceLogin ? { prompt: "login" } : {})
});
Problème 2 : Détection session invalide trop complexe et fragile
Situation actuelle :
- Logique complexe dans
app/signin/page.tsx(lignes 17-67) - Vérification multiple de cookies, sessionStorage, URL params
- Race conditions possibles
- Auto-login peut se déclencher incorrectement
Solution recommandée : Simplifier avec un flag serveur
Option A : Utiliser un cookie serveur pour marquer le logout
// app/api/auth/end-sso-session/route.ts
export async function POST(request: NextRequest) {
// ... code existant ...
// Après logout réussi, créer un cookie de flag
const response = NextResponse.json({ success: true });
response.cookies.set('force_login_prompt', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 300 // 5 minutes
});
return response;
}
Option B : Utiliser un paramètre d'état dans l'URL Keycloak
// Dans signout-handler.tsx, ajouter un paramètre state
const keycloakLogoutUrl = new URL(
`${keycloakIssuer}/protocol/openid-connect/logout`
);
keycloakLogoutUrl.searchParams.append('state', 'force_login');
// Keycloak renverra ce state dans le redirect
Simplification de signin/page.tsx :
// Vérifier le cookie serveur au lieu de logique complexe
const forceLoginCookie = document.cookie
.split(';')
.find(c => c.trim().startsWith('force_login_prompt='));
if (forceLoginCookie) {
// Supprimer le cookie
document.cookie = 'force_login_prompt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// Forcer prompt=login
signIn("keycloak", {
callbackUrl: "/",
// Ajouter prompt via custom params si possible
});
}
Problème 3 : Cookies Keycloak non supprimables (domaine différent)
Situation actuelle :
clearKeycloakCookies()échoue si Keycloak est sur un domaine différent- Les cookies Keycloak persistent après logout
- Session SSO peut persister
Solutions recommandées :
Solution 1 : Utiliser Keycloak logout endpoint avec tous les paramètres
// Améliorer le logout URL dans signout-handler.tsx
const keycloakLogoutUrl = new URL(
`${keycloakIssuer}/protocol/openid-connect/logout`
);
// Paramètres essentiels
keycloakLogoutUrl.searchParams.append('post_logout_redirect_uri',
window.location.origin + '/signin?logout=true');
keycloakLogoutUrl.searchParams.append('id_token_hint', idToken);
// ✅ AJOUTER ces paramètres pour forcer la suppression SSO
keycloakLogoutUrl.searchParams.append('kc_action', 'LOGOUT'); // Déjà présent
keycloakLogoutUrl.searchParams.append('logout_hint', 'true'); // Nouveau
// Si possible, utiliser le client_id pour forcer logout client
if (process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID) {
keycloakLogoutUrl.searchParams.append('client_id',
process.env.NEXT_PUBLIC_KEYcloak_CLIENT_ID);
}
Solution 2 : Utiliser Admin API pour terminer TOUTES les sessions
Améliorer app/api/auth/end-sso-session/route.ts :
// Au lieu de logout({ id: userId }), utiliser logoutAllSessions
try {
// Option 1 : Logout toutes les sessions de l'utilisateur
await adminClient.users.logout({ id: userId });
// Option 2 : Si disponible, utiliser une méthode plus agressive
// Note: Vérifier la version de Keycloak Admin Client
// Certaines versions supportent logoutAllSessions
// Option 3 : Invalider les refresh tokens
const userSessions = await adminClient.users.listSessions({ id: userId });
for (const session of userSessions) {
await adminClient.users.logoutSession({
id: userId,
sessionId: session.id
});
}
} catch (error) {
// ... gestion erreur
}
Solution 3 : Configurer Keycloak pour SameSite=None (si cross-domain)
Configuration Keycloak (à faire côté Keycloak) :
Cookie SameSite: None
Cookie Secure: true
Cookie Domain: .example.com (domaine parent partagé)
Puis améliorer clearKeycloakCookies() :
// lib/session.ts
export function clearKeycloakCookies() {
const keycloakIssuer = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER;
if (!keycloakIssuer) return;
try {
const keycloakUrl = new URL(keycloakIssuer);
const keycloakDomain = keycloakUrl.hostname;
// Extraire le domaine parent si possible
const domainParts = keycloakDomain.split('.');
const parentDomain = domainParts.length > 2
? '.' + domainParts.slice(-2).join('.')
: keycloakDomain;
const keycloakCookieNames = [
'KEYCLOAK_SESSION',
'KEYCLOAK_SESSION_LEGACY',
'KEYCLOAK_IDENTITY',
'KEYCLOAK_IDENTITY_LEGACY',
'AUTH_SESSION_ID',
'KC_RESTART',
'KC_RESTART_LEGACY'
];
// Essayer avec domaine parent (pour SameSite=None)
keycloakCookieNames.forEach(cookieName => {
// Avec domaine parent
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${parentDomain}; SameSite=None; Secure;`;
// Sans domaine (same-origin)
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=None; Secure;`;
// Avec path spécifique
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/realms/; domain=${parentDomain}; SameSite=None; Secure;`;
});
} catch (error) {
console.error('Error clearing Keycloak cookies:', error);
}
}
Problème 4 : Race condition entre logout et auto-login
Situation actuelle :
- Auto-login avec délai de 1 seconde
- Peut se déclencher pendant le flow de logout
- Flags
logout_in_progressetsession_invalidatedpeuvent être perdus
Solution recommandée : Utiliser un mécanisme plus robuste
Option A : Utiliser un cookie HttpOnly pour le flag
// Créer une route API pour marquer le logout
// app/api/auth/mark-logout/route.ts
export async function POST() {
const response = NextResponse.json({ success: true });
response.cookies.set('logout_completed', 'true', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 300 // 5 minutes
});
return response;
}
// Dans signout-handler.tsx
await fetch('/api/auth/mark-logout', { method: 'POST' });
// Dans signin/page.tsx
const logoutCompleted = document.cookie
.split(';')
.some(c => c.trim().startsWith('logout_completed='));
if (logoutCompleted) {
// Supprimer le cookie
document.cookie = 'logout_completed=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// Ne pas auto-login
return;
}
Option B : Utiliser un état dans l'URL Keycloak callback
// Lors du logout, ajouter un paramètre state
const logoutState = btoa(JSON.stringify({
logout: true,
timestamp: Date.now()
}));
// Keycloak renverra ce state dans le redirect
// Dans signin/page.tsx, vérifier le state
const urlParams = new URLSearchParams(window.location.search);
const state = urlParams.get('state');
if (state) {
try {
const stateData = JSON.parse(atob(state));
if (stateData.logout) {
// Ne pas auto-login
return;
}
} catch (e) {
// State invalide, ignorer
}
}
Problème 5 : Configuration cookies NextAuth non explicite
Situation actuelle :
- Pas de configuration explicite des cookies NextAuth
- Utilise les valeurs par défaut
- Pas de contrôle sur SameSite, Secure, etc.
Solution recommandée : Configurer explicitement les cookies
// app/api/auth/options.ts
export const authOptions: NextAuthOptions = {
// ... providers ...
// ✅ AJOUTER configuration explicite des cookies
cookies: {
sessionToken: {
name: `next-auth.session-token`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
// Domaine explicite si nécessaire
// domain: process.env.COOKIE_DOMAIN,
},
},
callbackUrl: {
name: `next-auth.callback-url`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
},
},
csrfToken: {
name: `next-auth.csrf-token`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
},
},
state: {
name: `next-auth.state`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NEXTAUTH_URL?.startsWith('https://') ?? false,
},
},
},
// ... reste de la config ...
};
Avantages :
- Contrôle total sur les cookies
- Peut ajuster SameSite pour cross-domain si nécessaire
- Meilleure sécurité
Problème 6 : Détection session invalide côté client uniquement
Situation actuelle :
- Détection session invalide uniquement côté client
- Vérification de cookies via
document.cookie - Peut être contourné ou mal interprété
Solution recommandée : Détection serveur + client
Créer une route API pour vérifier l'état de session
// app/api/auth/session-status/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '../options';
export async function GET(request: NextRequest) {
const session = await getServerSession(authOptions);
// Vérifier si la session existe mais est invalide
const hasSessionCookie = request.cookies.has('next-auth.session-token') ||
request.cookies.has('__Secure-next-auth.session-token') ||
request.cookies.has('__Host-next-auth.session-token');
return NextResponse.json({
hasSession: !!session,
hasSessionCookie,
isInvalid: hasSessionCookie && !session, // Cookie existe mais session invalide
shouldForceLogin: request.cookies.get('force_login_prompt')?.value === 'true',
});
}
Utiliser cette route dans signin/page.tsx
// app/signin/page.tsx
useEffect(() => {
const checkSessionStatus = async () => {
const response = await fetch('/api/auth/session-status');
const status = await response.json();
if (status.isInvalid) {
// Session invalidée, ne pas auto-login
sessionStorage.setItem('session_invalidated', 'true');
return;
}
if (status.shouldForceLogin) {
// Forcer prompt=login
signIn("keycloak", {
callbackUrl: "/",
// prompt: "login" via custom params si possible
});
return;
}
// Nouvel utilisateur, auto-login OK
if (status === "unauthenticated" && !status.hasSessionCookie) {
signIn("keycloak", { callbackUrl: "/" });
}
};
checkSessionStatus();
}, []);
Problème 7 : Gestion d'erreur token refresh insuffisante
Situation actuelle :
refreshAccessToken()détecteSessionNotActive- Retourne
error: "SessionNotActive" - Mais la gestion peut être améliorée
Solution recommandée : Améliorer la gestion d'erreur
// app/api/auth/options.ts
async function refreshAccessToken(token: ExtendedJWT) {
try {
const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
grant_type: "refresh_token",
refresh_token: token.refreshToken || '',
}),
method: "POST",
});
const refreshedTokens = await response.json();
if (!response.ok) {
// ✅ AMÉLIORATION : Détecter différents types d'erreurs
const errorType = refreshedTokens.error;
const errorDescription = refreshedTokens.error_description || '';
// Session invalide (logout depuis iframe ou Keycloak)
if (errorType === 'invalid_grant' ||
errorDescription.includes('Session not active') ||
errorDescription.includes('Token is not active') ||
errorDescription.includes('Session expired')) {
console.log("Keycloak session invalidated, marking for removal");
return {
...token,
error: "SessionNotActive",
// ✅ Supprimer tous les tokens
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
};
}
// Refresh token expiré (inactivité prolongée)
if (errorType === 'invalid_grant' &&
errorDescription.includes('Refresh token expired')) {
console.log("Refresh token expired, user needs to re-authenticate");
return {
...token,
error: "RefreshTokenExpired",
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
};
}
// Autre erreur
throw refreshedTokens;
}
return {
...token,
accessToken: refreshedTokens.access_token,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
idToken: token.idToken, // Keycloak ne renvoie pas de nouvel ID token
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
error: undefined, // ✅ Clear any previous errors
};
} catch (error: any) {
console.error("Error refreshing access token:", error);
// ✅ AMÉLIORATION : Gestion d'erreur plus robuste
if (error?.error === 'invalid_grant' ||
error?.error_description?.includes('Session not active') ||
error?.error_description?.includes('Token is not active')) {
return {
...token,
error: "SessionNotActive",
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
};
}
return {
...token,
error: "RefreshAccessTokenError",
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
};
}
}
🚀 Plan d'Implémentation Recommandé
Phase 1 : Corrections Critiques (Priorité Haute)
-
Supprimer
prompt=loginpar défaut- Fichier :
app/api/auth/options.ts - Impact : Améliore l'UX pour les utilisateurs légitimes
- Fichier :
-
Améliorer la gestion d'erreur token refresh
- Fichier :
app/api/auth/options.ts - Impact : Meilleure détection des sessions invalides
- Fichier :
-
Configurer explicitement les cookies NextAuth
- Fichier :
app/api/auth/options.ts - Impact : Meilleur contrôle et sécurité
- Fichier :
Phase 2 : Améliorations Logout (Priorité Moyenne)
-
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
- Nouveau fichier :
-
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
- Modifier :
-
Améliorer
clearKeycloakCookies()- Modifier :
lib/session.ts - Impact : Meilleure tentative de suppression cookies cross-domain
- Modifier :
Phase 3 : Optimisations (Priorité Basse)
-
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
- Nouveau fichier :
-
Simplifier logique signin/page.tsx
- Modifier :
app/signin/page.tsx - Impact : Code plus maintenable
- Modifier :
📝 Checklist d'Implémentation
Étape 1 : Configuration NextAuth
- Supprimer
prompt: "login"par défaut dansoptions.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.tsxpour 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=loginconditionnel 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
-
Valid Redirect URIs :
http://localhost:3000/api/auth/callback/keycloak https://your-domain.com/api/auth/callback/keycloak -
Web Origins :
http://localhost:3000 https://your-domain.com -
Post Logout Redirect URIs :
http://localhost:3000/signin?logout=true https://your-domain.com/signin?logout=true -
Access Token Lifespan : 5 minutes (recommandé)
-
SSO Session Idle : 30 minutes
-
SSO Session Max : 10 heures
Configuration Realm
-
Cookies :
- SameSite:
None(si cross-domain) - Secure:
true(si HTTPS) - Domain: Domaine parent partagé (si cross-domain)
- SameSite:
-
Session Management :
- Enable SSO Session Idle:
true - SSO Session Idle Timeout: 30 minutes
- SSO Session Max Lifespan: 10 heures
- Enable SSO Session Idle:
📊 Métriques de Succès
Après implémentation, vérifier :
- ✅ Login première visite : SSO fonctionne (pas de prompt si session Keycloak existe)
- ✅ Login après logout : Prompt credentials demandé
- ✅ Logout dashboard : Toutes les sessions supprimées
- ✅ Logout iframe : Dashboard se déconnecte automatiquement
- ✅ Expiration session : Redirection vers signin propre
- ✅ 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