UNPKG

@syngrisi/syngrisi

Version:
217 lines (179 loc) 7.28 kB
/** * SAML IdP Metadata Loader Service * * Loads and parses SAML IdP metadata XML from a URL. * Supports automatic discovery of SSO URL, certificate, and entity ID. */ import { XMLParser } from 'fast-xml-parser'; import log from '@logger'; export interface ParsedIdPMetadata { entityID: string; ssoUrl: string; certificate: string; } interface CacheEntry { data: ParsedIdPMetadata; expiresAt: Date; } export class MetadataLoaderService { private static instance: MetadataLoaderService; private cache: Map<string, CacheEntry> = new Map(); private parser: XMLParser; private constructor() { this.parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', }); } static getInstance(): MetadataLoaderService { if (!MetadataLoaderService.instance) { MetadataLoaderService.instance = new MetadataLoaderService(); } return MetadataLoaderService.instance; } /** * Load IdP metadata from URL * @param metadataUrl - URL to fetch metadata from * @param cacheTtlMinutes - Cache TTL in minutes (default: 60) */ async loadFromUrl(metadataUrl: string, cacheTtlMinutes = 60): Promise<ParsedIdPMetadata> { // Check cache first const cached = this.cache.get(metadataUrl); if (cached && cached.expiresAt > new Date()) { log.debug('Using cached IdP metadata', { url: metadataUrl }); return cached.data; } log.info('Fetching IdP metadata', { url: metadataUrl }); const response = await fetch(metadataUrl, { headers: { Accept: 'application/xml, text/xml' }, }); if (!response.ok) { throw new Error(`Failed to fetch IdP metadata: HTTP ${response.status} ${response.statusText}`); } const xmlText = await response.text(); const parsed = this.parseMetadataXml(xmlText); // Store in cache this.cache.set(metadataUrl, { data: parsed, expiresAt: new Date(Date.now() + cacheTtlMinutes * 60 * 1000), }); log.info('Successfully loaded IdP metadata', { url: metadataUrl, entityID: parsed.entityID, ssoUrl: parsed.ssoUrl, }); return parsed; } /** * Clear cache (useful for testing or manual refresh) */ clearCache(): void { this.cache.clear(); log.debug('IdP metadata cache cleared'); } /** * Parse SAML metadata XML and extract required fields */ private parseMetadataXml(xmlText: string): ParsedIdPMetadata { const doc = this.parser.parse(xmlText); // EntityDescriptor can be root or nested, with or without namespace prefix const entityDescriptor = this.findElement(doc, ['EntityDescriptor', 'md:EntityDescriptor']); if (!entityDescriptor) { throw new Error('Invalid SAML metadata: EntityDescriptor not found'); } const entityID = entityDescriptor['@_entityID']; if (!entityID) { throw new Error('Invalid SAML metadata: entityID attribute not found'); } // Find IDPSSODescriptor const idpDescriptor = this.findElement(entityDescriptor, ['IDPSSODescriptor', 'md:IDPSSODescriptor']); if (!idpDescriptor) { throw new Error('Invalid SAML metadata: IDPSSODescriptor not found'); } // Find SSO URL (prefer HTTP-Redirect, fallback to HTTP-POST) const ssoUrl = this.extractSsoUrl(idpDescriptor); if (!ssoUrl) { throw new Error('Invalid SAML metadata: SingleSignOnService URL not found'); } // Find signing certificate const certificate = this.extractCertificate(idpDescriptor); if (!certificate) { throw new Error('Invalid SAML metadata: signing certificate not found'); } return { entityID, ssoUrl, certificate }; } /** * Find an element by trying multiple possible names (for namespace variations) */ private findElement(parent: Record<string, unknown>, names: string[]): Record<string, unknown> | null { for (const name of names) { if (parent[name]) { return parent[name] as Record<string, unknown>; } } return null; } /** * Extract SSO URL from IDPSSODescriptor */ private extractSsoUrl(idpDescriptor: Record<string, unknown>): string | null { const ssoServiceElement = this.findElement(idpDescriptor, ['SingleSignOnService', 'md:SingleSignOnService']); if (!ssoServiceElement) { return null; } // Can be array or single object const ssoServices = Array.isArray(ssoServiceElement) ? ssoServiceElement : [ssoServiceElement]; // Prefer HTTP-Redirect binding, then HTTP-POST const preferredBindings = [ 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', ]; for (const binding of preferredBindings) { const service = ssoServices.find( (s: Record<string, unknown>) => (s['@_Binding'] as string)?.includes(binding.split(':').pop() || '') ); if (service && service['@_Location']) { return service['@_Location'] as string; } } // Fallback: take first available const firstService = ssoServices[0] as Record<string, unknown>; return (firstService?.['@_Location'] as string) || null; } /** * Extract signing certificate from IDPSSODescriptor */ private extractCertificate(idpDescriptor: Record<string, unknown>): string | null { const keyDescriptorElement = this.findElement(idpDescriptor, ['KeyDescriptor', 'md:KeyDescriptor']); if (!keyDescriptorElement) { return null; } // Can be array (signing + encryption) or single const keyDescriptors = Array.isArray(keyDescriptorElement) ? keyDescriptorElement : [keyDescriptorElement]; // Prefer signing key, fallback to any key without use attribute const signingKey = keyDescriptors.find( (k: Record<string, unknown>) => k['@_use'] === 'signing' || !k['@_use'] ) as Record<string, unknown>; if (!signingKey) { return null; } // Navigate to certificate: KeyInfo → X509Data → X509Certificate // Handle both with and without namespace prefixes const keyInfo = this.findElement(signingKey, ['KeyInfo', 'ds:KeyInfo']); if (!keyInfo) { return null; } const x509Data = this.findElement(keyInfo, ['X509Data', 'ds:X509Data']); if (!x509Data) { return null; } const certificate = x509Data['X509Certificate'] || x509Data['ds:X509Certificate']; if (!certificate) { return null; } // Clean up certificate (remove whitespace, newlines) return String(certificate).replace(/\s/g, ''); } } export const metadataLoaderService = MetadataLoaderService.getInstance();