auth flow

This commit is contained in:
alma 2025-05-02 12:48:01 +02:00
parent ed199d7a00
commit fa05961404
9 changed files with 628 additions and 554 deletions

15
.env
View File

@ -27,7 +27,7 @@ NEXT_PUBLIC_IFRAME_LEARN_URL=https://apprendre.slm-lab.net
NEXT_PUBLIC_IFRAME_PAROLE_URL=https://parole.slm-lab.net/channel/City
NEXT_PUBLIC_IFRAME_CHAPTER_URL=https://chapitre.slm-lab.net
NEXT_PUBLIC_IFRAME_AGILITY_URL=https://agilite.slm-lab.net/oidc/login
NEXT_PUBLIC_IFRAME_ARTLAB_URL=https://artlab.slm-lab.net
NEXT_PUBLIC_IFRAME_DESIGN_URL=https://artlab.slm-lab.net
NEXT_PUBLIC_IFRAME_GITE_URL=https://gite.slm-lab.net/user/oauth2/cube
NEXT_PUBLIC_IFRAME_CALCULATION_URL=https://calcul.slm-lab.net
NEXT_PUBLIC_IFRAME_MEDIATIONS_URL=https://connect.slm-lab.net/realms/cercle/protocol/openid-connect/auth?client_id=mediations.slm-lab.net&redirect_uri=https%3A%2F%2Fmediations.slm-lab.net%2F%3Fopenid_mode%3Dtrue&scope=openid%20profile%20email&response_type=code
@ -45,7 +45,7 @@ NEXT_PUBLIC_IFRAME_ANNOUNCEMENT_URL=https://espace.slm-lab.net/apps/announcement
NEXT_PUBLIC_IFRAME_HEALTHVIEW_URL=https://espace.slm-lab.net/apps/health/?embedMode=true&hideNavigation=true
NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL=https://connect.slm-lab.net/realms/cercle/protocol/openid-connect/auth?response_type=code&scope=openid&client_id=page.slm-lab.net&state=f72528f6756bc132e76dd258691b71cf&redirect_uri=https%3A%2F%2Fpage.slm-lab.net%2Fwp-admin%2F
NEXT_PUBLIC_IFRAME_USERSVIEW_URL=https://example.com/users-view
NEXT_PUBLIC_IFRAME_THEMESSAGE_URL=https://lemessage.slm-lab.net/admin/
NEXT_PUBLIC_IFRAME_THEMESSAGE_URL=https://lemessage.slm-lab.net/auth/oidc
NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL=https://alma.slm-lab.net
ROCKET_CHAT_TOKEN=w91TYgkH-Z67Oz72usYdkW5TZLLRwnre7qyAhp7aHJB
@ -85,3 +85,14 @@ MICROSOFT_CLIENT_ID="afaffea5-4e10-462a-aa64-e73baf642c57"
MICROSOFT_CLIENT_SECRET="eIx8Q~N3ZnXTjTsVM3ECZio4G7t.BO6AYlD1-b2h"
MICROSOFT_REDIRECT_URI="https://lab.slm-lab.net/ms"
MICROSOFT_TENANT_ID="cb4281a9-4a3e-4ff5-9a85-8425dd04e2b2"
# Reduce session size to prevent chunking
NEXTAUTH_JWT_CALLBACK_STORE_SESSION_DATA=false
NEXTAUTH_SESSION_STORE_SESSION_TOKEN=false
NEXTAUTH_JWT_STORE_RAW_TOKEN=false
# Cookie security and sharing
NEXTAUTH_COOKIE_DOMAIN=.slm-lab.net
NEXTAUTH_URL=https://lab.slm-lab.net
NEXTAUTH_COOKIE_SECURE=true
NEXTAUTH_COOKIE_SAMESITE=none

View File

@ -1 +0,0 @@
NEXT_PUBLIC_IFRAME_DESIGN_URL=https://design.slm-lab.net

View File

@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
const userId = session?.user?.id;
const keycloakUrl = process.env.KEYCLOAK_ISSUER;
const clientId = process.env.KEYCLOAK_CLIENT_ID;
const redirectUri = req.nextUrl.searchParams.get('redirectUri') || process.env.NEXTAUTH_URL;
// Build Keycloak logout URL
let logoutUrl = `${keycloakUrl}/protocol/openid-connect/logout?client_id=${clientId}`;
if (redirectUri) {
logoutUrl += `&post_logout_redirect_uri=${encodeURIComponent(redirectUri)}`;
}
console.log(`Initiating full Keycloak logout for user ${userId || 'unknown'}`);
console.log(`Logout URL: ${logoutUrl}`);
return NextResponse.json({
success: true,
logoutUrl,
message: "Keycloak logout URL generated"
});
} catch (error) {
console.error("Error generating Keycloak logout URL:", error);
return NextResponse.json({
success: false,
error: "Failed to generate Keycloak logout URL"
}, { status: 500 });
}
}

View File

