auth flow
This commit is contained in:
parent
ed199d7a00
commit
fa05961404
15
.env
15
.env
@ -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
|
||||
@ -1 +0,0 @@
|
||||
NEXT_PUBLIC_IFRAME_DESIGN_URL=https://design.slm-lab.net
|
||||
35
app/api/auth/full-logout/route.ts
Normal file
35
app/api/auth/full-logout/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
164
middleware.ts
164
middleware.ts
@ -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 };
|
||||
Loading…
Reference in New Issue
Block a user