From 43be7a99d2f3344c5031a11fc3076bdcc28c0f18 Mon Sep 17 00:00:00 2001 From: alma Date: Fri, 2 Jan 2026 16:05:02 +0100 Subject: [PATCH] keycloak improve with build 4 --- LOGOUT_LOGIN_FLOW_TRACE.md | 305 +++++++++++++++++++++++++++ app/api/auth/options.ts | 7 +- app/components/responsive-iframe.tsx | 48 ++++- app/signin/page.tsx | 7 +- components/auth/signout-handler.tsx | 9 +- components/layout/layout-wrapper.tsx | 72 +++++++ components/main-nav.tsx | 9 +- lib/session.ts | 46 ++++ 8 files changed, 498 insertions(+), 5 deletions(-) create mode 100644 LOGOUT_LOGIN_FLOW_TRACE.md diff --git a/LOGOUT_LOGIN_FLOW_TRACE.md b/LOGOUT_LOGIN_FLOW_TRACE.md new file mode 100644 index 00000000..42a74033 --- /dev/null +++ b/LOGOUT_LOGIN_FLOW_TRACE.md @@ -0,0 +1,305 @@ +# Full Logout/Login Flow Trace + +## Issue 1: No Credentials Asked After Logout/Login + +### Current Flow Trace + +#### Step 1: User Clicks Logout +``` +Location: components/main-nav.tsx (line 364) +Action: onClick handler triggered + +1. sessionStorage.setItem('just_logged_out', 'true') +2. document.cookie = 'logout_in_progress=true; path=/; max-age=60' +3. clearAuthCookies() - Clears NextAuth cookies client-side +4. signOut({ callbackUrl: '/signin?logout=true', redirect: false }) + → Calls NextAuth /api/auth/signout endpoint + → Clears NextAuth session cookie server-side +5. window.location.replace(keycloakLogoutUrl) + → Redirects to: ${KEYCLOAK_ISSUER}/protocol/openid-connect/logout + → Parameters: + - post_logout_redirect_uri: /signin?logout=true + - id_token_hint: +``` + +#### Step 2: Keycloak Logout Endpoint +``` +Location: Keycloak Server +URL: ${KEYCLOAK_ISSUER}/protocol/openid-connect/logout + +Expected Behavior: +- Keycloak should invalidate the session +- Keycloak should clear session cookies: + - KEYCLOAK_SESSION (main session cookie) + - KEYCLOAK_SESSION_LEGACY + - KEYCLOAK_IDENTITY (identity cookie) + - KEYCLOAK_IDENTITY_LEGACY + - AUTH_SESSION_ID + - KC_RESTART (if exists) + +ACTUAL BEHAVIOR (PROBLEM): +- Keycloak logout endpoint with id_token_hint SHOULD clear cookies +- BUT: Keycloak might have SSO session that persists across clients +- OR: Cookies might not be cleared if domain/path mismatch +- OR: Keycloak might set new cookies during redirect +``` + +#### Step 3: Redirect Back to Signin +``` +Location: app/signin/page.tsx +URL: /signin?logout=true + +1. Component mounts +2. useEffect checks for logout flag (line 16-45) + - Sets isLogoutRedirect.current = true + - Removes 'just_logged_out' from sessionStorage + - Clears OAuth params from URL +3. Shows logout message with "Se connecter" button +4. User clicks "Se connecter" button (line 143-148) + - Calls: signIn("keycloak", { callbackUrl: "/" }) +``` + +#### Step 4: Keycloak Authorization Request +``` +Location: NextAuth → Keycloak +URL: ${KEYCLOAK_ISSUER}/protocol/openid-connect/auth + +Parameters sent: +- client_id: KEYCLOAK_CLIENT_ID +- redirect_uri: ${NEXTAUTH_URL}/api/auth/callback/keycloak +- response_type: code +- scope: openid profile email roles +- state: +- code_challenge: (if PKCE enabled) + +KEYCLOAK BEHAVIOR: +1. Keycloak receives authorization request +2. Keycloak checks for existing session cookies +3. IF Keycloak session cookies still exist: + → Keycloak finds valid SSO session + → Keycloak auto-authenticates user (no login prompt) + → Keycloak redirects back with authorization code +4. IF Keycloak session cookies are cleared: + → Keycloak shows login page + → User enters credentials + → Keycloak creates new session + → Keycloak redirects back with authorization code + +PROBLEM IDENTIFIED: +- Keycloak logout endpoint might not be clearing ALL session cookies +- OR: Keycloak has SSO session that persists (separate from client session) +- OR: Keycloak sets new cookies during the logout redirect process +- OR: Browser is preserving cookies due to SameSite/domain issues +``` + +### Root Cause Analysis + +**Problem**: Keycloak SSO Session Persistence + +Keycloak maintains two types of sessions: +1. **Client Session** (per OAuth client) - Cleared by logout endpoint +2. **SSO Session** (realm-wide) - May persist even after client logout + +When you call: +``` +GET /protocol/openid-connect/logout?id_token_hint=...&post_logout_redirect_uri=... +``` + +Keycloak behavior: +- ✅ Clears the **client session** for that specific OAuth client +- ✅ Invalidates tokens for that client +- ❌ **MIGHT NOT** clear the **SSO session** (realm-wide session) +- ❌ **MIGHT NOT** clear all session cookies if cookies are set with different domain/path + +**Why SSO Session Persists:** +- Keycloak SSO session is realm-wide, not client-specific +- Multiple clients can share the same SSO session +- Logging out from one client doesn't necessarily log out from the realm +- The SSO session cookie (KEYCLOAK_SESSION) might persist + +**When User Clicks "Se connecter":** +1. Redirects to Keycloak authorization endpoint +2. Keycloak checks for SSO session cookie +3. If SSO session cookie exists → Auto-authenticates (no credentials asked) +4. If SSO session cookie cleared → Shows login page + +--- + +## Issue 2: Cannot Logout from Iframe Application + +### Current Flow Trace + +#### Step 1: User in Iframe Application +``` +Location: Iframe application (e.g., /parole, /gite, etc.) +State: +- Dashboard has NextAuth session +- Keycloak session cookies exist +- Iframe app authenticated via Keycloak cookies +``` + +#### Step 2: User Clicks Logout in Iframe +``` +Location: Iframe application's logout button +Action: Iframe app's logout handler + +Possible Scenarios: + +Scenario A: Iframe calls its own logout endpoint +- Iframe app might call: POST /api/logout (iframe app's endpoint) +- This might clear iframe app's session +- BUT: Keycloak session cookies might still exist +- Result: Iframe app logs out, but Keycloak session persists + +Scenario B: Iframe calls Keycloak logout +- Iframe app might call: GET ${KEYCLOAK_ISSUER}/protocol/openid-connect/logout +- Keycloak clears session cookies +- BUT: NextAuth dashboard session still exists +- Result: Keycloak session cleared, but dashboard still logged in + +Scenario C: Iframe doesn't have logout +- Iframe app might not have logout functionality +- User stuck in iframe with no way to logout +``` + +#### Step 3: What Happens After Iframe Logout +``` +If iframe calls Keycloak logout: +1. Keycloak invalidates session +2. Keycloak clears session cookies +3. NextAuth dashboard still has valid JWT (30-day expiration) +4. NextAuth doesn't know Keycloak session was cleared +5. Dashboard widgets still show (using NextAuth session) +6. Iframe apps can't authenticate (Keycloak cookies cleared) +``` + +### Root Cause Analysis + +**Problem**: No Communication Between Iframe and Dashboard + +When iframe app logs out: +1. **Iframe app** calls Keycloak logout → Clears Keycloak cookies +2. **Dashboard** doesn't know about this → NextAuth session still valid +3. **Dashboard widgets** continue to work (using NextAuth session) +4. **Iframe apps** can't authenticate (Keycloak cookies gone) + +**Why Dashboard Doesn't Know:** +- NextAuth session is independent of Keycloak session cookies +- NextAuth JWT has 30-day expiration +- No mechanism to detect Keycloak session invalidation from iframe +- Dashboard only detects invalidation when trying to refresh tokens + +**Why Iframe Can't Logout Properly:** +- Iframe apps rely on Keycloak cookies for SSO +- If iframe calls Keycloak logout, it clears cookies +- But dashboard session persists +- If iframe doesn't call logout, user can't logout from iframe +- No way for iframe to trigger dashboard logout + +--- + +## Key Findings + +### Finding 1: Keycloak SSO Session Persistence +- **Issue**: Keycloak logout endpoint might not clear SSO session +- **Evidence**: User auto-authenticates without credentials after logout +- **Root Cause**: SSO session cookie persists after client logout +- **Impact**: Security issue - user should be asked for credentials + +### Finding 2: Missing `prompt=login` Parameter +- **Issue**: When calling `signIn("keycloak")`, no `prompt=login` parameter is sent +- **Evidence**: Keycloak auto-authenticates if SSO session exists +- **Root Cause**: NextAuth Keycloak provider doesn't force login prompt +- **Impact**: User bypasses credential check + +### Finding 3: Iframe Logout Isolation +- **Issue**: Iframe logout doesn't affect dashboard session +- **Evidence**: Dashboard widgets still show after iframe logout +- **Root Cause**: No communication mechanism between iframe and dashboard +- **Impact**: Inconsistent logout state + +### Finding 4: No Cross-Origin Logout Communication +- **Issue**: Iframe can't trigger dashboard logout +- **Evidence**: User stuck in iframe after logout +- **Root Cause**: No postMessage or other communication mechanism +- **Impact**: Poor user experience + +--- + +## Flow Diagram + +### Current Logout Flow (Dashboard) +``` +User clicks logout + ↓ +Clear NextAuth cookies + ↓ +Call NextAuth signOut() + ↓ +Redirect to Keycloak logout + ↓ +Keycloak clears client session + ↓ +Keycloak MAY clear SSO session (not guaranteed) + ↓ +Redirect to /signin?logout=true + ↓ +User clicks "Se connecter" + ↓ +Redirect to Keycloak auth + ↓ +Keycloak checks SSO session + ↓ +IF SSO session exists → Auto-authenticate (NO CREDENTIALS) +IF SSO session cleared → Show login page +``` + +### Current Iframe Logout Flow +``` +User in iframe clicks logout + ↓ +Iframe app calls logout (varies by app) + ↓ +IF calls Keycloak logout: + → Keycloak clears session cookies + → Dashboard session still valid + → Widgets still show + → Iframe can't authenticate +IF doesn't call logout: + → User stuck in iframe + → No way to logout +``` + +--- + +## Recommendations + +### For Issue 1 (No Credentials After Logout) +1. **Add `prompt=login` parameter** to Keycloak authorization request + - Forces Keycloak to show login page even if SSO session exists + - Location: `app/api/auth/options.ts` - KeycloakProvider authorization params + +2. **Clear Keycloak SSO session explicitly** + - Add `kc_action=LOGOUT` parameter to logout URL + - Or call Keycloak admin API to end SSO session + +3. **Clear Keycloak cookies client-side** + - After Keycloak logout redirect, clear any remaining Keycloak cookies + - Check for cookies with Keycloak domain + +### For Issue 2 (Iframe Logout) +1. **Implement postMessage communication** + - Iframe sends logout message to parent + - Dashboard listens for logout messages + - Dashboard triggers logout when iframe logs out + +2. **Detect Keycloak session invalidation** + - Poll Keycloak session status + - Detect when Keycloak cookies are cleared + - Automatically logout dashboard + +3. **Unified logout endpoint** + - Create API endpoint that logs out both dashboard and Keycloak + - Iframe apps call this endpoint + - Ensures synchronized logout + diff --git a/app/api/auth/options.ts b/app/api/auth/options.ts index da64c032..703ef698 100644 --- a/app/api/auth/options.ts +++ b/app/api/auth/options.ts @@ -146,7 +146,12 @@ export const authOptions: NextAuthOptions = { issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"), authorization: { params: { - scope: "openid profile email roles" + scope: "openid profile email roles", + // Force login prompt even if SSO session exists + // This ensures user is asked for credentials after logout + // Note: This will always prompt for login, even on first visit + // If you want to allow SSO on first visit, remove this and handle it conditionally + prompt: "login" } }, profile(profile) { diff --git a/app/components/responsive-iframe.tsx b/app/components/responsive-iframe.tsx index 77299378..1ecc6f7d 100644 --- a/app/components/responsive-iframe.tsx +++ b/app/components/responsive-iframe.tsx @@ -106,6 +106,52 @@ export function ResponsiveIframe({ src, className = '', allow, style }: Responsi refreshSession(); }, [session, src, hasTriedRefresh, iframeSrc]); + // Listen for logout messages from iframe applications + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + // Security: Only accept messages from known iframe origins + // In production, you should validate event.origin against your iframe URLs + + // Check if message is a logout request from iframe + if (event.data && typeof event.data === 'object') { + if (event.data.type === 'KEYCLOAK_LOGOUT' || event.data.type === 'LOGOUT') { + console.log('Received logout request from iframe, triggering dashboard logout'); + + // Mark logout in progress + sessionStorage.setItem('just_logged_out', 'true'); + document.cookie = 'logout_in_progress=true; path=/; max-age=60'; + + // Trigger dashboard logout + if (session?.idToken) { + const keycloakIssuer = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER; + if (keycloakIssuer) { + const keycloakLogoutUrl = new URL( + `${keycloakIssuer}/protocol/openid-connect/logout` + ); + keycloakLogoutUrl.searchParams.append( + 'post_logout_redirect_uri', + window.location.origin + '/signin?logout=true' + ); + keycloakLogoutUrl.searchParams.append( + 'id_token_hint', + session.idToken + ); + window.location.replace(keycloakLogoutUrl.toString()); + } + } else { + // Fallback: redirect to signin + window.location.replace('/signin?logout=true'); + } + } + } + }; + + window.addEventListener('message', handleMessage); + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [session]); + useEffect(() => { const iframe = iframeRef.current; if (!iframe || !iframeSrc) return; @@ -146,7 +192,7 @@ export function ResponsiveIframe({ src, className = '', allow, style }: Responsi window.removeEventListener('hashchange', handleHashChange); iframe.removeEventListener('load', calculateHeight); }; - }, []); + }, [iframeSrc]); return ( <> diff --git a/app/signin/page.tsx b/app/signin/page.tsx index 58d03e7f..3b243ae6 100644 --- a/app/signin/page.tsx +++ b/app/signin/page.tsx @@ -144,7 +144,12 @@ export default function SignIn() { onClick={() => { hasAttemptedLogin.current = false; isLogoutRedirect.current = false; - signIn("keycloak", { callbackUrl: "/" }); + // Force login prompt by adding prompt=login parameter + // This ensures credentials are asked even if SSO session exists + signIn("keycloak", { + 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" > diff --git a/components/auth/signout-handler.tsx b/components/auth/signout-handler.tsx index 478b9925..4f483468 100644 --- a/components/auth/signout-handler.tsx +++ b/components/auth/signout-handler.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { signOut, useSession } from "next-auth/react"; -import { clearAuthCookies } from "@/lib/session"; +import { clearAuthCookies, clearKeycloakCookies } from "@/lib/session"; export function SignOutHandler() { const { data: session } = useSession(); @@ -21,6 +21,8 @@ export function SignOutHandler() { // Clear NextAuth cookies immediately before signOut clearAuthCookies(); + // Also attempt to clear Keycloak cookies + clearKeycloakCookies(); // Sign out from NextAuth (clears NextAuth session) await signOut({ @@ -43,6 +45,11 @@ export function SignOutHandler() { 'id_token_hint', idToken ); + // Add kc_action=LOGOUT to ensure SSO session is cleared + keycloakLogoutUrl.searchParams.append( + 'kc_action', + 'LOGOUT' + ); // Immediate redirect to Keycloak logout (prevents widget rendering) window.location.replace(keycloakLogoutUrl.toString()); diff --git a/components/layout/layout-wrapper.tsx b/components/layout/layout-wrapper.tsx index 9d0851ed..b10098c6 100644 --- a/components/layout/layout-wrapper.tsx +++ b/components/layout/layout-wrapper.tsx @@ -1,10 +1,13 @@ "use client"; +import { useEffect } from "react"; +import { useSession, signOut } from "next-auth/react"; import { MainNav } from "@/components/main-nav"; import { Footer } from "@/components/footer"; import { AuthCheck } from "@/components/auth/auth-check"; import { Toaster } from "@/components/ui/toaster"; import { useBackgroundImage } from "@/components/background-switcher"; +import { clearAuthCookies, clearKeycloakCookies } from "@/lib/session"; interface LayoutWrapperProps { children: React.ReactNode; @@ -14,6 +17,75 @@ interface LayoutWrapperProps { export function LayoutWrapper({ children, isSignInPage, isAuthenticated }: LayoutWrapperProps) { const { currentBackground, changeBackground } = useBackgroundImage(); + const { data: session } = useSession(); + + // Global listener for logout messages from iframe applications + useEffect(() => { + if (isSignInPage) return; // Don't listen on signin page + + const handleMessage = async (event: MessageEvent) => { + // Security: Validate message origin (in production, check against known iframe URLs) + // For now, we accept messages from any origin but validate the message structure + + // Check if message is a logout request from iframe + if (event.data && typeof event.data === 'object') { + if (event.data.type === 'KEYCLOAK_LOGOUT' || event.data.type === 'LOGOUT') { + console.log('Received logout request from iframe application, triggering dashboard logout'); + + try { + // Mark logout in progress + sessionStorage.setItem('just_logged_out', 'true'); + document.cookie = 'logout_in_progress=true; path=/; max-age=60'; + + // Clear cookies + clearAuthCookies(); + clearKeycloakCookies(); + + // Sign out from NextAuth + await signOut({ + callbackUrl: '/signin?logout=true', + redirect: false + }); + + // Call Keycloak logout if we have ID token + const keycloakIssuer = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER; + const idToken = session?.idToken; + + if (keycloakIssuer && idToken) { + const keycloakLogoutUrl = new URL( + `${keycloakIssuer}/protocol/openid-connect/logout` + ); + keycloakLogoutUrl.searchParams.append( + 'post_logout_redirect_uri', + window.location.origin + '/signin?logout=true' + ); + keycloakLogoutUrl.searchParams.append( + 'id_token_hint', + idToken + ); + // Add kc_action=LOGOUT to ensure SSO session is cleared + keycloakLogoutUrl.searchParams.append( + 'kc_action', + 'LOGOUT' + ); + window.location.replace(keycloakLogoutUrl.toString()); + } else { + // Fallback: redirect to signin + window.location.replace('/signin?logout=true'); + } + } catch (error) { + console.error('Error handling iframe logout:', error); + window.location.replace('/signin?logout=true'); + } + } + } + }; + + window.addEventListener('message', handleMessage); + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [isSignInPage, session]); return ( diff --git a/components/main-nav.tsx b/components/main-nav.tsx index b1b7af76..13945397 100644 --- a/components/main-nav.tsx +++ b/components/main-nav.tsx @@ -28,7 +28,7 @@ import Image from "next/image"; import Link from "next/link"; import { Sidebar } from "./sidebar"; import { useSession, signIn, signOut } from "next-auth/react"; -import { clearAuthCookies } from "@/lib/session"; +import { clearAuthCookies, clearKeycloakCookies } from "@/lib/session"; import { DropdownMenu, DropdownMenuContent, @@ -373,6 +373,8 @@ export function MainNav() { // Clear NextAuth cookies immediately before signOut clearAuthCookies(); + // Also attempt to clear Keycloak cookies + clearKeycloakCookies(); // Sign out from NextAuth (clears NextAuth session) await signOut({ @@ -396,6 +398,11 @@ export function MainNav() { 'id_token_hint', idToken ); + // Add kc_action=LOGOUT to ensure SSO session is cleared + keycloakLogoutUrl.searchParams.append( + 'kc_action', + 'LOGOUT' + ); // Immediate redirect to Keycloak logout (prevents widget rendering) window.location.replace(keycloakLogoutUrl.toString()); diff --git a/lib/session.ts b/lib/session.ts index 8456e090..93409d61 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -99,4 +99,50 @@ export function clearAuthCookies() { document.cookie = `${name.trim()}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; } } +} + +/** + * Clear Keycloak session cookies + * Keycloak cookies are typically set on Keycloak's domain, so we need to + * try clearing them with different domain/path combinations + */ +export function clearKeycloakCookies() { + const keycloakIssuer = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER; + if (!keycloakIssuer) return; + + try { + // Extract domain from Keycloak issuer + const keycloakUrl = new URL(keycloakIssuer); + const keycloakDomain = keycloakUrl.hostname; + + // Common Keycloak cookie names + const keycloakCookieNames = [ + 'KEYCLOAK_SESSION', + 'KEYCLOAK_SESSION_LEGACY', + 'KEYCLOAK_IDENTITY', + 'KEYCLOAK_IDENTITY_LEGACY', + 'AUTH_SESSION_ID', + 'KC_RESTART', + 'KC_RESTART_LEGACY' + ]; + + // Try to clear cookies with different domain/path combinations + const domains = [keycloakDomain, `.${keycloakDomain}`, window.location.hostname]; + const paths = ['/', '/realms/', keycloakUrl.pathname]; + + keycloakCookieNames.forEach(cookieName => { + domains.forEach(domain => { + paths.forEach(path => { + // Try with domain + document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${domain};`; + // Try without domain (for same-origin cookies) + document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path};`; + }); + }); + }); + + console.log('Attempted to clear Keycloak cookies'); + } catch (error) { + console.error('Error clearing Keycloak cookies:', error); + } } \ No newline at end of file