diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..bf1020b --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const KEYCLOAK_URL = process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'https://security.lions.dev'; +const KEYCLOAK_REALM = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'btpxpress'; +const CLIENT_ID = process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || 'btpxpress-frontend'; +const REDIRECT_URI = process.env.NEXT_PUBLIC_APP_URL + ? `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback` + : 'https://btpxpress.lions.dev/auth/callback'; + +export async function GET(request: NextRequest) { + console.log('đ Login API called'); + + // GĂ©nĂ©rer un state alĂ©atoire pour CSRF protection + const state = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); + + // Construire l'URL d'autorisation Keycloak + const authUrl = new URL(`${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth`); + + authUrl.searchParams.set('client_id', CLIENT_ID); + authUrl.searchParams.set('redirect_uri', REDIRECT_URI); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('scope', 'openid profile email'); + authUrl.searchParams.set('state', state); + + console.log('â Redirecting to Keycloak:', authUrl.toString()); + + // Rediriger vers Keycloak pour l'authentification + return NextResponse.redirect(authUrl.toString()); +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..a88389a --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const KEYCLOAK_URL = process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'https://security.lions.dev'; +const KEYCLOAK_REALM = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'btpxpress'; +const CLIENT_ID = process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || 'btpxpress-frontend'; +const POST_LOGOUT_REDIRECT_URI = process.env.NEXT_PUBLIC_APP_URL || 'https://btpxpress.lions.dev'; + +export async function GET(request: NextRequest) { + console.log('đȘ Logout API called'); + + const cookieStore = cookies(); + + // RĂ©cupĂ©rer l'id_token avant de supprimer les cookies + const idToken = cookieStore.get('id_token')?.value; + + // Supprimer tous les cookies d'authentification + cookieStore.delete('access_token'); + cookieStore.delete('refresh_token'); + cookieStore.delete('id_token'); + cookieStore.delete('token_expires_at'); + + console.log('â Authentication cookies deleted'); + + // Si on a un id_token, on fait un logout Keycloak + if (idToken) { + const logoutUrl = new URL( + `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/logout` + ); + + logoutUrl.searchParams.set('client_id', CLIENT_ID); + logoutUrl.searchParams.set('post_logout_redirect_uri', POST_LOGOUT_REDIRECT_URI); + logoutUrl.searchParams.set('id_token_hint', idToken); + + console.log('â Redirecting to Keycloak logout'); + + return NextResponse.redirect(logoutUrl.toString()); + } + + // Sinon, rediriger directement vers la page d'accueil + console.log('â Redirecting to home page'); + return NextResponse.redirect(POST_LOGOUT_REDIRECT_URI); +} + +export async function POST(request: NextRequest) { + // MĂȘme logique pour POST + return GET(request); +} diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts new file mode 100644 index 0000000..19ce7ff --- /dev/null +++ b/app/api/auth/token/route.ts @@ -0,0 +1,138 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const KEYCLOAK_URL = process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'https://security.lions.dev'; +const KEYCLOAK_REALM = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'btpxpress'; +const CLIENT_ID = process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || 'btpxpress-frontend'; +const REDIRECT_URI = process.env.NEXT_PUBLIC_APP_URL + ? `${process.env.NEXT_PUBLIC_APP_URL}/auth/callback` + : 'https://btpxpress.lions.dev/auth/callback'; + +const TOKEN_ENDPOINT = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`; + +export async function POST(request: NextRequest) { + console.log('đ Token exchange API called'); + + try { + const body = await request.json(); + const { code, state } = body; + + if (!code) { + console.error('â No authorization code provided'); + return NextResponse.json( + { error: 'Code d\'autorisation manquant' }, + { status: 400 } + ); + } + + console.log('â Exchanging code with Keycloak...'); + console.log('đ Token endpoint:', TOKEN_ENDPOINT); + console.log('đ Client ID:', CLIENT_ID); + console.log('đ Redirect URI:', REDIRECT_URI); + + // PrĂ©parer les paramĂštres pour l'Ă©change de code + const params = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: CLIENT_ID, + code: code, + redirect_uri: REDIRECT_URI, + }); + + // Ăchanger le code contre des tokens + const tokenResponse = await fetch(TOKEN_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }); + + if (!tokenResponse.ok) { + const errorText = await tokenResponse.text(); + console.error('â Keycloak token exchange failed:', tokenResponse.status, errorText); + + let errorData; + try { + errorData = JSON.parse(errorText); + } catch { + errorData = { error: 'Token exchange failed', details: errorText }; + } + + return NextResponse.json( + { + error: errorData.error || 'Ăchec de l\'Ă©change de token', + error_description: errorData.error_description || 'Erreur lors de la communication avec Keycloak', + details: errorData + }, + { status: tokenResponse.status } + ); + } + + const tokens = await tokenResponse.json(); + console.log('â Tokens received from Keycloak'); + + // Stocker les tokens dans des cookies HttpOnly sĂ©curisĂ©s + const cookieStore = cookies(); + const isProduction = process.env.NODE_ENV === 'production'; + + // Access token (durĂ©e: expires_in secondes) + cookieStore.set('access_token', tokens.access_token, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: tokens.expires_in || 300, // Par dĂ©faut 5 minutes + path: '/', + }); + + // Refresh token (durĂ©e plus longue) + if (tokens.refresh_token) { + cookieStore.set('refresh_token', tokens.refresh_token, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: tokens.refresh_expires_in || 1800, // Par dĂ©faut 30 minutes + path: '/', + }); + } + + // ID token + if (tokens.id_token) { + cookieStore.set('id_token', tokens.id_token, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: tokens.expires_in || 300, + path: '/', + }); + } + + // Stocker aussi le temps d'expiration + cookieStore.set('token_expires_at', String(Date.now() + (tokens.expires_in * 1000)), { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: tokens.expires_in || 300, + path: '/', + }); + + console.log('â Tokens stored in HttpOnly cookies'); + + // Retourner une rĂ©ponse de succĂšs (sans les tokens) + return NextResponse.json({ + success: true, + message: 'Authentification rĂ©ussie', + expires_in: tokens.expires_in, + }); + + } catch (error) { + console.error('â Error in token exchange API:', error); + + return NextResponse.json( + { + error: 'Erreur serveur', + message: error instanceof Error ? error.message : 'Erreur inconnue' + }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/userinfo/route.ts b/app/api/auth/userinfo/route.ts new file mode 100644 index 0000000..0b037ea --- /dev/null +++ b/app/api/auth/userinfo/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; + +const KEYCLOAK_URL = process.env.NEXT_PUBLIC_KEYCLOAK_URL || 'https://security.lions.dev'; +const KEYCLOAK_REALM = process.env.NEXT_PUBLIC_KEYCLOAK_REALM || 'btpxpress'; + +const USERINFO_ENDPOINT = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/userinfo`; + +export async function GET(request: NextRequest) { + console.log('đ€ Userinfo API called'); + + try { + const cookieStore = cookies(); + const accessToken = cookieStore.get('access_token')?.value; + + if (!accessToken) { + console.error('â No access token found'); + return NextResponse.json( + { error: 'Non authentifiĂ©', authenticated: false }, + { status: 401 } + ); + } + + console.log('â Access token found, fetching user info from Keycloak'); + + // RĂ©cupĂ©rer les informations utilisateur depuis Keycloak + const userinfoResponse = await fetch(USERINFO_ENDPOINT, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (!userinfoResponse.ok) { + const errorText = await userinfoResponse.text(); + console.error('â Keycloak userinfo failed:', userinfoResponse.status, errorText); + + // Si le token est invalide ou expirĂ© + if (userinfoResponse.status === 401) { + // Supprimer les cookies invalides + cookieStore.delete('access_token'); + cookieStore.delete('refresh_token'); + cookieStore.delete('id_token'); + cookieStore.delete('token_expires_at'); + + return NextResponse.json( + { error: 'Token expirĂ© ou invalide', authenticated: false }, + { status: 401 } + ); + } + + return NextResponse.json( + { + error: 'Erreur lors de la rĂ©cupĂ©ration des informations utilisateur', + authenticated: false + }, + { status: userinfoResponse.status } + ); + } + + const userinfo = await userinfoResponse.json(); + console.log('â User info retrieved:', userinfo.preferred_username || userinfo.sub); + + return NextResponse.json({ + authenticated: true, + user: userinfo, + }); + + } catch (error) { + console.error('â Error in userinfo API:', error); + + return NextResponse.json( + { + error: 'Erreur serveur', + authenticated: false, + message: error instanceof Error ? error.message : 'Erreur inconnue' + }, + { status: 500 } + ); + } +} diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx new file mode 100644 index 0000000..b9f3f26 --- /dev/null +++ b/app/auth/login/page.tsx @@ -0,0 +1,70 @@ +'use client'; + +import React from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Button } from 'primereact/button'; +import { Card } from 'primereact/card'; + +export default function LoginPage() { + const searchParams = useSearchParams(); + const returnUrl = searchParams.get('returnUrl') || '/dashboard'; + + const handleLogin = () => { + // Stocker l'URL de retour dans le sessionStorage + if (returnUrl) { + sessionStorage.setItem('returnUrl', returnUrl); + } + + // Rediriger vers l'API de login qui initiera le flux OAuth + window.location.href = '/api/auth/login'; + }; + + return ( +
+ Veuillez vous connecter pour accéder à l'application +
+ + + +