/** * Middleware Next.js pour la protection des routes avec Keycloak * Gère l'authentification et l'autorisation au niveau des routes */ import { NextRequest, NextResponse } from 'next/server'; import { jwtVerify, JWTPayload } from 'jose'; // Configuration des routes protégées const PROTECTED_ROUTES = [ '/dashboard', '/chantiers', '/clients', '/devis', '/factures', '/materiels', '/employes', '/equipes', '/planning', '/reports', '/admin', '/profile', ] as const; // Configuration des routes publiques (toujours accessibles) const PUBLIC_ROUTES = [ '/', '/api/health', '/api/auth/login', '/api/auth/logout', '/api/auth/token', ] as const; // Configuration des routes par rôle const ROLE_BASED_ROUTES = { '/admin': ['super_admin', 'admin'], '/reports': ['super_admin', 'admin', 'directeur', 'manager'], '/employes': ['super_admin', 'admin', 'directeur', 'manager', 'chef_chantier'], '/equipes': ['super_admin', 'admin', 'directeur', 'manager', 'chef_chantier'], '/materiels/manage': ['super_admin', 'admin', 'logisticien'], '/clients/manage': ['super_admin', 'admin', 'commercial'], '/devis/manage': ['super_admin', 'admin', 'commercial'], '/factures/manage': ['super_admin', 'admin', 'comptable'], } as const; // Interface pour le token JWT décodé interface KeycloakToken extends JWTPayload { preferred_username?: string; email?: string; given_name?: string; family_name?: string; realm_access?: { roles: string[]; }; resource_access?: { [key: string]: { roles: string[]; }; }; } // Fonction pour vérifier si une route est protégée function isProtectedRoute(pathname: string): boolean { return PROTECTED_ROUTES.some(route => pathname.startsWith(route)); } // Fonction pour vérifier si une route est publique function isPublicRoute(pathname: string): boolean { return PUBLIC_ROUTES.some(route => pathname === route || pathname.startsWith(route)); } // Fonction pour extraire les rôles du token function extractRoles(token: KeycloakToken): string[] { const realmRoles = token.realm_access?.roles || []; const clientRoles = token.resource_access?.['btpxpress-frontend']?.roles || []; return [...realmRoles, ...clientRoles]; } // Fonction pour vérifier les permissions de rôle function hasRequiredRole(userRoles: string[], requiredRoles: string[]): boolean { return requiredRoles.some(role => userRoles.includes(role)); } // Fonction pour vérifier et décoder le token JWT async function verifyToken(token: string): Promise { try { // Configuration de la clé publique Keycloak const keycloakUrl = process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'https://security.lions.dev'; const realm = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'btpxpress'; // Récupérer la clé publique depuis Keycloak const jwksUrl = `${keycloakUrl}/realms/${realm}/protocol/openid_connect/certs`; // Pour la vérification côté serveur, nous utilisons une approche simplifiée // En production, il faudrait implémenter une vérification complète avec JWKS // Décoder le token sans vérification pour le middleware (vérification côté client) const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); // Vérifier l'expiration if (payload.exp && payload.exp < Date.now() / 1000) { return null; } return payload as KeycloakToken; } catch (error) { console.error('Erreur lors de la vérification du token:', error); return null; } } // Fonction principale du middleware export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; // Ignorer les fichiers statiques et les API routes internes if ( pathname.startsWith('/_next') || pathname.startsWith('/api') || pathname.includes('.') || pathname.startsWith('/favicon') ) { return NextResponse.next(); } // Permettre l'accès aux routes publiques if (isPublicRoute(pathname)) { return NextResponse.next(); } // Vérifier si la route nécessite une authentification if (isProtectedRoute(pathname)) { // Récupérer le token depuis les cookies ou headers const authHeader = request.headers.get('authorization'); const tokenFromCookie = request.cookies.get('keycloak-token')?.value; const pkceVerifier = request.cookies.get('pkce_code_verifier')?.value; const hasAuthCode = request.nextUrl.searchParams.has('code'); console.log(`🔍 Middleware: Vérification de ${pathname}:`, { hasAuthHeader: !!authHeader, hasTokenCookie: !!tokenFromCookie, hasPkceVerifier: !!pkceVerifier, hasCode: hasAuthCode }); let token: string | null = null; if (authHeader && authHeader.startsWith('Bearer ')) { token = authHeader.substring(7); } else if (tokenFromCookie) { token = tokenFromCookie; } // Si pas de token, vérifier si un processus d'authentification est en cours if (!token) { // Autoriser l'accès SEULEMENT si on a un code d'autorisation ET un PKCE verifier // Cela permet le premier passage pour l'échange du code if (hasAuthCode && pkceVerifier && pathname === '/dashboard') { console.log('🔓 Middleware: Autorisant /dashboard pour l\'échange du code d\'autorisation'); return NextResponse.next(); } console.log(`🔒 Middleware: Redirection vers /api/auth/login pour ${pathname} (pas de token)`); const loginUrl = new URL('/api/auth/login', request.url); loginUrl.searchParams.set('redirect', pathname); return NextResponse.redirect(loginUrl); } // Vérifier et décoder le token const decodedToken = await verifyToken(token); if (!decodedToken) { // Token invalide ou expiré const loginUrl = new URL('/api/auth/login', request.url); loginUrl.searchParams.set('redirect', pathname); return NextResponse.redirect(loginUrl); } // Extraire les rôles de l'utilisateur const userRoles = extractRoles(decodedToken); // Vérifier les permissions basées sur les rôles pour des routes spécifiques for (const [route, requiredRoles] of Object.entries(ROLE_BASED_ROUTES)) { if (pathname.startsWith(route)) { if (!hasRequiredRole(userRoles, [...requiredRoles])) { // Utilisateur n'a pas les rôles requis return NextResponse.redirect(new URL('/api/auth/login', request.url)); } break; } } // Ajouter les informations utilisateur aux headers pour les composants const response = NextResponse.next(); response.headers.set('x-user-id', decodedToken.sub || ''); response.headers.set('x-user-email', decodedToken.email || ''); response.headers.set('x-user-roles', JSON.stringify(userRoles)); return response; } // Par défaut, permettre l'accès return NextResponse.next(); } // Configuration du matcher pour spécifier quelles routes le middleware doit traiter export const config = { matcher: [ /* * Match all request paths except for the ones starting with: * - api (API routes) * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) * - public files (images, etc.) */ '/((?!api|_next/static|_next/image|favicon.ico|.*\\..*|public).*)', ], };