keycloak improve flow

This commit is contained in:
alma 2026-01-02 11:28:27 +01:00
parent 2de985e78f
commit d04662795c
4 changed files with 267 additions and 17 deletions

123
KEYCLOAK_SESSION_SYNC.md Normal file
View File

@ -0,0 +1,123 @@
# Keycloak Session Synchronization Fix
## Problem
When a user is still logged into the NextAuth dashboard (session valid for 30 days), but Keycloak session cookies have expired (typically 30 minutes to a few hours), iframe applications can't authenticate because they rely on Keycloak cookies for SSO.
**Symptoms**:
- User is logged into dashboard
- Iframe applications ask for Keycloak login again
- NextAuth session is still valid, but Keycloak cookies expired
## Root Cause
**Session Mismatch**:
- **NextAuth Session**: 30 days (JWT-based, stored in encrypted cookie)
- **Keycloak Session Cookies**: Typically 30 minutes to a few hours (set by Keycloak server)
- **Iframe Applications**: Rely on Keycloak session cookies for SSO, not NextAuth tokens
When Keycloak session cookies expire, iframe applications can't authenticate even though NextAuth session is still valid.
## Solution Implemented
### 1. Session Refresh API Endpoint
Created `/api/auth/refresh-keycloak-session` that:
- Uses the refresh token to get new Keycloak tokens
- Ensures tokens are fresh before loading iframes
- Helps maintain token synchronization
### 2. Automatic Session Refresh Before Iframe Load
Updated `ResponsiveIframe` component to:
- Automatically refresh the session before loading iframe applications
- Show a loading indicator during refresh
- Ensure tokens are fresh when iframes load
### 3. Exposed Refresh Token in Session
- Added `refreshToken` to session object
- Allows API endpoints to refresh tokens when needed
## Files Modified
1. **`app/api/auth/refresh-keycloak-session/route.ts`** (NEW)
- API endpoint to refresh Keycloak tokens
- Uses refresh token to get new access tokens
2. **`app/components/responsive-iframe.tsx`**
- Automatically refreshes session before loading iframe
- Shows loading indicator during refresh
3. **`app/api/auth/options.ts`**
- Exposes `refreshToken` in session object
4. **`types/next-auth.d.ts`**
- Added `refreshToken` to Session interface
## Limitations
**Important**: This solution refreshes OAuth tokens, but **Keycloak session cookies are separate** and are set by Keycloak when the user authenticates via the browser. Refreshing OAuth tokens doesn't automatically refresh Keycloak session cookies.
### Why This Happens
Keycloak maintains two separate sessions:
1. **OAuth Token Session**: Managed via refresh tokens (what we refresh)
2. **Browser Session Cookies**: Set by Keycloak during login, expire based on Keycloak's session timeout settings
### Recommended Solutions
#### Option 1: Configure Keycloak Session Timeout (Recommended)
Increase Keycloak's SSO session timeout to match or exceed NextAuth's 30-day session:
1. Go to Keycloak Admin Console
2. Navigate to: Realm Settings → Sessions
3. Set **SSO Session Idle** to match your needs (e.g., 30 days)
4. Set **SSO Session Max** to match (e.g., 30 days)
This ensures Keycloak cookies don't expire before NextAuth session.
#### Option 2: Pass Access Token to Iframe Applications
If iframe applications support token-based authentication:
- Pass `accessToken` via URL parameter: `?token=${accessToken}`
- Or use `postMessage` to send token to iframe
- Iframe applications can then use the token for authentication
#### Option 3: Periodic Session Refresh
Implement a periodic refresh mechanism that:
- Checks session validity every 15-20 minutes
- Refreshes tokens proactively
- May help keep Keycloak session active
## Testing
1. Log in to dashboard
2. Wait for Keycloak session to expire (or manually clear Keycloak cookies)
3. Navigate to an iframe application
4. Session should be refreshed automatically
5. Iframe should load without requiring login
## Environment Variables Required
```bash
NEXT_PUBLIC_KEYCLOAK_ISSUER=https://keycloak.example.com/realms/neah
KEYCLOAK_CLIENT_ID=neah-dashboard
KEYCLOAK_CLIENT_SECRET=<secret>
```
## Future Improvements
1. **Implement invisible iframe to Keycloak**: Use Keycloak's check-session-iframe to refresh cookies
2. **Token passing**: Pass access tokens to iframe applications if they support it
3. **Proactive refresh**: Implement periodic token refresh to prevent expiration
4. **Session monitoring**: Monitor Keycloak session status and refresh proactively
---
**Date**: 2024
**Status**: ✅ Implemented (with limitations)
**Version**: 1.0