@ -1,52 +1,85 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { cleanupUserSessions } from '@/lib/redis';
import { getRedisClient } from '@/lib/redis';
import { closeUserImapConnections } from '@/lib/services/email-service';
/**
* API endpoint to clean up user sessions and invalidate cached data
* Called during logout to ensure proper cleanup of all connections
*/
export async function POST(request: NextRequest) {
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
const userId = session?.user?.id;
if (!userId) {
return NextResponse.json({ success: false, error: "No authenticated user found" }, { status: 401 });
}
await cleanupUserSessions(userId, false);
return NextResponse.json({ success: true, userId, message: "Session cleaned up" });
}
export async function POST(req: NextRequest) {
try {
// Get the user ID either from the session or request body
const session = await getServerSession(authOptions);
const body = await request.json().catch(() => ({}));
// Get user ID from session or from request body
const userId = session?.user?.id || body.userId;
const body = await req.json();
const userId = body.userId;
const preserveSso = !!body.preserveSso;
if (!userId) {
return NextResponse.json({
success: false,
error: 'No user ID provided or user not authenticated'
}, { status: 400 });
return NextResponse.json({ success: false, error: "No user ID provided" }, { status: 400 });
}
console.log(`Processing session cleanup for user ${userId}`);
// 1. Close any active IMAP connections using the dedicated function
const closedConnections = await closeUserImapConnections(userId);
// 2. Clean up Redis data
await cleanupUserSessions(userId);
// 3. Return success response with details
await cleanupUserSessions(userId, preserveSso);
return NextResponse.json({
success: true,
message: `Session cleanup completed for user ${userId}`,
details: {
closedConnections,
redisCleanupPerformed: true
}
userId,
preserveSso,
message: `Session cleaned up${preserveSso ? ' (SSO preserved)' : ''}`
});
} catch (error) {
console.error('Error in session cleanup:', error);
return NextResponse.json({
success: false,
error: 'Session cleanup failed',
details: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 });
console.error("Error in session-cleanup POST:", error);
return NextResponse.json({ success: false, error: "Failed to parse request" }, { status: 400 });
}
}
async function cleanupUserSessions(userId: string, preserveSso: boolean) {
try {
const redis = await getRedisClient();
if (!redis) {
console.error("Redis client not available for session cleanup");
return;
}
console.log(`Cleaning up sessions for user ${userId}${preserveSso ? ' (preserving SSO)' : ''}`);
// Find all keys for this user
const userKeys = await redis.keys(`*:${userId}*`);
if (userKeys.length > 0) {
console.log(`Found ${userKeys.length} keys for user ${userId}:`, userKeys);
// If preserving SSO, only delete application-specific keys
const keysToDelete = preserveSso
? userKeys.filter(key => !key.includes('keycloak') && !key.includes('sso'))
: userKeys;
if (keysToDelete.length > 0) {
await redis.del(keysToDelete);
console.log(`Deleted ${keysToDelete.length} keys for user ${userId}`);
} else {
console.log(`No application keys to delete while preserving SSO`);
}
} else {
console.log(`No keys found for user ${userId}`);
}
// Close any active IMAP connections
await closeUserImapConnections(userId);
} catch (error) {
console.error(`Error cleaning up sessions for user ${userId}:`, error);
}
}

View File

