Skip to main content
Glama
sso-service.ts14 kB
import passport from 'passport'; import { Strategy as SamlStrategy } from 'passport-saml'; import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; import { config } from '../config/config'; import { logger } from '../utils/logger'; import { JWTService } from './jwt-service'; import { redis } from '../database/redis'; import crypto from 'crypto'; export interface SAMLUser { nameID: string; email: string; firstName?: string; lastName?: string; displayName?: string; groups?: string[]; attributes?: Record<string, any>; } export interface SSOSession { userId: string; provider: 'saml' | 'oidc'; nameID: string; sessionIndex?: string; createdAt: string; expiresAt: string; } export class SSOService { private jwtService: JWTService; private initialized: boolean = false; constructor(jwtService: JWTService) { this.jwtService = jwtService; } /** * Initialize SSO service with configured strategies */ public async initialize(): Promise<void> { try { if (config.saml.enabled) { await this.initializeSAML(); } await this.initializeJWT(); this.initialized = true; logger.info('SSO service initialized', { samlEnabled: config.saml.enabled, }); } catch (error) { logger.error('Failed to initialize SSO service', { error }); throw error; } } /** * Initialize SAML strategy */ private async initializeSAML(): Promise<void> { if (!config.saml.entryPoint || !config.saml.callbackUrl) { throw new Error('SAML configuration incomplete'); } const samlOptions = { entryPoint: config.saml.entryPoint, issuer: config.saml.issuer || 'secure-mcp-server', callbackUrl: config.saml.callbackUrl, cert: config.saml.cert, privateKey: config.saml.privateKey, decryptionPvk: config.saml.privateKey, signatureAlgorithm: 'sha256', digestAlgorithm: 'sha256', validateInResponseTo: true, disableRequestedAuthnContext: false, authnContext: 'http://schemas.microsoft.com/ws/2008/06/identity/authenticationmethod/password', forceAuthn: false, skipRequestCompression: false, attributeConsumingServiceIndex: false, disableRequestArtifactResolution: true, logoutUrl: config.saml.entryPoint, logoutCallbackUrl: `${config.saml.callbackUrl}/logout`, additionalParams: {}, additionalAuthorizeParams: {}, identifierFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', wantAssertionsSigned: true, wantAuthnResponseSigned: true, racComparison: 'exact', signMetadata: false, requestIdExpirationPeriodMs: 28800000, // 8 hours cacheProvider: { saveAsync: async (key: string, value: string) => { await redis.setex(`saml_cache:${key}`, 28800, value); // 8 hours }, getAsync: async (key: string) => { return await redis.get(`saml_cache:${key}`); }, removeAsync: async (key: string) => { await redis.del(`saml_cache:${key}`); }, }, }; (passport as any).use('saml', new SamlStrategy(samlOptions as any, async (profile: any, done: any) => { try { const user = await this.processSAMLProfile(profile); done(null, user); } catch (error) { logger.error('SAML profile processing failed', { error, profile }); done(error, null); } })); logger.info('SAML strategy initialized'); } /** * Initialize JWT strategy for API authentication */ private async initializeJWT(): Promise<void> { const jwtOptions = { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: config.jwt.secret, issuer: config.jwt.issuer, audience: config.jwt.audience, algorithms: ['HS256'], }; passport.use('jwt', new JwtStrategy(jwtOptions, async (payload: any, done: any) => { try { // Verify token is still active (not revoked) const tokenPayload = await this.jwtService.verifyAccessToken( ExtractJwt.fromAuthHeaderAsBearerToken()( { headers: { authorization: `Bearer ${payload}` } } as any ) || '' ); // Get current user data const user = await this.getUserById(tokenPayload.sub); if (!user) { return done(null, false); } done(null, user); } catch (error) { logger.warn('JWT authentication failed', { error: error.message }); done(null, false); } })); logger.info('JWT strategy initialized'); } /** * Process SAML profile and create/update user */ private async processSAMLProfile(profile: any): Promise<SAMLUser> { const samlUser: SAMLUser = { nameID: profile.nameID, email: profile.email || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'], firstName: profile.firstName || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'], lastName: profile.lastName || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname'], displayName: profile.displayName || profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'], groups: profile.groups || profile['http://schemas.microsoft.com/ws/2008/06/identity/claims/groups'] || [], attributes: profile.attributes || {}, }; // Validate required fields if (!samlUser.email) { throw new Error('Email is required from SAML response'); } // Create or update user in the system const user = await this.createOrUpdateSAMLUser(samlUser); // Create SSO session await this.createSSOSession(user.id, 'saml', samlUser.nameID, profile.sessionIndex); logger.info('SAML user processed', { nameID: samlUser.nameID, email: samlUser.email, userId: user.id, }); return samlUser; } /** * Create or update user from SAML profile */ private async createOrUpdateSAMLUser(samlUser: SAMLUser): Promise<any> { // This would typically interact with your user database // For now, we'll store in Redis for demonstration const userId = await this.findUserByEmail(samlUser.email) || crypto.randomUUID(); const userData = { id: userId, email: samlUser.email, firstName: samlUser.firstName || '', lastName: samlUser.lastName || '', displayName: samlUser.displayName || `${samlUser.firstName} ${samlUser.lastName}`.trim(), provider: 'saml', nameID: samlUser.nameID, groups: JSON.stringify(samlUser.groups || []), roles: JSON.stringify(this.mapGroupsToRoles(samlUser.groups || [])), permissions: JSON.stringify(this.mapRolesToPermissions(this.mapGroupsToRoles(samlUser.groups || []))), lastLogin: new Date().toISOString(), updatedAt: new Date().toISOString(), }; // Store user data await redis.hset(`user:${userId}`, userData); await redis.set(`user_email:${samlUser.email}`, userId); logger.info('SAML user created/updated', { userId, email: samlUser.email }); return { id: userId, ...userData }; } /** * Create SSO session */ private async createSSOSession( userId: string, provider: 'saml' | 'oidc', nameID: string, sessionIndex?: string ): Promise<void> { const sessionId = crypto.randomUUID(); const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000); // 8 hours const session: SSOSession = { userId, provider, nameID, sessionIndex, createdAt: new Date().toISOString(), expiresAt: expiresAt.toISOString(), }; await redis.setex(`sso_session:${sessionId}`, 28800, JSON.stringify(session)); await redis.sadd(`user_sso_sessions:${userId}`, sessionId); logger.info('SSO session created', { sessionId, userId, provider }); } /** * Initiate SAML logout */ public async initiateSAMLLogout(userId: string): Promise<string | null> { try { // Find active SAML sessions for user const sessionIds = await redis.smembers(`user_sso_sessions:${userId}`); for (const sessionId of sessionIds) { const sessionData = await redis.get(`sso_session:${sessionId}`); if (sessionData) { const session: SSOSession = JSON.parse(sessionData); if (session.provider === 'saml') { // Generate SAML logout request const strategy = (passport as any)._strategy('saml') as any; return new Promise((resolve, reject) => { strategy.logout( { nameID: session.nameID, sessionIndex: session.sessionIndex } as any, (err: any, url: string) => { if (err) { logger.error('SAML logout failed', { error: err, userId }); reject(err); } else { resolve(url); } } ); }); } } } return null; } catch (error) { logger.error('Failed to initiate SAML logout', { error, userId }); throw error; } } /** * Handle SAML logout response */ public async handleSAMLLogoutResponse(userId: string): Promise<void> { try { // Remove all SSO sessions for user const sessionIds = await redis.smembers(`user_sso_sessions:${userId}`); for (const sessionId of sessionIds) { await redis.del(`sso_session:${sessionId}`); } await redis.del(`user_sso_sessions:${userId}`); // Revoke all JWT tokens for user await this.jwtService.revokeAllUserTokens(userId); logger.info('SAML logout completed', { userId }); } catch (error) { logger.error('Failed to handle SAML logout', { error, userId }); throw error; } } /** * Get SSO sessions for user */ public async getUserSSOSessions(userId: string): Promise<SSOSession[]> { try { const sessionIds = await redis.smembers(`user_sso_sessions:${userId}`); const sessions: SSOSession[] = []; for (const sessionId of sessionIds) { const sessionData = await redis.get(`sso_session:${sessionId}`); if (sessionData) { const session: SSOSession = JSON.parse(sessionData); // Check if session is still valid if (new Date(session.expiresAt) > new Date()) { sessions.push(session); } else { // Clean up expired session await redis.del(`sso_session:${sessionId}`); await redis.srem(`user_sso_sessions:${userId}`, sessionId); } } } return sessions; } catch (error) { logger.error('Failed to get user SSO sessions', { error, userId }); return []; } } /** * Validate SSO session */ public async validateSSOSession(sessionId: string): Promise<SSOSession | null> { try { const sessionData = await redis.get(`sso_session:${sessionId}`); if (!sessionData) return null; const session: SSOSession = JSON.parse(sessionData); // Check expiration if (new Date(session.expiresAt) <= new Date()) { await redis.del(`sso_session:${sessionId}`); await redis.srem(`user_sso_sessions:${session.userId}`, sessionId); return null; } return session; } catch (error) { logger.error('Failed to validate SSO session', { error, sessionId }); return null; } } /** * Find user by email */ private async findUserByEmail(email: string): Promise<string | null> { try { return await redis.get(`user_email:${email}`); } catch (error) { logger.error('Failed to find user by email', { error, email }); return null; } } /** * Get user by ID */ private async getUserById(userId: string): Promise<any | null> { try { const userData = await redis.hgetall(`user:${userId}`); if (!userData.id) return null; return { id: userData.id, email: userData.email, firstName: userData.firstName, lastName: userData.lastName, displayName: userData.displayName, roles: JSON.parse(userData.roles || '[]'), permissions: JSON.parse(userData.permissions || '[]'), lastLogin: userData.lastLogin, }; } catch (error) { logger.error('Failed to get user by ID', { error, userId }); return null; } } /** * Map SAML groups to application roles */ private mapGroupsToRoles(groups: string[]): string[] { const roleMapping: Record<string, string[]> = { 'MCP_Administrators': ['admin'], 'MCP_Users': ['user'], 'MCP_Readonly': ['readonly'], 'MCP_Developers': ['developer'], }; const roles: string[] = []; for (const group of groups) { const mappedRoles = roleMapping[group]; if (mappedRoles) { roles.push(...mappedRoles); } } // Default role if no mappings found if (roles.length === 0) { roles.push('user'); } // Remove duplicates return [...new Set(roles)]; } /** * Map roles to permissions */ private mapRolesToPermissions(roles: string[]): string[] { const permissionMapping: Record<string, string[]> = { 'admin': ['*'], // All permissions 'developer': ['tools:*', 'resources:read', 'resources:write', 'debug'], 'user': ['tools:read', 'tools:execute', 'resources:read'], 'readonly': ['tools:read', 'resources:read'], }; const permissions: string[] = []; for (const role of roles) { const rolePermissions = permissionMapping[role]; if (rolePermissions) { permissions.push(...rolePermissions); } } // Remove duplicates return [...new Set(permissions)]; } /** * Check if SSO service is initialized */ public isInitialized(): boolean { return this.initialized; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/perfecxion-ai/secure-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server