diff --git a/IFRAME_LOGOUT_AUTO_LOGIN_ANALYSIS.md b/IFRAME_LOGOUT_AUTO_LOGIN_ANALYSIS.md new file mode 100644 index 00000000..98e30f87 --- /dev/null +++ b/IFRAME_LOGOUT_AUTO_LOGIN_ANALYSIS.md @@ -0,0 +1,259 @@ +# Iframe Logout Auto-Login Issue Analysis + +## Problem + +When you log out from an iframe application, you are automatically logged back into the dashboard without being prompted for credentials. + +## Flow Trace + +### Scenario: User Logs Out from Iframe Application + +#### Step 1: Iframe Application Logout +``` +Location: Iframe application (e.g., /parole, /gite, etc.) +Action: User clicks logout in iframe + +What happens: +- Iframe app may call Keycloak logout endpoint directly +- OR: Iframe app sends postMessage to parent: { type: 'KEYCLOAK_LOGOUT' } +- OR: Iframe app clears its own session cookies +``` + +#### Step 2A: If Iframe Sends PostMessage (Expected Flow) +``` +Location: components/layout/layout-wrapper.tsx (line 26-106) +OR: app/components/responsive-iframe.tsx (line 110-153) + +Action: Dashboard receives logout message + +What happens: +1. Sets sessionStorage.setItem('just_logged_out', 'true') +2. Sets document.cookie = 'logout_in_progress=true; path=/; max-age=60' +3. Calls /api/auth/end-sso-session (Admin API) +4. Calls signOut() from NextAuth +5. Redirects to Keycloak logout endpoint +6. Keycloak redirects back to /signin?logout=true +``` + +#### Step 2B: If Iframe Calls Keycloak Logout Directly (Actual Flow - Problem) +``` +Location: Iframe application + +Action: Iframe calls Keycloak logout endpoint directly + +What happens: +1. Iframe redirects to: ${KEYCLOAK_ISSUER}/protocol/openid-connect/logout +2. Keycloak clears session cookies +3. Keycloak may redirect iframe back to its own logout page +4. Dashboard doesn't know about this logout +5. Dashboard still has NextAuth session (valid for 30 days) +``` + +#### Step 3: Dashboard Detects Session Invalidation +``` +Location: app/api/auth/options.ts (refreshAccessToken function) + +When: NextAuth tries to refresh the access token + +What happens: +1. Dashboard calls Keycloak token refresh endpoint +2. Keycloak returns: { error: 'invalid_grant', error_description: 'Session not active' } +3. refreshAccessToken detects this error (line 100-108) +4. Returns token with error: "SessionNotActive" +5. JWT callback clears tokens (line 248-256) +6. Session callback returns null (line 272-276) +7. NextAuth treats user as unauthenticated +8. Status becomes "unauthenticated" +``` + +#### Step 4: Sign-In Page Auto-Login (THE PROBLEM) +``` +Location: app/signin/page.tsx (line 47-79) + +When: User is redirected to /signin (or status becomes "unauthenticated") + +What happens: +1. Component mounts +2. First useEffect (line 16-45) checks for logout flag + - If logout=true in URL, sets isLogoutRedirect.current = true + - Removes 'just_logged_out' from sessionStorage +3. Second useEffect (line 47-79) checks authentication status + - If status === "authenticated" → redirects to home ✅ + - If status === "unauthenticated" → triggers auto-login ❌ + +THE PROBLEM: +- When iframe logs out directly (not via postMessage), dashboard doesn't set logout flags +- Status becomes "unauthenticated" (because Keycloak session was cleared) +- Sign-in page sees status === "unauthenticated" +- Auto-login logic triggers after 1 second (line 69) +- signIn("keycloak") is called +- Keycloak still has SSO session cookie (if it wasn't fully cleared) +- User is auto-authenticated without credentials ❌ +``` + +## Root Cause Analysis + +### Problem 1: Missing Logout Flags + +**When iframe logs out directly (not via postMessage):** +- Dashboard doesn't know about the logout +- `just_logged_out` is NOT set in sessionStorage +- `logout_in_progress` cookie is NOT set +- Sign-in page doesn't know this is a logout scenario + +**Result**: Sign-in page treats it as a normal "unauthenticated" state and triggers auto-login. + +### Problem 2: Auto-Login Logic Timing + +**Sign-in page auto-login logic** (`app/signin/page.tsx:66-78`): +```typescript +if (status === "unauthenticated") { + hasAttemptedLogin.current = true; + const timer = setTimeout(() => { + if (!isLogoutRedirect.current) { + signIn("keycloak", { callbackUrl: "/" }); + } + }, 1000); +} +``` + +**The Issue**: +- `isLogoutRedirect.current` is set in the first useEffect (line 16-45) +- But it only checks for `logout=true` in URL or `just_logged_out` in sessionStorage +- If iframe logs out directly, neither of these is set +- After 1 second, auto-login triggers +- `isLogoutRedirect.current` is still `false` (because logout flags weren't set) +- `signIn("keycloak")` is called +- User is auto-authenticated + +### Problem 3: SSO Session Cookie Persistence + +**Even if logout flags are set correctly:** +- Keycloak SSO session cookie (`KEYCLOAK_SESSION`) may still exist +- When `signIn("keycloak")` is called, Keycloak checks for SSO session cookie +- If cookie exists, Keycloak auto-authenticates without credentials +- This happens even with `prompt=login` parameter (if SSO session is still valid) + +## Why This Happens + +### Flow 1: Iframe Logs Out via PostMessage (Works Correctly) +``` +1. Iframe sends postMessage → Dashboard receives it +2. Dashboard sets logout flags ✅ +3. Dashboard calls logout endpoints ✅ +4. Redirects to /signin?logout=true ✅ +5. Sign-in page sees logout=true ✅ +6. Auto-login is prevented ✅ +7. User must click "Se connecter" manually ✅ +``` + +### Flow 2: Iframe Logs Out Directly (THE PROBLEM) +``` +1. Iframe calls Keycloak logout directly +2. Keycloak clears session cookies +3. Dashboard doesn't know about logout ❌ +4. NextAuth tries to refresh token +5. Keycloak returns "Session not active" +6. NextAuth marks user as unauthenticated +7. User is redirected to /signin (no logout=true) ❌ +8. Sign-in page sees status="unauthenticated" ❌ +9. Auto-login triggers after 1 second ❌ +10. Keycloak still has SSO session cookie ❌ +11. User is auto-authenticated ❌ +``` + +## The Real Issue + +**The sign-in page auto-login logic is too aggressive:** + +1. It triggers auto-login for ANY "unauthenticated" state +2. It doesn't distinguish between: + - User never logged in (should auto-login) ✅ + - User logged out (should NOT auto-login) ❌ + - Session expired (should NOT auto-login) ❌ + - Keycloak session invalidated (should NOT auto-login) ❌ + +3. The logout detection only works if: + - `logout=true` is in URL (from Keycloak redirect) + - `just_logged_out` is in sessionStorage (from dashboard logout) + - But NOT if iframe logs out directly + +## Solution Requirements + +To fix this issue, you need to: + +1. **Detect Keycloak Session Invalidation**: + - When NextAuth detects "SessionNotActive" error + - Set a flag to prevent auto-login + - Mark this as a logout scenario, not a new login + +2. **Improve Logout Detection**: + - Check for Keycloak session cookie existence + - If session was invalidated (not just expired), prevent auto-login + - Store logout reason in sessionStorage + +3. **Modify Auto-Login Logic**: + - Only auto-login if: + - User is truly unauthenticated (never logged in) + - AND no logout flags are set + - AND no session invalidation detected + - Don't auto-login if: + - Logout flags are set + - Session was invalidated + - User came from a logout flow + +4. **Handle Iframe Direct Logout**: + - Detect when Keycloak session is invalidated + - Set logout flags automatically + - Prevent auto-login + +## Current Code Issues + +### Issue 1: Auto-Login Logic (`app/signin/page.tsx:66-78`) +```typescript +if (status === "unauthenticated") { + // This triggers for ANY unauthenticated state + // Doesn't check if session was invalidated + signIn("keycloak", { callbackUrl: "/" }); +} +``` + +### Issue 2: Logout Detection (`app/signin/page.tsx:16-45`) +```typescript +// Only checks for explicit logout flags +// Doesn't detect session invalidation +const logoutParam = searchParams.get('logout'); +const fromLogout = sessionStorage.getItem('just_logged_out'); +``` + +### Issue 3: Session Invalidation Detection (`app/api/auth/options.ts:248-256`) +```typescript +// Detects session invalidation +// But doesn't set logout flags +// Sign-in page doesn't know session was invalidated +if (refreshedToken.error === "SessionNotActive") { + return { + ...refreshedToken, + accessToken: undefined, + // Should set a flag here to prevent auto-login + }; +} +``` + +## Summary + +**Why you're auto-logged in after iframe logout:** + +1. Iframe logs out directly (not via postMessage) +2. Keycloak session is cleared +3. Dashboard detects session invalidation +4. User becomes "unauthenticated" +5. Sign-in page auto-login logic triggers (after 1 second) +6. Keycloak still has SSO session cookie +7. User is auto-authenticated without credentials + +**The fix requires:** +- Detecting session invalidation and setting logout flags +- Preventing auto-login when session was invalidated +- Only auto-login for truly new users (never logged in) + diff --git a/INACTIVITY_AND_LOGOUT_ANALYSIS.md b/INACTIVITY_AND_LOGOUT_ANALYSIS.md new file mode 100644 index 00000000..7b73402b --- /dev/null +++ b/INACTIVITY_AND_LOGOUT_ANALYSIS.md @@ -0,0 +1,232 @@ +# Inactivity Timeout and Logout Analysis + +## Issue 1: Dashboard Should Disconnect After 30 Minutes of Inactivity + +### Current State + +**Session Configuration** (`app/api/auth/options.ts:190`): +```typescript +session: { + strategy: "jwt", + maxAge: 30 * 24 * 60 * 60, // 30 days +} +``` + +**SessionProvider Configuration** (`components/providers.tsx`): +```typescript + + {children} + +``` + +### Problem Analysis + +1. **No Inactivity Detection**: + - NextAuth session is set to 30 days maximum + - No client-side inactivity timeout logic exists + - No activity tracking (mouse movements, clicks, keyboard input) + - SessionProvider doesn't have `refetchInterval` configured + +2. **How NextAuth Sessions Work**: + - NextAuth sessions are JWT-based (stateless) + - Session validity is checked on each request to `/api/auth/session` + - No automatic expiration based on inactivity + - Session expires only when `maxAge` is reached (30 days in your case) + +3. **What's Missing**: + - Client-side activity monitoring + - Automatic session invalidation after inactivity period + - Session refresh based on activity (not just time) + +### Root Cause + +**NextAuth doesn't track user activity** - it only tracks session age. The session will remain valid for 30 days regardless of whether the user is active or not. + +### Solution Requirements + +To implement 30-minute inactivity timeout, you need: + +1. **Client-Side Activity Tracking**: + - Monitor user activity (mouse, keyboard, clicks) + - Track last activity timestamp + - Store in `sessionStorage` or `localStorage` + +2. **Session Invalidation Logic**: + - Check inactivity period on each page interaction + - Call `signOut()` if inactivity exceeds 30 minutes + - Clear NextAuth session and Keycloak session + +3. **Activity Reset on User Actions**: + - Reset inactivity timer on any user interaction + - Update last activity timestamp + +4. **SessionProvider Configuration**: + - Optionally configure `refetchInterval` to check session periodically + - But this won't help with inactivity - it only refreshes the session + +### Implementation Approach + +The inactivity timeout must be implemented **client-side** because: +- NextAuth sessions are stateless (JWT) +- Server doesn't know about user activity +- Activity tracking requires browser events + +**Recommended Implementation**: +1. Create an `InactivityHandler` component +2. Monitor user activity events (mousemove, keydown, click, scroll) +3. Store last activity time in `sessionStorage` +4. Check inactivity every minute (or on page focus) +5. If inactivity > 30 minutes, trigger logout + +--- + +## Issue 2: Applications Outside Dashboard Still Connected After Logout + +### Current Implementation + +**Logout Flow** (`components/main-nav.tsx`, `components/auth/signout-handler.tsx`): +1. Clear NextAuth cookies +2. Clear Keycloak cookies (client-side attempt) +3. Call `/api/auth/end-sso-session` (NEW) + - Uses Keycloak Admin API: `adminClient.users.logout({ id: userId })` +4. Sign out from NextAuth +5. Redirect to Keycloak logout endpoint with `id_token_hint` + +### Problem Analysis + +**Why Applications Are Still Connected:** + +1. **Keycloak Admin API `users.logout()` Behavior**: + - The method `adminClient.users.logout({ id: userId })` **logs out the user from all client sessions** + - However, it may **NOT clear the SSO session cookie** (`KEYCLOAK_SESSION`) + - The SSO session cookie is what allows applications to auto-authenticate + +2. **SSO Session vs Client Sessions**: + - **Client Sessions**: Per OAuth client (dashboard, app1, app2, etc.) + - **SSO Session**: Realm-wide, shared across all clients + - `users.logout()` clears client sessions but may leave SSO session active + - Applications check for SSO session cookie, not client sessions + +3. **Cookie Domain/Path Issues**: + - Keycloak cookies are set on Keycloak's domain + - Client-side `clearKeycloakCookies()` may not work if: + - Cookies are `HttpOnly` (can't be cleared from JavaScript) + - Cookies are on different domain (cross-domain restrictions) + - Cookies have different path/domain settings + +4. **Logout Endpoint Behavior**: + - Keycloak logout endpoint (`/protocol/openid-connect/logout`) with `id_token_hint`: + - Clears the **client session** for that specific OAuth client + - May clear SSO session **only if it's the last client session** + - If other applications have active sessions, SSO session persists + +### Root Cause + +**The SSO session cookie persists** because: +1. `users.logout()` Admin API method clears client sessions but may not clear SSO session cookie +2. Keycloak logout endpoint only clears SSO session if it's the last client session +3. If other applications have active sessions, the SSO session remains valid +4. Applications check for SSO session cookie, not client sessions + +### Why This Happens + +**Keycloak's SSO Design**: +- SSO session is designed to persist across client logouts +- This allows users to stay logged in across multiple applications +- Logging out from one application shouldn't log out from all applications +- This is **by design** for SSO functionality + +**However**, when you want **global logout**, you need to: +1. Clear the SSO session cookie explicitly +2. Or ensure all client sessions are logged out first +3. Or use Keycloak's Single Logout (SLO) feature + +### Solution Requirements + +To ensure applications are logged out: + +1. **Keycloak Configuration** (Server-Side): + - Enable **Front-Channel Logout** for all clients + - Configure **Back-Channel Logout URLs** for each client + - This allows Keycloak to notify all applications when logout occurs + +2. **Admin API Limitations**: + - `users.logout()` may not clear SSO session cookie + - Need to use Keycloak's logout endpoint with proper parameters + - Or use Keycloak Admin API to end SSO session directly (if available) + +3. **Alternative Approach**: + - Use Keycloak's **Single Logout (SLO)** feature + - Configure all clients to participate in SLO + - When one client logs out, all clients are notified + +### What's Actually Happening + +When you call `/api/auth/end-sso-session`: +1. ✅ Admin API `users.logout()` is called +2. ✅ All client sessions are logged out +3. ❌ SSO session cookie may still exist +4. ❌ Applications check SSO session cookie → still authenticated + +When you redirect to Keycloak logout endpoint: +1. ✅ Dashboard client session is cleared +2. ✅ If it's the last client session, SSO session is cleared +3. ❌ If other applications have active sessions, SSO session persists +4. ❌ Applications can still authenticate using SSO session cookie + +### Verification Steps + +To verify why applications are still connected: + +1. **Check if Admin API call succeeds**: + - Look for console logs: "Successfully ended SSO session for user: {userId}" + - Check for errors in `/api/auth/end-sso-session` endpoint + +2. **Check Keycloak session cookies**: + - After logout, check browser cookies for: + - `KEYCLOAK_SESSION` + - `KEYCLOAK_SESSION_LEGACY` + - `KEYCLOAK_IDENTITY` + - If these cookies still exist, SSO session is still active + +3. **Check if other applications have active sessions**: + - If other applications are open in other tabs/windows + - They may have active client sessions + - This prevents SSO session from being cleared + +4. **Check Keycloak Admin Console**: + - Navigate to: Users → [User] → Sessions + - Check if sessions are actually cleared + - Verify SSO session status + +### Recommended Solutions + +**Option 1: Keycloak Configuration (Recommended)** +- Enable Front-Channel Logout for all clients +- Configure Back-Channel Logout URLs +- This ensures all applications are notified of logout + +**Option 2: Clear SSO Session Cookie Explicitly** +- After Admin API logout, redirect to Keycloak logout endpoint +- Use `kc_action=LOGOUT` parameter (already implemented) +- Ensure all client sessions are logged out first + +**Option 3: Use Keycloak Single Logout (SLO)** +- Configure all clients to participate in SLO +- When dashboard logs out, all clients are automatically logged out +- Requires Keycloak configuration changes + +--- + +## Summary + +### Issue 1: 30-Minute Inactivity Timeout +- **Status**: Not implemented +- **Reason**: NextAuth doesn't track activity, only session age +- **Solution**: Client-side activity tracking + automatic logout + +### Issue 2: Applications Still Connected +- **Status**: Partially working +- **Reason**: SSO session cookie persists even after client sessions are cleared +- **Solution**: Keycloak configuration (Front-Channel Logout) or SLO + diff --git a/app/api/auth/options.ts b/app/api/auth/options.ts index 703ef698..4499af43 100644 --- a/app/api/auth/options.ts +++ b/app/api/auth/options.ts @@ -280,8 +280,11 @@ export const authOptions: NextAuthOptions = { hasAccessToken: !!token.accessToken, hasRefreshToken: !!token.refreshToken }); + // Return null to make NextAuth treat user as unauthenticated // This will trigger automatic redirect to sign-in page + // The client-side code will detect session invalidation by checking for + // session cookie existence when status is unauthenticated return null as any; } diff --git a/app/signin/page.tsx b/app/signin/page.tsx index 3b243ae6..94626407 100644 --- a/app/signin/page.tsx +++ b/app/signin/page.tsx @@ -13,14 +13,26 @@ export default function SignIn() { const isLogoutRedirect = useRef(false); // Check if this is a logout redirect (from Keycloak post_logout_redirect_uri) + // OR if session was invalidated (e.g., from iframe logout) useEffect(() => { // Check URL parameters or session storage for logout flag const logoutParam = searchParams.get('logout'); const fromLogout = sessionStorage.getItem('just_logged_out'); + const sessionInvalidated = sessionStorage.getItem('session_invalidated'); - if (logoutParam === 'true' || fromLogout === 'true') { + // Check if there's a NextAuth session cookie that's now invalid + // This indicates the session was invalidated (e.g., by iframe logout) + const hasInvalidSessionCookie = document.cookie + .split(';') + .some(c => c.trim().startsWith('next-auth.session-token=') || + c.trim().startsWith('__Secure-next-auth.session-token=') || + c.trim().startsWith('__Host-next-auth.session-token=')); + + // If session was invalidated or this is a logout redirect, prevent auto-login + if (logoutParam === 'true' || fromLogout === 'true' || sessionInvalidated === 'true') { isLogoutRedirect.current = true; sessionStorage.removeItem('just_logged_out'); + sessionStorage.removeItem('session_invalidated'); // Clear any OAuth parameters from URL to prevent callback processing const url = new URL(window.location.href); @@ -42,7 +54,17 @@ export default function SignIn() { // Don't auto-trigger login after logout return; } - }, [searchParams]); + + // Detect session invalidation: if status becomes unauthenticated + // and we had a session cookie, it means the session was invalidated + if (status === 'unauthenticated' && hasInvalidSessionCookie && !hasAttemptedLogin.current) { + console.log('Session invalidation detected (likely from iframe logout), preventing auto-login'); + sessionStorage.setItem('session_invalidated', 'true'); + isLogoutRedirect.current = true; + // Don't auto-login - user must manually click "Se connecter" + return; + } + }, [searchParams, status]); useEffect(() => { // If user is already authenticated, redirect to home @@ -51,8 +73,9 @@ export default function SignIn() { return; } - // Don't auto-login if this is a logout redirect or we've already attempted login - if (isLogoutRedirect.current || hasAttemptedLogin.current) { + // Don't auto-login if this is a logout redirect, session was invalidated, or we've already attempted login + const sessionInvalidated = sessionStorage.getItem('session_invalidated') === 'true'; + if (isLogoutRedirect.current || sessionInvalidated || hasAttemptedLogin.current) { return; } @@ -62,14 +85,36 @@ export default function SignIn() { } // Only trigger Keycloak sign-in if not authenticated, not loading, and not from logout - // Add a longer delay to ensure OAuth callbacks have completed + // AND only if this is a truly new user (never logged in), not a session invalidation if (status === "unauthenticated") { + // Check if there's evidence of a previous session (session cookie exists but invalid) + // This indicates session was invalidated, not a new user + const hasSessionCookie = document.cookie + .split(';') + .some(c => { + const cookie = c.trim(); + return cookie.startsWith('next-auth.session-token=') || + cookie.startsWith('__Secure-next-auth.session-token=') || + cookie.startsWith('__Host-next-auth.session-token='); + }); + + // If there's a session cookie but status is unauthenticated, session was invalidated + // Don't auto-login in this case + if (hasSessionCookie) { + console.log('Session cookie detected but status is unauthenticated - session was invalidated, preventing auto-login'); + sessionStorage.setItem('session_invalidated', 'true'); + isLogoutRedirect.current = true; + return; + } + + // Only auto-login for truly new users (no session cookie) hasAttemptedLogin.current = true; // Longer delay to ensure we're not in a logout redirect flow or OAuth callback const timer = setTimeout(() => { // Double-check we're still unauthenticated and not in a logout flow - if (!isLogoutRedirect.current) { - // Trigger Keycloak sign-in + const stillInvalidated = sessionStorage.getItem('session_invalidated') === 'true'; + if (!isLogoutRedirect.current && !stillInvalidated) { + // Trigger Keycloak sign-in only for new users signIn("keycloak", { callbackUrl: "/" }); } }, 1000); @@ -112,8 +157,11 @@ export default function SignIn() { } }, [session]); - // Show logout message if coming from logout - const showLogoutMessage = isLogoutRedirect.current || searchParams.get('logout') === 'true'; + // Show logout message if coming from logout or session was invalidated + const sessionInvalidated = sessionStorage.getItem('session_invalidated') === 'true'; + const showLogoutMessage = isLogoutRedirect.current || + searchParams.get('logout') === 'true' || + sessionInvalidated; return (

{showLogoutMessage - ? "Vous avez été déconnecté avec succès" + ? (sessionInvalidated + ? "Votre session a expiré. Veuillez vous reconnecter." + : "Vous avez été déconnecté avec succès") : initializationStatus === "initializing" ? "Initialisation de votre espace..." : initializationStatus === "success" @@ -142,8 +192,11 @@ export default function SignIn() {