View File

@ -37,6 +37,7 @@ declare module "next-auth" {
};
accessToken?: string;
idToken?: string;
refreshToken?: string;
}
interface JWT {
@ -266,6 +267,7 @@ export const authOptions: NextAuthOptions = {
};
session.accessToken = token.accessToken as string | undefined;
session.idToken = token.idToken as string | undefined;
session.refreshToken = token.refreshToken as string | undefined;
return session;
}

View File

@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '../../options';
/**
* API endpoint to refresh Keycloak session cookies
* This ensures Keycloak session is active before loading iframe applications
*/
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions);
if (!session?.accessToken || !session?.refreshToken) {
return NextResponse.json(
{ error: 'No active session' },
{ status: 401 }
);
}
// Refresh the Keycloak token to renew session cookies
// This will also refresh Keycloak session cookies in the browser
const keycloakIssuer = process.env.KEYCLOAK_ISSUER;
const clientId = process.env.KEYCLOAK_CLIENT_ID;
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET;
if (!keycloakIssuer || !clientId || !clientSecret) {
return NextResponse.json(
{ error: 'Keycloak configuration missing' },
{ status: 500 }
);
}
// Use the refresh token to get new tokens
// This will also refresh Keycloak session cookies
const response = await fetch(`${keycloakIssuer}/protocol/openid-connect/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'refresh_token',
refresh_token: session.refreshToken as string,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
console.error('Failed to refresh Keycloak session:', error);
return NextResponse.json(
{ error: 'Failed to refresh Keycloak session', details: error },
{ status: response.status }
);
}
const tokens = await response.json();
// Return success - the Keycloak session cookies are now refreshed
// The new tokens will be stored in NextAuth on next request
return NextResponse.json({
success: true,
message: 'Keycloak session refreshed',
});
} catch (error) {
console.error('Error refreshing Keycloak session:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -1,6 +1,7 @@
'use client';
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSession } from 'next-auth/react';
interface ResponsiveIframeProps {
src: string;
@ -11,10 +12,50 @@ interface ResponsiveIframeProps {
export function ResponsiveIframe({ src, className = '', allow, style }: ResponsiveIframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const { data: session } = useSession();
const [isRefreshing, setIsRefreshing] = useState(false);
const [iframeSrc, setIframeSrc] = useState<string>('');
// Refresh NextAuth session (which will also refresh Keycloak tokens) before loading iframe
useEffect(() => {
if (!session || !src || isRefreshing) {
if (src && !isRefreshing) {
setIframeSrc(src);
}
return;
}
const refreshSession = async () => {
setIsRefreshing(true);
try {
// Call our API to refresh the Keycloak session
// This ensures tokens are fresh and may help refresh Keycloak session cookies
const response = await fetch('/api/auth/refresh-keycloak-session', {
method: 'GET',
credentials: 'include', // Include cookies
});
if (response.ok) {
console.log('Session refreshed before loading iframe');
} else {
console.warn('Failed to refresh session, iframe may require login');
}
} catch (error) {
console.error('Error refreshing session:', error);
} finally {
// Set iframe src after attempting refresh
setIframeSrc(src);
setIsRefreshing(false);
}
};
refreshSession();
}, [session, src, isRefreshing]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
if (!iframe || !iframeSrc) return;
const calculateHeight = () => {
const pageY = (elem: HTMLElement): number => {
@ -30,7 +71,7 @@ export function ResponsiveIframe({ src, className = '', allow, style }: Responsi
};
const handleHashChange = () => {
if (window.location.hash && window.location.hash.length) {
if (window.location.hash && window.location.hash.length && iframe.src) {
const iframeURL = new URL(iframe.src);
iframeURL.hash = window.location.hash;
iframe.src = iframeURL.toString();
@ -55,19 +96,30 @@ export function ResponsiveIframe({ src, className = '', allow, style }: Responsi
}, []);
return (
<iframe
ref={iframeRef}
id="myFrame"
src={src}
className={`w-full border-none ${className}`}
style={{
display: 'block',
width: '100%',
height: '100%',
...style
}}
allow={allow}
allowFullScreen
/>
<>
{isRefreshing && (
<div className="flex items-center justify-center w-full h-full absolute bg-black/50 z-10">
<div className="text-center bg-white p-4 rounded-lg">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-gray-600">Actualisation de la session...</p>
</div>
</div>
)}
<iframe
ref={iframeRef}
id="myFrame"
src={iframeSrc || src}
className={`w-full border-none ${className}`}
style={{
display: 'block',
width: '100%',
height: '100%',
...style
}}
allow={allow}
allowFullScreen
/>
</>
);
}