@ -3,155 +3,180 @@
import { useEffect, useRef, useState } from 'react';
import { useSession } from 'next-auth/react';
interface ResponsiveIframeProps {
export interface ResponsiveIframeProps {
src: string;
className?: string;
allow?: string;
style?: React.CSSProperties;
title?: string;
token?: string;
className?: string;
style?: React.CSSProperties;
hideUntilLoad?: boolean;
allowFullScreen?: boolean;
scrolling?: boolean;
heightOffset?: number;
}
export function ResponsiveIframe({ src, className = '', allow, style, token }: ResponsiveIframeProps) {
export default function ResponsiveIframe({
src,
title = 'Embedded content',
token,
className = '',
style = {},
hideUntilLoad = false,
allowFullScreen = false,
scrolling = true,
heightOffset = 0,
}: ResponsiveIframeProps) {
const [height, setHeight] = useState<number>(0);
const [loaded, setLoaded] = useState<boolean>(false);
const [authError, setAuthError] = useState<boolean>(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const silentAuthRef = useRef<HTMLIFrameElement>(null);
const silentAuthTimerRef = useRef<NodeJS.Timeout | null>(null);
const { data: session } = useSession();
const [authError, setAuthError] = useState<string | null>(null);
// Add token parameter only if token is provided
const fullSrc = token ?
`${src}${src.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` :
src;
// Append token to src if provided
const fullSrc = token ? `${src}${src.includes('?') ? '&' : '?'}token=${token}` : src;
// Handle silent authentication refresh
useEffect(() => {
let silentRefreshTimer: NodeJS.Timeout;
// Set up periodic silent refresh (every 15 minutes)
const startSilentRefresh = () => {
silentRefreshTimer = setInterval(() => {
console.log('Performing silent authentication check for iframes');
// Setup silent authentication check every 15 minutes
const setupSilentAuth = () => {
if (silentAuthTimerRef.current) {
clearTimeout(silentAuthTimerRef.current);
}
// Create a hidden iframe to check authentication status
silentAuthTimerRef.current = setTimeout(() => {
console.log('Running silent authentication check');
// Create a hidden iframe for silent authentication
const refreshFrame = document.createElement('iframe');
refreshFrame.style.display = 'none';
refreshFrame.src = '/silent-refresh';
document.body.appendChild(refreshFrame);
// Create the silent auth iframe if it doesn't exist
if (silentAuthRef.current && !silentAuthRef.current.src) {
silentAuthRef.current.src = '/silent-refresh';
}
// Remove iframe after it has loaded (5 seconds timeout)
setTimeout(() => {
if (refreshFrame && refreshFrame.parentNode) {
refreshFrame.parentNode.removeChild(refreshFrame);
}
}, 5000);
// Setup next check
setupSilentAuth();
}, 15 * 60 * 1000); // 15 minutes
};
if (session) {
startSilentRefresh();
}
// Handle messages from the silent auth iframe
const handleAuthMessage = (event: MessageEvent) => {
if (event.data && event.data.type === 'AUTH_STATUS') {
console.log('Received auth status:', event.data);
if (event.data.status === 'UNAUTHENTICATED') {
console.error('Silent authentication failed - user is not authenticated');
setAuthError(true);
// Force immediate refresh
if (silentAuthRef.current) {
silentAuthRef.current.src = '/silent-refresh';
}
} else if (event.data.status === 'AUTHENTICATED') {
console.log('Silent authentication successful');
setAuthError(false);
}
}
};
window.addEventListener('message', handleAuthMessage);
setupSilentAuth();
// Cleanup
return () => {
if (silentRefreshTimer) {
clearInterval(silentRefreshTimer);
window.removeEventListener('message', handleAuthMessage);
if (silentAuthTimerRef.current) {
clearTimeout(silentAuthTimerRef.current);
}
};
}, [session]);
}, []);
// Adjust iframe height based on window size
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const calculateHeight = () => {
const pageY = (elem: HTMLElement): number => {
return elem.offsetParent ?
(elem.offsetTop + pageY(elem.offsetParent as HTMLElement)) :
elem.offsetTop;
};
const height = document.documentElement.clientHeight;
const iframeY = pageY(iframe);
const newHeight = Math.max(0, height - iframeY);
iframe.style.height = `${newHeight}px`;
const updateHeight = () => {
const viewportHeight = window.innerHeight;
const newHeight = viewportHeight - heightOffset;
setHeight(newHeight > 0 ? newHeight : 0);
};
updateHeight();
window.addEventListener('resize', updateHeight);
return () => {
window.removeEventListener('resize', updateHeight);
};
}, [heightOffset]);
// Handle hash changes by updating iframe source
useEffect(() => {
const handleHashChange = () => {
if (window.location.hash && window.location.hash.length) {
const iframeURL = new URL(iframe.src);
iframeURL.hash = window.location.hash;
iframe.src = iframeURL.toString();
}
};
// Handle authentication messages from iframe
const handleMessage = (event: MessageEvent) => {
// Accept messages from our iframe or from silent auth iframe
if (event.source !== iframe.contentWindow &&
!event.data?.type?.startsWith('SILENT_AUTH_')) return;
const { type, data, error } = event.data || {};
// Handle auth-related messages
if (type === 'AUTH_ERROR' || type === 'SESSION_EXPIRED') {
console.log('Auth error in iframe:', data || error);
setAuthError(error || 'Authentication error');
} else if (type === 'SILENT_AUTH_SUCCESS') {
console.log('Silent authentication successful');
setAuthError(null);
} else if (type === 'SILENT_AUTH_FAILURE') {
console.log('Silent authentication failed:', error);
// Only set error if it's persistent
if (error !== 'loading') {
setAuthError('Session expired');
const iframe = iframeRef.current;
if (iframe && iframe.src) {
const url = new URL(iframe.src);
// If there's a hash in the parent window's URL
if (window.location.hash) {
url.hash = window.location.hash;
iframe.src = url.toString();
}
}
};
// Initial setup
calculateHeight();
handleHashChange();
// Event listeners
window.addEventListener('resize', calculateHeight);
window.addEventListener('hashchange', handleHashChange);
window.addEventListener('message', handleMessage);
iframe.addEventListener('load', calculateHeight);
// Cleanup
return () => {
window.removeEventListener('resize', calculateHeight);
window.removeEventListener('hashchange', handleHashChange);
window.removeEventListener('message', handleMessage);
iframe.removeEventListener('load', calculateHeight);
};
}, []);
return (
<>
{authError && (
<div
style={{
backgroundColor: 'rgba(255, 0, 0, 0.1)',
padding: '10px',
borderRadius: '4px',
marginBottom: '10px'
}}
>
Authentication error: {authError}. The service might not work correctly.
</div>
)}
<iframe
ref={iframeRef}
id="myFrame"
src={fullSrc}
className={`w-full border-none ${className}`}
style={{
display: 'block',
width: '100%',
height: '100%',
...style
}}
allow={allow}
allowFullScreen
{/* Hidden iframe for silent authentication */}
<iframe
ref={silentAuthRef}
style={{ display: 'none' }}
title="Silent Authentication"
/>
{/* Main content iframe */}
<div className="relative w-full h-full">
{authError && (
<div className="absolute inset-0 flex items-center justify-center bg-red-50 bg-opacity-90 z-10">
<div className="text-center p-4">
<p className="text-red-600 font-semibold">Session expired or authentication error</p>
<button
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
onClick={() => window.location.href = '/api/auth/signin?callbackUrl=' + encodeURIComponent(window.location.href)}
>
Sign in again
</button>
</div>
</div>
)}
<iframe
ref={iframeRef}
src={fullSrc}
title={title}
className={`w-full ${className}`}
style={{
height: height > 0 ? `${height}px` : '100%',
border: 'none',
visibility: hideUntilLoad && !loaded ? 'hidden' : 'visible',
...style,
}}
onLoad={() => {
setLoaded(true);
}}
allowFullScreen={allowFullScreen}
scrolling={scrolling ? 'yes' : 'no'}
/>
{hideUntilLoad && !loaded && (
<div className="flex justify-center items-center w-full h-full absolute top-0 left-0">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
</div>
)}
</div>
</>
);
}

View File

@ -1,237 +1,280 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { clearAuthCookies } from "@/lib/session";
import Link from "next/link";
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
export default function LoggedOut() {
export default function LoggedOutPage() {
const [sessionStatus, setSessionStatus] = useState<'checking' | 'cleared' | 'error'>('checking');
const [message, setMessage] = useState<string>('');
const [keycloakLogoutUrl, setKeycloakLogoutUrl] = useState<string | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const forceLogout = new URLSearchParams(window.location.search).get('forceLogout') === 'true';
const searchParams = useSearchParams();
const preserveSso = searchParams.get('preserveSso') === 'true';
// Listen for any messages from iframes
useEffect(() => {
const messageHandler = (event: MessageEvent) => {
// Handle any auth-related messages from iframes
if (event.data && event.data.type === 'AUTH_ERROR') {
console.log('Received auth error from iframe:', event.data);
// Listen for messages from iframes
const handleMessage = (event: MessageEvent) => {
if (event.data && event.data.type === 'AUTH_STATUS') {
console.log('Received auth status message:', event.data);
}
};
window.addEventListener('message', handleMessage);
window.addEventListener('message', messageHandler);
return () => window.removeEventListener('message', messageHandler);
}, []);
// Clear auth cookies again on this page as an extra precaution
useEffect(() => {
const checkAndClearSessions = async () => {
const clearSessions = async () => {
try {
// Additional browser storage clearing
console.log('Performing complete browser storage cleanup');
console.log(`Clearing sessions (preserveSso: ${preserveSso})`);
// Add a hidden iframe to directly call Keycloak logout endpoint
// This ensures the server-side Keycloak session is properly terminated
if (process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER) {
console.log('Adding Keycloak logout iframe');
const keycloakBaseUrl = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER;
const logoutEndpoint = `${keycloakBaseUrl}/protocol/openid-connect/logout`;
if (iframeRef.current) {
iframeRef.current.src = logoutEndpoint;
}
}
// Try to get any user ID from localStorage or sessionStorage for server-side cleanup
let userId = '';
// Try to get any stored user IDs for server-side cleanup
let userId = null;
try {
// Check standard localStorage locations for userId
const possibleUserIdKeys = [
'userId',
'user_id',
'currentUser',
'user',
'keycloak.userId',
'auth.userId'
];
// Check localStorage first
userId = localStorage.getItem('userId') || sessionStorage.getItem('userId');
for (const key of possibleUserIdKeys) {
const value = localStorage.getItem(key) || sessionStorage.getItem(key);
if (value) {
// Try to get from sessionStorage as fallback
if (!userId) {
const sessionData = sessionStorage.getItem('nextauth.session-token');
if (sessionData) {
try {
// It might be a JSON object
const parsed = JSON.parse(value);
userId = parsed.id || parsed.userId || parsed.user_id || parsed.sub || '';
if (userId) break;
} catch {
// Or it might be a plain string
userId = value;
break;
const parsed = JSON.parse(atob(sessionData.split('.')[1]));
userId = parsed.sub || parsed.id;
} catch (e) {
console.error('Failed to parse session data:', e);
}
}
}
console.log('Found user ID for server cleanup:', userId || 'None found');
} catch (e) {
console.error('Error getting user ID from storage:', e);
console.error('Error accessing storage:', e);
}
// Call the server-side cleanup if we have a user ID
// If we found a user ID, call server-side cleanup
if (userId) {
console.log(`Found user ID: ${userId}, cleaning up server-side`);
try {
const cleanupResponse = await fetch('/api/auth/session-cleanup', {
const response = await fetch('/api/auth/session-cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ userId }),
body: JSON.stringify({
userId,
preserveSso
}),
credentials: 'include'
});
const cleanupResult = await cleanupResponse.json();
console.log('Server-side cleanup result:', cleanupResult);
const result = await response.json();
console.log('Server cleanup result:', result);
} catch (e) {
console.error('Error calling server-side cleanup:', e);
console.error('Error during server-side cleanup:', e);
}
}
// Clear cookies
clearAuthCookies();
// If not preserving SSO, get Keycloak logout URL
if (!preserveSso) {
try {
const response = await fetch('/api/auth/full-logout', {
credentials: 'include'
});
const data = await response.json();
if (data.success && data.logoutUrl) {
setKeycloakLogoutUrl(data.logoutUrl);
console.log('Keycloak logout URL:', data.logoutUrl);
}
} catch (e) {
console.error('Failed to get Keycloak logout URL:', e);
}
}
// Clear cookies appropriately based on preserve SSO setting
const cookies = document.cookie.split(';');
// Get all cookies names
const cookieNames = cookies.map(cookie => cookie.split('=')[0].trim());
// Find chunked cookies
const chunkedCookies = cookieNames
.filter(name => /next-auth.*\.\d+$/.test(name));
// Define cookies to clear
let cookiesToClear = [];
if (preserveSso) {
// Only clear app-specific cookies if preserving SSO
cookiesToClear = [
'next-auth.session-token',
'next-auth.csrf-token',
'next-auth.callback-url',
'__Secure-next-auth.session-token',
'__Host-next-auth.csrf-token',
...chunkedCookies
];
} else {
// Clear ALL auth-related cookies for full logout
const authCookies = cookieNames.filter(name =>
name.includes('auth') ||
name.includes('KEYCLOAK') ||
name.includes('KC_') ||
name.includes('session')
);
cookiesToClear = [
...authCookies,
...chunkedCookies,
'JSESSIONID',
'KEYCLOAK_SESSION',
'KEYCLOAK_IDENTITY',
'KC_RESTART'
];
}
// Clear the cookies with all possible path and domain combinations
const hostname = window.location.hostname;
const baseDomain = hostname.split('.').slice(-2).join('.');
cookiesToClear.forEach(cookieName => {
// Try various path and domain combinations to ensure complete cleanup
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${hostname};`;
// Only try this for multi-part domains
if (hostname !== baseDomain) {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${baseDomain};`;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${hostname};`;
}
// Add secure and SameSite attributes
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=None; Secure;`;
});
// Clear session storage
try {
sessionStorage.clear();
console.log('Session storage cleared');
} catch (e) {
console.error('Error clearing session storage:', e);
console.error('Failed to clear sessionStorage:', e);
}
// Clear local storage items related to auth
// Clear auth-related localStorage items
try {
const authLocalStoragePrefixes = ['token', 'auth', 'session', 'keycloak', 'kc', 'oidc', 'user', 'meteor'];
localStorage.removeItem('userId');
localStorage.removeItem('userName');
localStorage.removeItem('userEmail');
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
const keyLower = key.toLowerCase();
if (authLocalStoragePrefixes.some(prefix => keyLower.includes(prefix))) {
console.log(`Clearing localStorage: ${key}`);
localStorage.removeItem(key);
}
}
}
console.log('Local storage auth items cleared');
// Rocket Chat items
localStorage.removeItem('Meteor.loginToken');
localStorage.removeItem('Meteor.userId');
} catch (e) {
console.error('Error clearing localStorage:', e);
console.error('Failed to clear localStorage items:', e);
}
// Double check for Keycloak specific cookies and chunked cookies
const cookies = document.cookie.split(';');
const cookieNames = cookies.map(cookie => cookie.split('=')[0].trim());
// Look for chunked cookies
const chunkedCookies = cookieNames.filter(name => /\.\d+$/.test(name));
const keycloakCookies = [
'KEYCLOAK_SESSION',
'KEYCLOAK_IDENTITY',
'KC_RESTART',
'rc_token',
'rc_uid',
'Meteor.loginToken',
'AUTH_SESSION_ID',
'AUTH_SESSION_ID_LEGACY',
...chunkedCookies
];
for (const cookieName of keycloakCookies) {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname}; SameSite=None; Secure;`;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
// Also try with root domain
const rootDomain = window.location.hostname.split('.').slice(-2).join('.');
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.${rootDomain}; SameSite=None; Secure;`;
}
// Additional paths that Keycloak might use
['/auth', '/realms'].forEach(path => {
keycloakCookies.forEach(cookieName => {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${window.location.hostname}; SameSite=None; Secure;`;
});
});
// Notify any parent windows/iframes
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'SESSION_CLEARED' }, '*');
// Notify parent window if we're in an iframe
if (window !== window.parent) {
try {
window.parent.postMessage({
type: 'AUTH_STATUS',
status: 'LOGGED_OUT',
preserveSso
}, '*');
} catch (e) {
console.error('Failed to send logout message to parent:', e);
}
} catch (e) {
console.error('Error notifying parent window:', e);
}
setSessionStatus('cleared');
setMessage(preserveSso
? 'You have been logged out of this application, but your SSO session is still active for other services.'
: 'You have been completely logged out of all services.'
);
} catch (error) {
console.error('Error during session cleanup:', error);
console.error('Error during logout cleanup:', error);
setSessionStatus('error');
setMessage('There was an error during the logout process.');
}
};
clearSessions();
checkAndClearSessions();
}, []);
return () => {
window.removeEventListener('message', handleMessage);
};
}, [preserveSso]);
return (
<div
className="min-h-screen flex items-center justify-center"
style={{
backgroundImage: "url('/signin.jpg')",
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}}
>
{/* Hidden iframe for direct Keycloak logout */}
<iframe
ref={iframeRef}
style={{ display: 'none' }}
title="keycloak-logout"
/>
<div className="w-full max-w-md p-8 bg-black/60 backdrop-blur-sm rounded-lg shadow-xl">
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 p-4">
<div className="w-full max-w-md p-8 space-y-6 bg-white rounded-lg shadow-md">
<div className="text-center">
<h2 className="text-3xl font-bold text-white mb-4">
You have been logged out
</h2>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{preserveSso ? 'Application Sign Out' : 'Complete Sign Out'}
</h1>
{sessionStatus === 'checking' && (
<p className="text-white/80 mb-4">
Verifying all sessions are terminated...
</p>
<p className="text-gray-600">Cleaning up your session...</p>
)}
{sessionStatus === 'cleared' && (
<p className="text-white/80 mb-4">
Your session has been completely terminated and all authentication data has been cleared.
</p>
<>
<p className="text-gray-600 mb-4">{message}</p>
{preserveSso ? (
<>
<p className="text-sm text-gray-500 mb-4">
You can continue using other applications without signing in again.
</p>
<div className="flex flex-col space-y-4">
<Link
href="/"
className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Return to Home Page
</Link>
<button
onClick={() => window.location.href = '/loggedout'}
className="inline-block px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors"
>
Sign Out Completely
</button>
</div>
</>
) : (
<>
<p className="text-sm text-gray-500 mb-4">
You will need to sign in again to access any protected services.
</p>
{keycloakLogoutUrl && (
<iframe
ref={iframeRef}
src={keycloakLogoutUrl}
style={{ width: '1px', height: '1px', position: 'absolute', top: '-100px', left: '-100px' }}
title="Keycloak Logout"
/>
)}
<Link
href="/api/auth/signin?fresh=true"
className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Sign In Again
</Link>
</>
)}
</>
)}
{sessionStatus === 'error' && (
<p className="text-white/80 mb-4">
Your session has been terminated, but there might be some residual session data.
For complete security, please close your browser.
</p>
<>
<p className="text-red-600 mb-4">{message}</p>
<Link
href="/"
className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Return to Home Page
</Link>
</>
)}
<div className="mt-6">
<Link
href="/signin?fresh=true"
className="inline-block px-8 py-3 bg-white text-gray-800 rounded hover:bg-gray-100 transition-colors mb-4"
>
Sign In Again
</Link>
<p className="text-white/60 text-sm mt-4">
Note: You'll need to enter your credentials when signing in again.
</p>
</div>
</div>
</div>
</div>

View File

@ -1,51 +1,56 @@
"use client";
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useSession } from 'next-auth/react';
export default function SilentRefresh() {
const { data: session, status } = useSession();
const [message, setMessage] = useState('Checking authentication...');
useEffect(() => {
// Notify parent window of authentication state
const notifyParent = () => {
// Notify parent window of authentication status
const notifyParent = (statusType: string) => {
try {
// Post message to parent window
if (window.parent && window.parent !== window) {
if (status === 'authenticated' && session) {
window.parent.postMessage({
type: 'SILENT_AUTH_SUCCESS',
session: {
authenticated: true,
userId: session.user.id,
username: session.user.username,
roles: session.user.role
}
}, '*');
setMessage('Authentication successful. You can close this window.');
} else if (status === 'unauthenticated') {
window.parent.postMessage({
type: 'SILENT_AUTH_FAILURE',
error: 'Not authenticated'
}, '*');
setMessage('Not authenticated. You may need to log in again.');
}
window.parent.postMessage({
type: 'AUTH_STATUS',
status: statusType,
timestamp: Date.now()
}, '*');
console.log(`Silent refresh: notified parent of ${statusType} status`);
}
} catch (e) {
console.error('Error notifying parent window:', e);
setMessage('Error communicating with parent window.');
} catch (error) {
console.error('Error notifying parent window:', error);
}
};
if (status !== 'loading') {
notifyParent();
// When session status changes, notify parent
if (status === 'authenticated' && session) {
// User is authenticated
notifyParent('AUTHENTICATED');
} else if (status === 'unauthenticated') {
// User is not authenticated
notifyParent('UNAUTHENTICATED');
}
// Set up automatic cleanup
const timeout = setTimeout(() => {
// Notify parent we're cleaning up (in case we're in loading state forever)
notifyParent('CLEANUP');
}, 10000); // 10 second timeout
return () => clearTimeout(timeout);
}, [session, status]);
// This page is meant to be loaded in an iframe, so keep it minimal
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif', color: '#666' }}>
{message}
<div className="p-4 text-center">
<h1 className="text-lg font-medium">Silent Authentication Check</h1>
<p className="text-sm text-gray-500 mt-2">
{status === 'loading' && 'Checking authentication status...'}
{status === 'authenticated' && 'You are authenticated.'}
{status === 'unauthenticated' && 'You are not authenticated.'}
</p>
</div>
);
}

View File

@ -12,18 +12,21 @@ export function SignOutHandler() {
try {
// Store the user ID before signout clears the session
const userId = session?.user?.id;
console.log('Starting comprehensive logout process');
console.log('Starting optimized logout process (preserving SSO)');
// First trigger server-side session cleanup
// First trigger server-side Redis cleanup only
if (userId) {
console.log('Triggering server-side session cleanup');
console.log('Triggering server-side Redis cleanup');
try {
const cleanupResponse = await fetch('/api/auth/session-cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ userId }),
body: JSON.stringify({
userId,
preserveSso: true
}),
credentials: 'include'
});
@ -31,13 +34,32 @@ export function SignOutHandler() {
console.log('Server cleanup result:', cleanupResult);
} catch (cleanupError) {
console.error('Error during server-side cleanup:', cleanupError);
// Continue with logout even if cleanup fails
}
}
// Then, attempt to sign out from NextAuth explicitly with force option
// Use NextAuth signOut but ONLY for our application cookies
// This preserves the Keycloak SSO session for other services
await signOut({ redirect: false });
// Clear ONLY application-specific cookies (not Keycloak SSO cookies)
const appCookies = [
'next-auth.session-token',
'next-auth.csrf-token',
'next-auth.callback-url',
'__Secure-next-auth.session-token',
'__Host-next-auth.csrf-token',
];
// Only clear chunked cookies for our app
const cookies = document.cookie.split(';');
const chunkedCookies = cookies
.map(cookie => cookie.split('=')[0].trim())
.filter(name => /next-auth.*\.\d+$/.test(name));
[...appCookies, ...chunkedCookies].forEach(cookieName => {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${window.location.hostname}; SameSite=None; Secure`;
});
// Clear Rocket Chat authentication tokens
try {
console.log('Clearing Rocket Chat tokens');
@ -48,139 +70,16 @@ export function SignOutHandler() {
// Remove localStorage items
localStorage.removeItem('Meteor.loginToken');
localStorage.removeItem('Meteor.userId');
// Try to send logout to Rocket Chat server
const rocketChatBaseUrl = process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0];
if (rocketChatBaseUrl) {
// This is a best-effort logout - we don't wait for it to complete
fetch(`${rocketChatBaseUrl}/api/v1/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}).catch(e => console.error('Failed to notify Rocket Chat server about logout:', e));
}
} catch (e) {
console.error('Error clearing Rocket Chat tokens:', e);
}
// Then clear all auth-related cookies to ensure we break any local sessions
clearAuthCookies();
// Get Keycloak logout URL with additional parameters to force session expiration
if (process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER) {
console.log('Preparing complete Keycloak logout');
// Create a proper Keycloak logout URL with all required parameters for front-channel logout
const keycloakBaseUrl = process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER;
const logoutEndpoint = `${keycloakBaseUrl}/protocol/openid-connect/logout`;
// Create form for POST logout (more reliable than GET)
const form = document.createElement('form');
form.method = 'POST';
form.action = logoutEndpoint;
// Add id_token_hint if available
if (session?.accessToken) {
const tokenInput = document.createElement('input');
tokenInput.type = 'hidden';
tokenInput.name = 'id_token_hint';
tokenInput.value = session.accessToken;
form.appendChild(tokenInput);
}
// Add client_id parameter - CRITICAL for proper logout
const clientIdInput = document.createElement('input');
clientIdInput.type = 'hidden';
clientIdInput.name = 'client_id';
clientIdInput.value = process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || 'lab';
form.appendChild(clientIdInput);
// Add post_logout_redirect_uri pointing to our logged out page
const redirectInput = document.createElement('input');
redirectInput.type = 'hidden';
redirectInput.name = 'post_logout_redirect_uri';
redirectInput.value = `${window.location.origin}/loggedout?forceLogout=true`;
form.appendChild(redirectInput);
// Add logout_hint=server to explicitly request server-side session cleanup
const logoutHintInput = document.createElement('input');
logoutHintInput.type = 'hidden';
logoutHintInput.name = 'logout_hint';
logoutHintInput.value = 'server';
form.appendChild(logoutHintInput);
// Add state parameter with random value to prevent CSRF
const stateInput = document.createElement('input');
stateInput.type = 'hidden';
stateInput.name = 'state';
stateInput.value = Math.random().toString(36).substring(2);
form.appendChild(stateInput);
// Set initiate_login_uri parameter to force login screen on next login
const initiateLoginInput = document.createElement('input');
initiateLoginInput.type = 'hidden';
initiateLoginInput.name = 'initiate_login_uri';
initiateLoginInput.value = `${window.location.origin}/signin?fresh=true`;
form.appendChild(initiateLoginInput);
// Add UI locales parameter
const uiLocalesInput = document.createElement('input');
uiLocalesInput.type = 'hidden';
uiLocalesInput.name = 'ui_locales';
uiLocalesInput.value = 'fr';
form.appendChild(uiLocalesInput);
// Notify iframe parents before logging out
try {
// Attempt to notify any iframes that might be using this authentication
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: 'LOGOUT_EVENT' }, '*');
}
} catch (e) {
console.error('Error notifying parent of logout:', e);
}
// Completely remove all Keycloak session cookies before logout
// This helps prevent automatic re-login
const keycloakCookies = [
'KEYCLOAK_SESSION',
'KEYCLOAK_IDENTITY',
'KEYCLOAK_REMEMBER_ME',
'KC_RESTART',
'KEYCLOAK_SESSION_LEGACY',
'KEYCLOAK_IDENTITY_LEGACY',
'AUTH_SESSION_ID',
'AUTH_SESSION_ID_LEGACY',
'JSESSIONID'
];
const domains = [
window.location.hostname,
`.${window.location.hostname}`,
window.location.hostname.split('.').slice(-2).join('.'),
`.${window.location.hostname.split('.').slice(-2).join('.')}`
];
keycloakCookies.forEach(cookieName => {
domains.forEach(domain => {
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=${domain}; SameSite=None; Secure`;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/realms/; domain=${domain}; SameSite=None; Secure`;
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/auth/; domain=${domain}; SameSite=None; Secure`;
});
});
// Append to body and submit
document.body.appendChild(form);
console.log('Submitting Keycloak logout form with server-side logout');
form.submit();
} else {
console.log('No Keycloak configuration found, performing simple redirect');
window.location.href = '/loggedout?forceLogout=true';
}
// Redirect to loggedout page
window.location.href = '/loggedout?preserveSso=true';
} catch (error) {
console.error('Error during logout:', error);
window.location.href = '/loggedout?forceLogout=true';
window.location.href = '/loggedout?preserveSso=true';
}
};
@ -196,7 +95,7 @@ export function SignOutHandler() {
<div className="w-full h-full flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold">Logging out...</h2>
<p className="text-gray-500 mt-2">Please wait while we sign you out completely.</p>
<p className="text-gray-500 mt-2">Please wait while we sign you out of this application.</p>
</div>
</div>
);

View File

@ -1,80 +1,104 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
// Maximum cookie size in bytes (even more conservative to prevent chunking issues)
const MAX_COOKIE_SIZE = 3500;
const config = {
matcher: [
/*
* Match all paths except for:
* 1. /api routes
* 2. /_next (Next.js internals)
* 3. /_static (inside /public)
* 4. all root files inside /public (e.g. /favicon.ico)
*/
'/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)',
],
};
// This middleware runs before any request
export function middleware(request: NextRequest) {
// Force NextAuth environment variables at runtime to prevent cookie chunking
process.env.NEXTAUTH_COOKIE_SIZE_LIMIT = MAX_COOKIE_SIZE.toString();
export default async function middleware(req: NextRequest) {
const url = req.nextUrl;
const response = NextResponse.next();
// Maximum size to prevent cookie chunking
const MAX_COOKIE_SIZE = 3500; // conservative limit in bytes
// Disable cookie callbacks which can increase cookie size
process.env.NEXTAUTH_CALLBACK = 'false';
process.env.NEXTAUTH_SESSION_STORE_SESSION_TOKEN = 'false';
process.env.NEXTAUTH_JWT_STORE_RAW_TOKEN = 'false';
// Check if this is a logout-related request
const url = request.nextUrl.pathname;
const isLogoutPath = url.includes('/signout') || url.includes('/loggedout');
const isSigninPath = url.includes('/signin');
const hasForceLogoutParam = request.nextUrl.searchParams.has('forceLogout');
if (isLogoutPath || hasForceLogoutParam) {
// On logout pages, we want to ensure cookies are cleaned up
const response = NextResponse.next();
// Function to set all required nextAuth environment variables
const setNextAuthEnvVars = () => {
// Disable callbacks that could increase cookie size
process.env.NEXTAUTH_DISABLE_CALLBACK = 'true';
process.env.NEXTAUTH_DISABLE_JWT_CALLBACK = 'true';
process.env.NEXTAUTH_COOKIE_SIZE_LIMIT = String(MAX_COOKIE_SIZE);
process.env.NEXTAUTH_COOKIES_CHUNKING = 'true';
process.env.NEXTAUTH_COOKIES_CHUNKING_SIZE = String(MAX_COOKIE_SIZE);
process.env.NEXTAUTH_COOKIES_SECURE = 'true';
process.env.NEXTAUTH_COOKIES_SAMESITE = 'none';
};
// Set environment variables for all routes
setNextAuthEnvVars();
// Special handling for loggedout page to clean up cookies
if (url.pathname === '/loggedout') {
// Check if we're preserving SSO or doing a full logout
const preserveSso = url.searchParams.get('preserveSso') === 'true';
// List of authentication-related cookies to clear on logout
const authCookies = [
'next-auth.session-token',
'next-auth.callback-url',
'next-auth.csrf-token',
'next-auth.pkce.code-verifier',
'__Secure-next-auth.session-token',
'__Host-next-auth.csrf-token'
];
console.log(`Middleware detected logout (preserveSso: ${preserveSso})`);
// Clear each cookie with appropriate settings
authCookies.forEach(cookieName => {
// Try to detect and clear chunked cookies too
for (let i = 0; i < 5; i++) {
const chunkName = i > 0 ? `${cookieName}.${i}` : cookieName;
// Clear with domain
if (process.env.NEXTAUTH_COOKIE_DOMAIN) {
response.cookies.delete({
name: chunkName,
domain: process.env.NEXTAUTH_COOKIE_DOMAIN,
path: '/'
});
}
// Also clear without domain
response.cookies.delete({
name: chunkName,
path: '/'
});
}
});
return response;
if (preserveSso) {
// Only clean up NextAuth cookies but preserve Keycloak SSO cookies
const nextAuthCookies = [
'next-auth.session-token',
'next-auth.csrf-token',
'next-auth.callback-url',
'__Secure-next-auth.session-token',
'__Host-next-auth.csrf-token'
];
nextAuthCookies.forEach(name => {
response.cookies.delete(name);
});
// Also delete any chunked cookies
const cookieNames = Object.keys(req.cookies.getAll());
const chunkedCookies = cookieNames.filter(name => /next-auth.*\.\d+$/.test(name));
chunkedCookies.forEach(name => {
response.cookies.delete(name);
});
} else {
// Full logout - clear all auth-related cookies
const authCookies = [
'next-auth.session-token',
'next-auth.csrf-token',
'next-auth.callback-url',
'__Secure-next-auth.session-token',
'__Host-next-auth.csrf-token',
'KEYCLOAK_SESSION',
'KEYCLOAK_IDENTITY',
'KC_RESTART',
'JSESSIONID'
];
authCookies.forEach(name => {
response.cookies.delete(name);
});
// Also delete any chunked cookies
const cookieNames = Object.keys(req.cookies.getAll());
const chunkedCookies = cookieNames.filter(name =>
/next-auth.*\.\d+$/.test(name) ||
/KEYCLOAK.*/.test(name) ||
/KC_.*/.test(name)
);
chunkedCookies.forEach(name => {
response.cookies.delete(name);
});
}
}
// For the signin page with fresh=true param, pass a header indicating fresh login
if (isSigninPath && request.nextUrl.searchParams.get('fresh') === 'true') {
const response = NextResponse.next();
response.headers.set('X-Force-Fresh-Login', 'true');
return response;
// For sign-in page, add header if fresh login is requested
if (url.pathname === '/api/auth/signin' && url.searchParams.get('fresh') === 'true') {
response.headers.set('X-Auth-Fresh-Login', 'true');
}
// Continue with the request for all other paths
return NextResponse.next();
return response;
}
// Configure the middleware to run on specific paths
export const config = {
matcher: [
// Apply to all routes except static files
'/((?!_next/static|_next/image|favicon.ico|public).*)',
],
};
export { config };