From fa05961404bba2f2ac5f76b4c75387e2beaa3e15 Mon Sep 17 00:00:00 2001 From: alma Date: Fri, 2 May 2025 12:48:01 +0200 Subject: [PATCH] auth flow --- .env | 15 +- .env.local | 1 - app/api/auth/full-logout/route.ts | 35 +++ app/api/auth/session-cleanup/route.ts | 103 ++++--- app/components/responsive-iframe.tsx | 249 +++++++++-------- app/loggedout/page.tsx | 377 ++++++++++++++------------ app/silent-refresh/page.tsx | 71 ++--- components/auth/signout-handler.tsx | 167 +++--------- middleware.ts | 164 ++++++----- 9 files changed, 628 insertions(+), 554 deletions(-) delete mode 100644 .env.local create mode 100644 app/api/auth/full-logout/route.ts diff --git a/.env b/.env index 8c7818f9..935d1961 100644 --- a/.env +++ b/.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 \ No newline at end of file diff --git a/.env.local b/.env.local deleted file mode 100644 index a46f0db2..00000000 --- a/.env.local +++ /dev/null @@ -1 +0,0 @@ -NEXT_PUBLIC_IFRAME_DESIGN_URL=https://design.slm-lab.net diff --git a/app/api/auth/full-logout/route.ts b/app/api/auth/full-logout/route.ts new file mode 100644 index 00000000..ab1de813 --- /dev/null +++ b/app/api/auth/full-logout/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/api/auth/session-cleanup/route.ts b/app/api/auth/session-cleanup/route.ts index abbf1f69..46af4a3f 100644 --- a/app/api/auth/session-cleanup/route.ts +++ b/app/api/auth/session-cleanup/route.ts @@ -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); } } \ No newline at end of file diff --git a/app/components/responsive-iframe.tsx b/app/components/responsive-iframe.tsx index da1ee02f..42d6476b 100644 --- a/app/components/responsive-iframe.tsx +++ b/app/components/responsive-iframe.tsx @@ -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(0); + const [loaded, setLoaded] = useState(false); + const [authError, setAuthError] = useState(false); const iframeRef = useRef(null); + const silentAuthRef = useRef(null); + const silentAuthTimerRef = useRef(null); const { data: session } = useSession(); - const [authError, setAuthError] = useState(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 && ( -
- Authentication error: {authError}. The service might not work correctly. -
- )} -