From d04662795cb47a2889b7c8b4e2e796d1ce1ebfae Mon Sep 17 00:00:00 2001 From: alma Date: Fri, 2 Jan 2026 11:28:27 +0100 Subject: [PATCH] keycloak improve flow --- KEYCLOAK_SESSION_SYNC.md | 123 ++++++++++++++++++ app/api/auth/options.ts | 2 + .../auth/refresh-keycloak-session/route.ts | 73 +++++++++++ app/components/responsive-iframe.tsx | 86 +++++++++--- 4 files changed, 267 insertions(+), 17 deletions(-) create mode 100644 KEYCLOAK_SESSION_SYNC.md create mode 100644 app/api/auth/refresh-keycloak-session/route.ts diff --git a/KEYCLOAK_SESSION_SYNC.md b/KEYCLOAK_SESSION_SYNC.md new file mode 100644 index 00000000..64bfa32d --- /dev/null +++ b/KEYCLOAK_SESSION_SYNC.md @@ -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= +``` + +## 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 + diff --git a/app/api/auth/options.ts b/app/api/auth/options.ts index 57a56bd2..45e77d59 100644 --- a/app/api/auth/options.ts +++ b/app/api/auth/options.ts @@ -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; } diff --git a/app/api/auth/refresh-keycloak-session/route.ts b/app/api/auth/refresh-keycloak-session/route.ts new file mode 100644 index 00000000..fcc9825c --- /dev/null +++ b/app/api/auth/refresh-keycloak-session/route.ts @@ -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 } + ); + } +} + diff --git a/app/components/responsive-iframe.tsx b/app/components/responsive-iframe.tsx index c9185864..e152f958 100644 --- a/app/components/responsive-iframe.tsx +++ b/app/components/responsive-iframe.tsx @@ -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(null); + const { data: session } = useSession(); + const [isRefreshing, setIsRefreshing] = useState(false); + const [iframeSrc, setIframeSrc] = useState(''); + + // 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 ( -