keycloak improve with build 6

This commit is contained in:
alma 2026-01-02 17:08:55 +01:00
parent f0c109ed8e
commit 61bc7d6809
4 changed files with 557 additions and 10 deletions

View File

@ -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)

View File

@ -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
<SessionProvider>
{children}
</SessionProvider>
```
### 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

View File

@ -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;
}

View File

@ -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 (
<div
@ -129,7 +177,9 @@ export default function SignIn() {
<div>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">
{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() {
<div className="mt-4 text-center">
<button
onClick={() => {
// Clear all logout/invalidation flags before logging in
hasAttemptedLogin.current = false;
isLogoutRedirect.current = false;
sessionStorage.removeItem('just_logged_out');
sessionStorage.removeItem('session_invalidated');
// Force login prompt by adding prompt=login parameter
// This ensures credentials are asked even if SSO session exists
signIn("keycloak", {