Missions/Equipe
This commit is contained in:
parent
205a584f88
commit
97f5570a66
152
app/api/auth/service-sso/route.ts
Normal file
152
app/api/auth/service-sso/route.ts
Normal file
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
interface ResponsiveIframeProps {
|
interface ResponsiveIframeProps {
|
||||||
@ -8,16 +8,90 @@ interface ResponsiveIframeProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
allow?: string;
|
allow?: string;
|
||||||
style?: React.CSSProperties;
|
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<string, string> = {
|
||||||
|
'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<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [iframeSrc, setIframeSrc] = useState<string>('');
|
const [iframeSrc, setIframeSrc] = useState<string>('');
|
||||||
const [hasTriedRefresh, setHasTriedRefresh] = useState(false);
|
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<string> => {
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
// If no src, nothing to do
|
// If no src, nothing to do
|
||||||
if (!src) {
|
if (!src) {
|
||||||
@ -79,7 +153,10 @@ export function ResponsiveIframe({ src, className = '', allow, style }: Responsi
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
console.log('Session refreshed before loading iframe');
|
console.log('Session refreshed before loading iframe');
|
||||||
setIframeSrc(src);
|
|
||||||
|
// Try to get SSO-authenticated URL
|
||||||
|
const authenticatedUrl = await getSsoAuthenticatedUrl(src);
|
||||||
|
setIframeSrc(authenticatedUrl);
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
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)');
|
console.warn('Failed to refresh session, loading iframe anyway (may require login)');
|
||||||
// Still load iframe, but it may prompt for login
|
// Still try SSO, then fall back to original URL
|
||||||
setIframeSrc(src);
|
const authenticatedUrl = await getSsoAuthenticatedUrl(src);
|
||||||
|
setIframeSrc(authenticatedUrl);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing session:', 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);
|
setIframeSrc(src);
|
||||||
|
setSsoStatus('error');
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
refreshSession();
|
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(() => {
|
useEffect(() => {
|
||||||
const handleMessage = (event: MessageEvent) => {
|
const handleMessage = async (event: MessageEvent) => {
|
||||||
// Security: Only accept messages from known iframe origins
|
// 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') {
|
if (event.data && typeof event.data === 'object') {
|
||||||
|
// Handle logout request from iframe
|
||||||
if (event.data.type === 'KEYCLOAK_LOGOUT' || event.data.type === 'LOGOUT') {
|
if (event.data.type === 'KEYCLOAK_LOGOUT' || event.data.type === 'LOGOUT') {
|
||||||
console.log('Received logout request from iframe, triggering dashboard 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');
|
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]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{isRefreshing && (
|
{isRefreshing && (
|
||||||
<div className="flex items-center justify-center w-full h-full absolute bg-black/50 z-10">
|
<div className="flex items-center justify-center w-full h-full absolute bg-black/50 z-10">
|
||||||
<div className="text-center bg-white p-4 rounded-lg">
|
<div className="text-center bg-white p-4 rounded-lg shadow-xl">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
<p className="text-gray-600">Actualisation de la session...</p>
|
<p className="text-gray-600">
|
||||||
|
{ssoStatus === 'pending' && 'Connexion SSO en cours...'}
|
||||||
|
{ssoStatus === 'success' && 'Authentification réussie...'}
|
||||||
|
{ssoStatus === 'fallback' && 'Chargement...'}
|
||||||
|
{ssoStatus === 'error' && 'Chargement (connexion manuelle peut être requise)...'}
|
||||||
|
</p>
|
||||||
|
{serviceName && (
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
Service: {serviceName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -208,17 +351,31 @@ export function ResponsiveIframe({ src, className = '', allow, style }: Responsi
|
|||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
id="myFrame"
|
id="myFrame"
|
||||||
src={iframeSrc || src}
|
src={iframeSrc || ''}
|
||||||
className={`w-full border-none ${className}`}
|
className={`w-full border-none ${className}`}
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: iframeSrc ? 'block' : 'none',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
...style
|
...style
|
||||||
}}
|
}}
|
||||||
allow={allow}
|
allow={allow}
|
||||||
allowFullScreen
|
allowFullScreen
|
||||||
|
onLoad={handleIframeLoad}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Show placeholder while waiting for session */}
|
||||||
|
{!iframeSrc && !isRefreshing && (
|
||||||
|
<div className="flex items-center justify-center w-full h-full bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 w-32 bg-gray-700 rounded mx-auto mb-4"></div>
|
||||||
|
<div className="h-4 w-48 bg-gray-700 rounded mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 mt-4">En attente de la session...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
168
lib/services/iframe-auth.ts
Normal file
168
lib/services/iframe-auth.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Iframe Authentication Service
|
||||||
|
*
|
||||||
|
* Handles SSO token forwarding for embedded iframe services.
|
||||||
|
* Since browsers block third-party cookies, we need to pass authentication
|
||||||
|
* tokens directly to iframe services.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ServiceAuthConfig {
|
||||||
|
name: string;
|
||||||
|
baseUrl: string;
|
||||||
|
authType: 'oidc-redirect' | 'bearer-token' | 'custom-api' | 'iframe-auth' | 'none';
|
||||||
|
// OIDC redirect: Redirect to Keycloak with service's client_id
|
||||||
|
// bearer-token: Pass access_token as URL param or header
|
||||||
|
// custom-api: Service has custom auth endpoint
|
||||||
|
// iframe-auth: Service supports postMessage authentication
|
||||||
|
// none: No auth needed or service handles it internally
|
||||||
|
clientId?: string;
|
||||||
|
authEndpoint?: string;
|
||||||
|
tokenParam?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service configurations - customize based on your Keycloak client setup
|
||||||
|
export const serviceAuthConfigs: Record<string, ServiceAuthConfig> = {
|
||||||
|
// NextCloud - supports OIDC and bearer token
|
||||||
|
nextcloud: {
|
||||||
|
name: 'NextCloud',
|
||||||
|
baseUrl: process.env.NEXT_PUBLIC_IFRAME_DRIVE_URL?.split('/apps')[0] || 'https://espace.slm-lab.net',
|
||||||
|
authType: 'oidc-redirect',
|
||||||
|
clientId: 'nextcloud', // Your Keycloak client ID for NextCloud
|
||||||
|
authEndpoint: '/apps/user_oidc/login',
|
||||||
|
},
|
||||||
|
|
||||||
|
// RocketChat - supports iframe authentication
|
||||||
|
rocketchat: {
|
||||||
|
name: 'RocketChat',
|
||||||
|
baseUrl: process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL?.split('/channel')[0] || 'https://parole.slm-lab.net',
|
||||||
|
authType: 'iframe-auth',
|
||||||
|
authEndpoint: '/api/v1/login',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Moodle - supports OIDC
|
||||||
|
moodle: {
|
||||||
|
name: 'Moodle',
|
||||||
|
baseUrl: process.env.NEXT_PUBLIC_IFRAME_LEARN_URL || 'https://apprendre.slm-lab.net',
|
||||||
|
authType: 'oidc-redirect',
|
||||||
|
clientId: 'moodle',
|
||||||
|
authEndpoint: '/auth/oidc/',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Penpot - supports OIDC
|
||||||
|
penpot: {
|
||||||
|
name: 'Penpot',
|
||||||
|
baseUrl: process.env.NEXT_PUBLIC_IFRAME_ARTLAB_URL || 'https://artlab.slm-lab.net',
|
||||||
|
authType: 'oidc-redirect',
|
||||||
|
clientId: 'penpot',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Open-WebUI - supports Bearer token
|
||||||
|
openwebui: {
|
||||||
|
name: 'Open-WebUI',
|
||||||
|
baseUrl: process.env.NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL || 'https://alma.slm-lab.net',
|
||||||
|
authType: 'bearer-token',
|
||||||
|
tokenParam: 'token', // or use Authorization header
|
||||||
|
},
|
||||||
|
|
||||||
|
// ListMonk - may need custom auth
|
||||||
|
listmonk: {
|
||||||
|
name: 'ListMonk',
|
||||||
|
baseUrl: process.env.NEXT_PUBLIC_IFRAME_THEMESSAGE_URL || 'https://lemessage.slm-lab.net',
|
||||||
|
authType: 'none', // ListMonk admin typically uses basic auth
|
||||||
|
},
|
||||||
|
|
||||||
|
// Leantime - already has OIDC login endpoint
|
||||||
|
leantime: {
|
||||||
|
name: 'Leantime',
|
||||||
|
baseUrl: 'https://agilite.slm-lab.net',
|
||||||
|
authType: 'oidc-redirect',
|
||||||
|
clientId: 'leantime',
|
||||||
|
authEndpoint: '/oidc/login',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Jitsi - typically uses JWT
|
||||||
|
jitsi: {
|
||||||
|
name: 'Jitsi',
|
||||||
|
baseUrl: process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL || 'https://vision.slm-lab.net',
|
||||||
|
authType: 'bearer-token',
|
||||||
|
tokenParam: 'jwt',
|
||||||
|
},
|
||||||
|
|
||||||
|
// BookStack - supports OIDC
|
||||||
|
bookstack: {
|
||||||
|
name: 'BookStack',
|
||||||
|
baseUrl: process.env.NEXT_PUBLIC_IFRAME_CHAPTER_URL || 'https://chapitre.slm-lab.net',
|
||||||
|
authType: 'oidc-redirect',
|
||||||
|
clientId: 'bookstack',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an authenticated URL for a service
|
||||||
|
* This builds the appropriate auth URL based on the service type
|
||||||
|
*/
|
||||||
|
export function generateAuthenticatedUrl(
|
||||||
|
serviceName: string,
|
||||||
|
accessToken: string,
|
||||||
|
targetPath: string = '/',
|
||||||
|
keycloakIssuer: string
|
||||||
|
): string {
|
||||||
|
const config = serviceAuthConfigs[serviceName.toLowerCase()];
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
console.warn(`No auth config found for service: ${serviceName}`);
|
||||||
|
return targetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (config.authType) {
|
||||||
|
case 'oidc-redirect': {
|
||||||
|
// Build Keycloak authorization URL that will redirect to the service
|
||||||
|
const authUrl = new URL(`${keycloakIssuer}/protocol/openid-connect/auth`);
|
||||||
|
authUrl.searchParams.set('client_id', config.clientId || serviceName);
|
||||||
|
authUrl.searchParams.set('redirect_uri', `${config.baseUrl}${config.authEndpoint || '/'}${targetPath}`);
|
||||||
|
authUrl.searchParams.set('response_type', 'code');
|
||||||
|
authUrl.searchParams.set('scope', 'openid profile email');
|
||||||
|
// Use login_hint to pre-fill username if available
|
||||||
|
// Use prompt=none to attempt silent auth (won't show login page)
|
||||||
|
authUrl.searchParams.set('prompt', 'none');
|
||||||
|
return authUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'bearer-token': {
|
||||||
|
// Add token as URL parameter
|
||||||
|
const url = new URL(`${config.baseUrl}${targetPath}`);
|
||||||
|
if (config.tokenParam) {
|
||||||
|
url.searchParams.set(config.tokenParam, accessToken);
|
||||||
|
}
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'iframe-auth': {
|
||||||
|
// For iframe auth, we'll handle it via postMessage
|
||||||
|
// Return base URL, authentication happens via JS
|
||||||
|
return `${config.baseUrl}${targetPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'custom-api':
|
||||||
|
case 'none':
|
||||||
|
default:
|
||||||
|
return `${config.baseUrl}${targetPath}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the auth configuration for a service based on its URL
|
||||||
|
*/
|
||||||
|
export function getServiceConfigByUrl(url: string): ServiceAuthConfig | undefined {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const hostname = urlObj.hostname;
|
||||||
|
|
||||||
|
return Object.values(serviceAuthConfigs).find(config => {
|
||||||
|
try {
|
||||||
|
const configUrl = new URL(config.baseUrl);
|
||||||
|
return configUrl.hostname === hostname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user