Missions/Equipe

This commit is contained in:
alma 2026-01-10 12:19:12 +01:00
parent 97f5570a66
commit ab1bc1faae
3 changed files with 8 additions and 386 deletions

View File

@ -1,152 +0,0 @@
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 }
);
}
}

View File

@ -8,13 +8,9 @@ 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;
}
// Known service domains for automatic SSO detection
// Known service domains for postMessage authentication
const SSO_SERVICE_DOMAINS: Record<string, string> = {
'espace.slm-lab.net': 'nextcloud',
'parole.slm-lab.net': 'rocketchat',
@ -44,54 +40,17 @@ export function ResponsiveIframe({
className = '',
allow,
style,
enableSso = true,
serviceName: propServiceName,
}: ResponsiveIframeProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const { data: session } = useSession();
const [isRefreshing, setIsRefreshing] = useState(false);
const [iframeSrc, setIframeSrc] = useState<string>('');
const [hasTriedRefresh, setHasTriedRefresh] = useState(false);
const [ssoStatus, setSsoStatus] = useState<'pending' | 'success' | 'fallback' | 'error'>('pending');
// Detect service name from URL if not provided
const serviceName = propServiceName || detectServiceFromUrl(src);
// Detect service name from URL for postMessage auth
const serviceName = 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
// Refresh NextAuth session before loading iframe
useEffect(() => {
// If no src, nothing to do
if (!src) {
@ -153,10 +112,7 @@ export function ResponsiveIframe({
if (response.ok) {
console.log('Session refreshed before loading iframe');
// Try to get SSO-authenticated URL
const authenticatedUrl = await getSsoAuthenticatedUrl(src);
setIframeSrc(authenticatedUrl);
setIframeSrc(src);
} else {
const errorData = await response.json().catch(() => ({}));
@ -168,22 +124,19 @@ export function ResponsiveIframe({
}
console.warn('Failed to refresh session, loading iframe anyway (may require login)');
// Still try SSO, then fall back to original URL
const authenticatedUrl = await getSsoAuthenticatedUrl(src);
setIframeSrc(authenticatedUrl);
setIframeSrc(src);
}
} catch (error) {
console.error('Error refreshing session:', error);
// On error, still try to load iframe with original URL
setIframeSrc(src);
setSsoStatus('error');
} finally {
setIsRefreshing(false);
}
};
refreshSession();
}, [session, src, hasTriedRefresh, iframeSrc, getSsoAuthenticatedUrl]);
}, [session, src, hasTriedRefresh, iframeSrc]);
// Listen for messages from iframe applications (logout, auth requests)
useEffect(() => {
@ -333,17 +286,7 @@ export function ResponsiveIframe({
<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 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>
<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>
)}
<p className="text-gray-600">Chargement...</p>
</div>
</div>
)}
@ -372,7 +315,6 @@ export function ResponsiveIframe({
<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>
)}

View File

@ -1,168 +0,0 @@
/**
* 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;
}
});
}