From 97f5570a66cd3cdaebb1c210f13228551c8d1699 Mon Sep 17 00:00:00 2001 From: alma Date: Sat, 10 Jan 2026 12:12:30 +0100 Subject: [PATCH] Missions/Equipe --- app/api/auth/service-sso/route.ts | 152 +++++++++++++++++++++ app/components/responsive-iframe.tsx | 189 ++++++++++++++++++++++++--- lib/services/iframe-auth.ts | 168 ++++++++++++++++++++++++ 3 files changed, 493 insertions(+), 16 deletions(-) create mode 100644 app/api/auth/service-sso/route.ts create mode 100644 lib/services/iframe-auth.ts diff --git a/app/api/auth/service-sso/route.ts b/app/api/auth/service-sso/route.ts new file mode 100644 index 0000000..2efd2c6 --- /dev/null +++ b/app/api/auth/service-sso/route.ts @@ -0,0 +1,152 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '../options'; +import { getServiceConfigByUrl, generateAuthenticatedUrl, serviceAuthConfigs } from '@/lib/services/iframe-auth'; + +/** + * API endpoint to generate authenticated URLs for iframe services + * + * This endpoint exchanges the user's Keycloak access token for a + * service-specific authentication URL that can be used in iframes. + * + * Usage: + * GET /api/auth/service-sso?service=nextcloud&path=/apps/files + * GET /api/auth/service-sso?url=https://espace.slm-lab.net/apps/mail + */ +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.accessToken) { + return NextResponse.json( + { error: 'Unauthorized', message: 'No active session' }, + { status: 401 } + ); + } + + const searchParams = request.nextUrl.searchParams; + const serviceName = searchParams.get('service'); + const targetUrl = searchParams.get('url'); + const targetPath = searchParams.get('path') || '/'; + + const keycloakIssuer = process.env.KEYCLOAK_ISSUER; + if (!keycloakIssuer) { + return NextResponse.json( + { error: 'Configuration error', message: 'Keycloak issuer not configured' }, + { status: 500 } + ); + } + + let authenticatedUrl: string; + + if (serviceName) { + // Get auth URL by service name + const config = serviceAuthConfigs[serviceName.toLowerCase()]; + if (!config) { + return NextResponse.json( + { + error: 'Unknown service', + message: `Service '${serviceName}' not configured`, + availableServices: Object.keys(serviceAuthConfigs) + }, + { status: 400 } + ); + } + + authenticatedUrl = generateAuthenticatedUrl( + serviceName, + session.accessToken, + targetPath, + keycloakIssuer + ); + } else if (targetUrl) { + // Get auth URL by target URL + const config = getServiceConfigByUrl(targetUrl); + if (!config) { + // Service not configured, return original URL + return NextResponse.json({ + success: true, + url: targetUrl, + authType: 'none', + message: 'Service not configured for SSO, using original URL' + }); + } + + // Extract path from target URL + const urlObj = new URL(targetUrl); + authenticatedUrl = generateAuthenticatedUrl( + config.name.toLowerCase(), + session.accessToken, + urlObj.pathname + urlObj.search, + keycloakIssuer + ); + } else { + return NextResponse.json( + { + error: 'Missing parameter', + message: 'Provide either "service" or "url" parameter', + availableServices: Object.keys(serviceAuthConfigs) + }, + { status: 400 } + ); + } + + return NextResponse.json({ + success: true, + url: authenticatedUrl, + expiresIn: 300, // URL is valid for 5 minutes (token expiry) + }); + } catch (error) { + console.error('Error generating service SSO URL:', error); + return NextResponse.json( + { error: 'Internal server error', message: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} + +/** + * POST endpoint for RocketChat iframe authentication + * This generates a login token for RocketChat's iframe auth + */ +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + + if (!session?.accessToken || !session?.user) { + return NextResponse.json( + { error: 'Unauthorized', message: 'No active session' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { service } = body; + + if (service === 'rocketchat') { + // For RocketChat, we need to use their OAuth login or iframe auth + // This returns the token that can be used with postMessage + return NextResponse.json({ + success: true, + authData: { + token: session.accessToken, + userId: session.user.id, + email: session.user.email, + username: session.user.username, + }, + message: 'Use postMessage to send this to RocketChat iframe' + }); + } + + return NextResponse.json( + { error: 'Unsupported service for POST', message: 'Use GET for most services' }, + { status: 400 } + ); + } catch (error) { + console.error('Error in service SSO POST:', error); + return NextResponse.json( + { error: 'Internal server error', message: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} diff --git a/app/components/responsive-iframe.tsx b/app/components/responsive-iframe.tsx index 1ecc6f7..fd0a5b1 100644 --- a/app/components/responsive-iframe.tsx +++ b/app/components/responsive-iframe.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { useSession } from 'next-auth/react'; interface ResponsiveIframeProps { @@ -8,16 +8,90 @@ interface ResponsiveIframeProps { className?: string; allow?: string; style?: React.CSSProperties; + /** Enable SSO token forwarding for this iframe */ + enableSso?: boolean; + /** Service name for SSO (e.g., 'nextcloud', 'rocketchat') */ + serviceName?: string; } -export function ResponsiveIframe({ src, className = '', allow, style }: ResponsiveIframeProps) { +// Known service domains for automatic SSO detection +const SSO_SERVICE_DOMAINS: Record = { + 'espace.slm-lab.net': 'nextcloud', + 'parole.slm-lab.net': 'rocketchat', + 'apprendre.slm-lab.net': 'moodle', + 'artlab.slm-lab.net': 'penpot', + 'alma.slm-lab.net': 'openwebui', + 'agilite.slm-lab.net': 'leantime', + 'chapitre.slm-lab.net': 'bookstack', + 'vision.slm-lab.net': 'jitsi', + 'lemessage.slm-lab.net': 'listmonk', +}; + +/** + * Detect service name from URL + */ +function detectServiceFromUrl(url: string): string | undefined { + try { + const urlObj = new URL(url); + return SSO_SERVICE_DOMAINS[urlObj.hostname]; + } catch { + return undefined; + } +} + +export function ResponsiveIframe({ + src, + className = '', + allow, + style, + enableSso = true, + serviceName: propServiceName, +}: ResponsiveIframeProps) { const iframeRef = useRef(null); const { data: session } = useSession(); const [isRefreshing, setIsRefreshing] = useState(false); const [iframeSrc, setIframeSrc] = useState(''); const [hasTriedRefresh, setHasTriedRefresh] = useState(false); + const [ssoStatus, setSsoStatus] = useState<'pending' | 'success' | 'fallback' | 'error'>('pending'); - // Refresh NextAuth session (which will also refresh Keycloak tokens) before loading iframe + // Detect service name from URL if not provided + const serviceName = propServiceName || detectServiceFromUrl(src); + + /** + * Attempt to get an SSO-authenticated URL from our API + */ + const getSsoAuthenticatedUrl = useCallback(async (originalUrl: string): Promise => { + if (!enableSso || !session?.accessToken) { + return originalUrl; + } + + try { + // Try to get an authenticated URL from our SSO API + const response = await fetch(`/api/auth/service-sso?url=${encodeURIComponent(originalUrl)}`, { + method: 'GET', + credentials: 'include', + }); + + if (response.ok) { + const data = await response.json(); + if (data.success && data.url) { + console.log(`[SSO] Got authenticated URL for ${serviceName || 'unknown service'}`); + setSsoStatus('success'); + return data.url; + } + } + + console.warn('[SSO] Failed to get authenticated URL, using original'); + setSsoStatus('fallback'); + return originalUrl; + } catch (error) { + console.error('[SSO] Error getting authenticated URL:', error); + setSsoStatus('error'); + return originalUrl; + } + }, [enableSso, session?.accessToken, serviceName]); + + // Refresh NextAuth session and get SSO URL before loading iframe useEffect(() => { // If no src, nothing to do if (!src) { @@ -79,7 +153,10 @@ export function ResponsiveIframe({ src, className = '', allow, style }: Responsi if (response.ok) { console.log('Session refreshed before loading iframe'); - setIframeSrc(src); + + // Try to get SSO-authenticated URL + const authenticatedUrl = await getSsoAuthenticatedUrl(src); + setIframeSrc(authenticatedUrl); } else { const errorData = await response.json().catch(() => ({})); @@ -91,29 +168,38 @@ export function ResponsiveIframe({ src, className = '', allow, style }: Responsi } console.warn('Failed to refresh session, loading iframe anyway (may require login)'); - // Still load iframe, but it may prompt for login - setIframeSrc(src); + // Still try SSO, then fall back to original URL + const authenticatedUrl = await getSsoAuthenticatedUrl(src); + setIframeSrc(authenticatedUrl); } } catch (error) { console.error('Error refreshing session:', error); - // On error, still try to load iframe + // On error, still try to load iframe with original URL setIframeSrc(src); + setSsoStatus('error'); } finally { setIsRefreshing(false); } }; refreshSession(); - }, [session, src, hasTriedRefresh, iframeSrc]); + }, [session, src, hasTriedRefresh, iframeSrc, getSsoAuthenticatedUrl]); - // Listen for logout messages from iframe applications + // Listen for messages from iframe applications (logout, auth requests) useEffect(() => { - const handleMessage = (event: MessageEvent) => { + const handleMessage = async (event: MessageEvent) => { // Security: Only accept messages from known iframe origins - // In production, you should validate event.origin against your iframe URLs + const trustedOrigins = Object.keys(SSO_SERVICE_DOMAINS).map(domain => `https://${domain}`); + const isFromTrustedOrigin = trustedOrigins.some(origin => event.origin.includes(origin.replace('https://', ''))); - // Check if message is a logout request from iframe + if (!isFromTrustedOrigin && event.origin !== window.location.origin) { + // Log but don't block - some services may have different origins + console.debug('[Iframe] Message from unknown origin:', event.origin); + } + + // Check if message is a data object if (event.data && typeof event.data === 'object') { + // Handle logout request from iframe if (event.data.type === 'KEYCLOAK_LOGOUT' || event.data.type === 'LOGOUT') { console.log('Received logout request from iframe, triggering dashboard logout'); @@ -143,6 +229,32 @@ export function ResponsiveIframe({ src, className = '', allow, style }: Responsi window.location.replace('/signin?logout=true'); } } + + // Handle RocketChat auth request + if (event.data.type === 'rocketchat.getAuthToken' || event.data.event === 'call-token-auth') { + console.log('[RocketChat] Auth token requested by iframe'); + if (session?.accessToken && iframeRef.current?.contentWindow) { + // Send auth token to RocketChat + iframeRef.current.contentWindow.postMessage({ + event: 'login-with-token', + loginToken: session.accessToken, + }, event.origin); + } + } + + // Handle generic SSO auth request from any iframe + if (event.data.type === 'SSO_AUTH_REQUEST') { + console.log('[SSO] Auth request from iframe:', event.origin); + if (session?.accessToken && iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.postMessage({ + type: 'SSO_AUTH_RESPONSE', + accessToken: session.accessToken, + userId: session.user?.id, + email: session.user?.email, + username: session.user?.username, + }, event.origin); + } + } } }; @@ -194,13 +306,44 @@ export function ResponsiveIframe({ src, className = '', allow, style }: Responsi }; }, [iframeSrc]); + // Send auth token to RocketChat iframe after it loads + const handleIframeLoad = useCallback(() => { + if (!iframeRef.current || !session?.accessToken) return; + + const iframe = iframeRef.current; + const iframeUrl = iframe.src; + + // Check if this is RocketChat and send auth token + if (serviceName === 'rocketchat' && iframe.contentWindow) { + try { + console.log('[RocketChat] Sending auth token to iframe'); + iframe.contentWindow.postMessage({ + externalCommand: 'login-with-token', + token: session.accessToken, + }, new URL(iframeUrl).origin); + } catch (error) { + console.warn('[RocketChat] Could not send auth token:', error); + } + } + }, [session?.accessToken, serviceName]); + return ( <> {isRefreshing && (
-
+
-

Actualisation de la session...

+

+ {ssoStatus === 'pending' && 'Connexion SSO en cours...'} + {ssoStatus === 'success' && 'Authentification réussie...'} + {ssoStatus === 'fallback' && 'Chargement...'} + {ssoStatus === 'error' && 'Chargement (connexion manuelle peut être requise)...'} +

+ {serviceName && ( +

+ Service: {serviceName} +

+ )}
)} @@ -208,17 +351,31 @@ export function ResponsiveIframe({ src, className = '', allow, style }: Responsi