diff --git a/package.json b/package.json index 72ea5c1b..d57da8e3 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,8 @@ "uuid": "^3.3.2", "xml": "^1.0.1", "xml-crypto": "^2.1.3", - "xpath": "^0.0.32" + "xpath": "^0.0.32", + "zod": "^3.17.10" }, "devDependencies": { "@ava/typescript": "^1.1.1", diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 00000000..cc592e7d --- /dev/null +++ b/src/app.ts @@ -0,0 +1,63 @@ +/** + * This is the data structure for idp-sp management, most of the saml + * operations will be defined here. + * + * This will provide one idp to N sp and N idp to N sp according to the saml + * backend use cases. Developer can link pairs + */ + +import { IdentityProvider } from "./idp"; +import { ServiceProvider } from "./sp"; + +export interface SamlApp { + +} + +/** + * Pair up with another entity result in a new data structure with all + * common functions + */ +export const create = (): SamlApp => { + + const connections = {}; + const entities = {}; + + /** + * + * @param idp + * @param sp + */ + const bind = (idp: IdentityProvider, sp: ServiceProvider) => { + + // TODO: Validate for the pair up to see if there is any conflict + + // Cached into the nested object for function access + if (!connections[idp.id]) { + connections[idp.id] = {}; + } + if (!connections[idp.id][sp.id]) { + connections[idp.id][sp.id] = {}; + } + connections[idp.id][sp.id] = true; + entities[idp.id] = idp; + entities[sp.id] = sp; + + }; + + /** + * Check if the connection is active + * + * @param idpId + * @param spId + * @returns + */ + const isActive = (idpId: string, spId: string) => { + return !!connections[idpId]?.[spId]; + }; + + return { + bind, + isActive + }; + +} \ No newline at end of file diff --git a/src/idp.ts b/src/idp.ts new file mode 100644 index 00000000..d39e97a0 --- /dev/null +++ b/src/idp.ts @@ -0,0 +1,239 @@ +/** + * Define the identity provider interface, construction and feature + */ +import { v4 } from "uuid"; +import { z } from "zod"; +import { extract } from "./extractor"; +import libsaml from "./libsaml"; +import xml from 'xml'; +import { namespace } from "./urn"; +import { SSOServiceConfig } from "./types"; + +export const CreateProps = z.object({ + wantAuthnRequestsSigned: z.boolean().default(false), + entityId: z.string().optional().default(v4()), + signingCert: z.string().or(z.instanceof(Buffer)).optional(), + encryptCert: z.string().or(z.instanceof(Buffer)).optional(), + requestSignatureAlgorithm: z.string().optional(), + nameIDFormat: z.array(z.string()).optional().default([]), + singleSignOnService: SSOServiceConfig(1), + singleLogoutService: SSOServiceConfig(0) +}); + +export type CreateProps = z.infer; + +export const LoadProps = z.object({ + metadata: z.string().startsWith('http').or(z.string()), + extractions: z.array( + z.object({ + key: z.string(), + localPath: z.array(z.string()), + attributes: z.array(z.string()), + index: z.array(z.string()).optional(), + attributePath: z.array(z.string()).optional(), + context: z.boolean().optional() + }) + ).default([]) +}); + +export type LoadProps = z.infer; + +export interface IdentityProvider { + id: string, + metadata: Metadata; + rawMetadata: string; +}; + +export interface Metadata { + wantAuthnRequestsSigned?: boolean; + entityDescriptor?: any; + singleSignOnService?: any; + singleLogoutService?: any; + entityID?: any; + sharedCertificate?: any; + certificate?: { + signing: string; + encryption: string; + }; + nameIDFormat?: any; +} + +/** + * Easier interface to get access to essential props defined in metadata + * + * @param xmlString + * @returns + */ +const fetchEssentials = (xmlString: string): Metadata => { + const metadata: Metadata = extract(xmlString, [ + { + key: 'wantAuthnRequestsSigned', + localPath: ['EntityDescriptor', 'IDPSSODescriptor'], + attributes: ['WantAuthnRequestsSigned'], + }, + { + key: 'singleSignOnService', + localPath: ['EntityDescriptor', 'IDPSSODescriptor', 'SingleSignOnService'], + index: ['Binding'], + attributePath: [], + attributes: ['Location'] + }, + { + key: 'entityDescriptor', + localPath: ['EntityDescriptor'], + attributes: [], + context: true + }, + { + key: 'entityID', + localPath: ['EntityDescriptor'], + attributes: ['entityID'] + }, + { + // shared certificate for both encryption and signing + key: 'sharedCertificate', + localPath: ['EntityDescriptor', '~SSODescriptor', 'KeyDescriptor', 'KeyInfo', 'X509Data', 'X509Certificate'], + attributes: [] + }, + { + // explicit certificate declaration for encryption and signing + key: 'certificate', + localPath: ['EntityDescriptor', '~SSODescriptor', 'KeyDescriptor'], + index: ['use'], + attributePath: ['KeyInfo', 'X509Data', 'X509Certificate'], + attributes: [] + }, + { + key: 'singleLogoutService', + localPath: ['EntityDescriptor', '~SSODescriptor', 'SingleLogoutService'], + attributes: ['Binding', 'Location'] + }, + { + key: 'nameIDFormat', + localPath: ['EntityDescriptor', '~SSODescriptor', 'NameIDFormat'], + attributes: [], + } + ]); + + if (metadata.sharedCertificate) { + metadata.certificate = { + signing: metadata.sharedCertificate, + encryption: metadata.sharedCertificate + }; + } + + return metadata; + +}; + +/** + * Create function and returns a set of helper functions + * + * @param props + * @returns + */ +export const create = (props: CreateProps): IdentityProvider => { + + props = CreateProps.parse(props); + + // Prepare the payload for metadata construction + const entityDescriptors: any = [{ + _attr: { + WantAuthnRequestsSigned: String(props.wantAuthnRequestsSigned), + protocolSupportEnumeration: namespace.names.protocol, + }, + }]; + + if (props.signingCert) { + entityDescriptors.push(libsaml.createKeySection('signing', props.signingCert)); + } + + if (props.encryptCert) { + entityDescriptors.push(libsaml.createKeySection('encryption', props.encryptCert)); + } + + if (props.nameIDFormat.length > 0) { + props.nameIDFormat.forEach(f => entityDescriptors.push({ NameIDFormat: f })); + } + + // TODO: throw ERR_IDP_METADATA_MISSING_SINGLE_SIGN_ON_SERVICE + props.singleSignOnService.forEach((a, indexCount) => { + entityDescriptors.push({ + SingleSignOnService: [{ + _attr: { + Binding: a.binding, + Location: a.location, + isDefault: a.isDefault + } + }] + }); + }); + + // + props.singleLogoutService.forEach((a, indexCount) => { + entityDescriptors.push({ + SingleLogoutService: [{ + _attr: { + Binding: a.binding, + Location: a.location, + isDefault: a.isDefault + } + }] + }); + }); + + // Build the metadata xml based on the pass-in props + const metadataXml = xml([{ + EntityDescriptor: [{ + _attr: { + 'xmlns': namespace.names.metadata, + 'xmlns:assertion': namespace.names.assertion, + 'xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', + entityID: props.entityId, + }, + }, { IDPSSODescriptor: entityDescriptors }], + }]); + + return { + id: props.entityId, + metadata: fetchEssentials(metadataXml.toString()), + rawMetadata: metadataXml.toString() + }; + +} + +/** + * Create an idp by import a metadata, we separate the creation via metadata or create via object + * + * @param props + * @returns + */ +export const load = (props: LoadProps): IdentityProvider => { + + props = LoadProps.parse(props); + + // Load from url or file + const online = props.metadata.startsWith('http'); + + let xmlString: string = ''; + + // Get the metadata file from online + if (online) { + // TODO + } + + // Load the metadata file as xml string + if (!online) { + xmlString = props.metadata.toString(); + } + + // Fetch essential from its metadata + const metadata: Metadata = fetchEssentials(xmlString); + + return { + id: metadata.entityID, + metadata: metadata, + rawMetadata: xmlString + } + +}; \ No newline at end of file diff --git a/src/metadata-idp.ts b/src/metadata-idp.ts deleted file mode 100644 index 89fda7c4..00000000 --- a/src/metadata-idp.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** -* @file metadata-idp.ts -* @author tngan -* @desc Metadata of identity provider -*/ -import Metadata, { MetadataInterface } from './metadata'; -import { MetadataIdpOptions, MetadataIdpConstructor } from './types'; -import { namespace } from './urn'; -import libsaml from './libsaml'; -import { isNonEmptyArray, isString } from './utility'; -import xml from 'xml'; - -export interface IdpMetadataInterface extends MetadataInterface { - -} - -/* - * @desc interface function - */ -export default function(meta: MetadataIdpConstructor) { - return new IdpMetadata(meta); -} - -export class IdpMetadata extends Metadata { - - constructor(meta: MetadataIdpConstructor) { - - const isFile = isString(meta) || meta instanceof Buffer; - - if (!isFile) { - - const { - entityID, - signingCert, - encryptCert, - wantAuthnRequestsSigned = false, - nameIDFormat = [], - singleSignOnService = [], - singleLogoutService = [], - } = meta as MetadataIdpOptions; - - const IDPSSODescriptor: any[] = [{ - _attr: { - WantAuthnRequestsSigned: String(wantAuthnRequestsSigned), - protocolSupportEnumeration: namespace.names.protocol, - }, - }]; - - if (signingCert) { - IDPSSODescriptor.push(libsaml.createKeySection('signing', signingCert)); - } else { - //console.warn('Construct identity provider - missing signing certificate'); - } - - if (encryptCert) { - IDPSSODescriptor.push(libsaml.createKeySection('encryption', encryptCert)); - } else { - //console.warn('Construct identity provider - missing encrypt certificate'); - } - - if (isNonEmptyArray(nameIDFormat)) { - nameIDFormat.forEach(f => IDPSSODescriptor.push({ NameIDFormat: f })); - } - - if (isNonEmptyArray(singleSignOnService)) { - singleSignOnService.forEach((a, indexCount) => { - const attr: any = { - Binding: a.Binding, - Location: a.Location, - }; - if (a.isDefault) { - attr.isDefault = true; - } - IDPSSODescriptor.push({ SingleSignOnService: [{ _attr: attr }] }); - }); - } else { - throw new Error('ERR_IDP_METADATA_MISSING_SINGLE_SIGN_ON_SERVICE'); - } - - if (isNonEmptyArray(singleLogoutService)) { - singleLogoutService.forEach((a, indexCount) => { - const attr: any = {}; - if (a.isDefault) { - attr.isDefault = true; - } - attr.Binding = a.Binding; - attr.Location = a.Location; - IDPSSODescriptor.push({ SingleLogoutService: [{ _attr: attr }] }); - }); - } else { - console.warn('Construct identity provider - missing endpoint of SingleLogoutService'); - } - // Create a new metadata by setting - meta = xml([{ - EntityDescriptor: [{ - _attr: { - 'xmlns': namespace.names.metadata, - 'xmlns:assertion': namespace.names.assertion, - 'xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', - entityID, - }, - }, { IDPSSODescriptor }], - }]); - } - - super(meta as string | Buffer, [ - { - key: 'wantAuthnRequestsSigned', - localPath: ['EntityDescriptor', 'IDPSSODescriptor'], - attributes: ['WantAuthnRequestsSigned'], - }, - { - key: 'singleSignOnService', - localPath: ['EntityDescriptor', 'IDPSSODescriptor', 'SingleSignOnService'], - index: ['Binding'], - attributePath: [], - attributes: ['Location'] - }, - ]); - - } - - /** - * @desc Get the preference whether it wants a signed request - * @return {boolean} WantAuthnRequestsSigned - */ - isWantAuthnRequestsSigned(): boolean { - const was = this.meta.wantAuthnRequestsSigned; - if (was === undefined) { - return false; - } - return String(was) === 'true'; - } - - /** - * @desc Get the entity endpoint for single sign on service - * @param {string} binding protocol binding (e.g. redirect, post) - * @return {string/object} location - */ - getSingleSignOnService(binding: string): string | object { - if (isString(binding)) { - const bindName = namespace.binding[binding]; - const service = this.meta.singleSignOnService[bindName]; - if (service) { - return service; - } - } - return this.meta.singleSignOnService; - } -} diff --git a/src/metadata-sp.ts b/src/metadata-sp.ts deleted file mode 100644 index 977624a2..00000000 --- a/src/metadata-sp.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** -* @file metadata-sp.ts -* @author tngan -* @desc Metadata of service provider -*/ -import Metadata, { MetadataInterface } from './metadata'; -import { MetadataSpConstructor, MetadataSpOptions } from './types'; -import { namespace, elementsOrder as order } from './urn'; -import libsaml from './libsaml'; -import { isNonEmptyArray, isString } from './utility'; -import xml from 'xml'; - -export interface SpMetadataInterface extends MetadataInterface { - -} - -// https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf (P.16, 18) -interface MetaElement { - KeyDescriptor?: any[]; - NameIDFormat?: any[]; - SingleLogoutService?: any[]; - AssertionConsumerService?: any[]; - AttributeConsumingService?: any[]; -} - -/* - * @desc interface function - */ -export default function(meta: MetadataSpConstructor) { - return new SpMetadata(meta); -} - -/** -* @desc SP Metadata is for creating Service Provider, provides a set of API to manage the actions in SP. -*/ -export class SpMetadata extends Metadata { - - /** - * @param {object/string} meta (either xml string or configuation in object) - * @return {object} prototypes including public functions - */ - constructor(meta: MetadataSpConstructor) { - - const isFile = isString(meta) || meta instanceof Buffer; - - // use object configuation instead of importing metadata file directly - if (!isFile) { - - const { - elementsOrder = order.default, - entityID, - signingCert, - encryptCert, - authnRequestsSigned = false, - wantAssertionsSigned = false, - wantMessageSigned = false, - signatureConfig, - nameIDFormat = [], - singleLogoutService = [], - assertionConsumerService = [], - } = meta as MetadataSpOptions; - - const descriptors: MetaElement = { - KeyDescriptor: [], - NameIDFormat: [], - SingleLogoutService: [], - AssertionConsumerService: [], - AttributeConsumingService: [], - }; - - const SPSSODescriptor: any[] = [{ - _attr: { - AuthnRequestsSigned: String(authnRequestsSigned), - WantAssertionsSigned: String(wantAssertionsSigned), - protocolSupportEnumeration: namespace.names.protocol, - }, - }]; - - if (wantMessageSigned && signatureConfig === undefined) { - console.warn('Construct service provider - missing signatureConfig'); - } - - if (signingCert) { - descriptors.KeyDescriptor!.push(libsaml.createKeySection('signing', signingCert).KeyDescriptor); - } else { - //console.warn('Construct service provider - missing signing certificate'); - } - - if (encryptCert) { - descriptors.KeyDescriptor!.push(libsaml.createKeySection('encryption', encryptCert).KeyDescriptor); - } else { - //console.warn('Construct service provider - missing encrypt certificate'); - } - - if (isNonEmptyArray(nameIDFormat)) { - nameIDFormat.forEach(f => descriptors.NameIDFormat!.push(f)); - } else { - // default value - descriptors.NameIDFormat!.push(namespace.format.emailAddress); - } - - if (isNonEmptyArray(singleLogoutService)) { - singleLogoutService.forEach(a => { - const attr: any = { - Binding: a.Binding, - Location: a.Location, - }; - if (a.isDefault) { - attr.isDefault = true; - } - descriptors.SingleLogoutService!.push([{ _attr: attr }]); - }); - } - - if (isNonEmptyArray(assertionConsumerService)) { - let indexCount = 0; - assertionConsumerService.forEach(a => { - const attr: any = { - index: String(indexCount++), - Binding: a.Binding, - Location: a.Location, - }; - if (a.isDefault) { - attr.isDefault = true; - } - descriptors.AssertionConsumerService!.push([{ _attr: attr }]); - }); - } else { - // console.warn('Missing endpoint of AssertionConsumerService'); - } - - // handle element order - const existedElements = elementsOrder.filter(name => isNonEmptyArray(descriptors[name])); - existedElements.forEach(name => { - descriptors[name].forEach(e => SPSSODescriptor.push({ [name]: e })); - }); - - // Re-assign the meta reference as a XML string|Buffer for use with the parent constructor - meta = xml([{ - EntityDescriptor: [{ - _attr: { - entityID, - 'xmlns': namespace.names.metadata, - 'xmlns:assertion': namespace.names.assertion, - 'xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', - }, - }, { SPSSODescriptor }], - }]); - - } - - // Use the re-assigned meta object reference here - super(meta as string | Buffer, [ - { - key: 'spSSODescriptor', - localPath: ['EntityDescriptor', 'SPSSODescriptor'], - attributes: ['WantAssertionsSigned', 'AuthnRequestsSigned'], - }, - { - key: 'assertionConsumerService', - localPath: ['EntityDescriptor', 'SPSSODescriptor', 'AssertionConsumerService'], - attributes: ['Binding', 'Location', 'isDefault', 'index'], - } - ]); - - } - - /** - * @desc Get the preference whether it wants a signed assertion response - * @return {boolean} Wantassertionssigned - */ - public isWantAssertionsSigned(): boolean { - return this.meta.spSSODescriptor.wantAssertionsSigned === 'true'; - } - /** - * @desc Get the preference whether it signs request - * @return {boolean} Authnrequestssigned - */ - public isAuthnRequestSigned(): boolean { - return this.meta.spSSODescriptor.authnRequestsSigned === 'true'; - } - /** - * @desc Get the entity endpoint for assertion consumer service - * @param {string} binding protocol binding (e.g. redirect, post) - * @return {string/[string]} URL of endpoint(s) - */ - public getAssertionConsumerService(binding: string): string | string[] { - if (isString(binding)) { - let location; - const bindName = namespace.binding[binding]; - if (isNonEmptyArray(this.meta.assertionConsumerService)) { - this.meta.assertionConsumerService.forEach(obj => { - if (obj.binding === bindName) { - location = obj.location; - return; - } - }); - } else { - if (this.meta.assertionConsumerService.binding === bindName) { - location = this.meta.assertionConsumerService.location; - } - } - return location; - } - return this.meta.assertionConsumerService; - } -} diff --git a/src/metadata.ts b/src/metadata.ts deleted file mode 100644 index c3db6ebc..00000000 --- a/src/metadata.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** -* @file metadata.ts -* @author tngan -* @desc An abstraction for metadata of identity provider and service provider -*/ -import * as fs from 'fs'; -import { namespace } from './urn'; -import { extract } from './extractor'; -import { isString } from './utility'; - -export interface MetadataInterface { - xmlString: string; - getMetadata: () => string; - exportMetadata: (exportFile: string) => void; - getEntityID: () => string; - getX509Certificate: (certType: string) => string | string[]; - getNameIDFormat: () => any[]; - getSingleLogoutService: (binding: string | undefined) => string | object; - getSupportBindings: (services: string[]) => string[]; -} - -export default class Metadata implements MetadataInterface { - - xmlString: string; - meta: any; - - /** - * @param {string | Buffer} metadata xml - * @param {object} extraParse for custom metadata extractor - */ - constructor(xml: string | Buffer, extraParse: any = []) { - this.xmlString = xml.toString(); - this.meta = extract(this.xmlString, extraParse.concat([ - { - key: 'entityDescriptor', - localPath: ['EntityDescriptor'], - attributes: [], - context: true - }, - { - key: 'entityID', - localPath: ['EntityDescriptor'], - attributes: ['entityID'] - }, - { - // shared certificate for both encryption and signing - key: 'sharedCertificate', - localPath: ['EntityDescriptor', '~SSODescriptor', 'KeyDescriptor', 'KeyInfo', 'X509Data', 'X509Certificate'], - attributes: [] - }, - { - // explicit certificate declaration for encryption and signing - key: 'certificate', - localPath: ['EntityDescriptor', '~SSODescriptor', 'KeyDescriptor'], - index: ['use'], - attributePath: ['KeyInfo', 'X509Data', 'X509Certificate'], - attributes: [] - }, - { - key: 'singleLogoutService', - localPath: ['EntityDescriptor', '~SSODescriptor', 'SingleLogoutService'], - attributes: ['Binding', 'Location'] - }, - { - key: 'nameIDFormat', - localPath: ['EntityDescriptor', '~SSODescriptor', 'NameIDFormat'], - attributes: [], - } - ])); - - // get shared certificate - const sharedCertificate = this.meta.sharedCertificate; - if (typeof sharedCertificate === 'string') { - this.meta.certificate = { - signing: sharedCertificate, - encryption: sharedCertificate - }; - delete this.meta.sharedCertificate; - } - - if ( - Array.isArray(this.meta.entityDescriptor) && - this.meta.entityDescriptor.length > 1 - ) { - throw new Error('ERR_MULTIPLE_METADATA_ENTITYDESCRIPTOR'); - } - - } - - /** - * @desc Get the metadata in xml format - * @return {string} metadata in xml format - */ - public getMetadata(): string { - return this.xmlString; - } - - /** - * @desc Export the metadata to specific file - * @param {string} exportFile is the output file path - */ - public exportMetadata(exportFile: string): void { - fs.writeFileSync(exportFile, this.xmlString); - } - - /** - * @desc Get the entityID in metadata - * @return {string} entityID - */ - public getEntityID(): string { - return this.meta.entityID; - } - - /** - * @desc Get the x509 certificate declared in entity metadata - * @param {string} use declares the type of certificate - * @return {string} certificate in string format - */ - public getX509Certificate(use: string): string | string[] { - return this.meta.certificate[use] || null; - } - - /** - * @desc Get the support NameID format declared in entity metadata - * @return {array} support NameID format - */ - public getNameIDFormat(): any { - return this.meta.nameIDFormat; - } - - /** - * @desc Get the entity endpoint for single logout service - * @param {string} binding e.g. redirect, post - * @return {string/object} location - */ - public getSingleLogoutService(binding: string | undefined): string | object { - if (binding && isString(binding)) { - const bindType = namespace.binding[binding]; - let singleLogoutService = this.meta.singleLogoutService; - if (!(singleLogoutService instanceof Array)) { - singleLogoutService = [singleLogoutService]; - } - const service = singleLogoutService.find(obj => obj.binding === bindType); - if (service) { - return service.location; - } - } - return this.meta.singleLogoutService; - } - - /** - * @desc Get the support bindings - * @param {[string]} services - * @return {[string]} support bindings - */ - public getSupportBindings(services: string[]): string[] { - let supportBindings = []; - if (services) { - supportBindings = services.reduce((acc: any, service) => { - const supportBinding = Object.keys(service)[0]; - return acc.push(supportBinding); - }, []); - } - return supportBindings; - } -} diff --git a/src/sp.ts b/src/sp.ts new file mode 100644 index 00000000..b122cb73 --- /dev/null +++ b/src/sp.ts @@ -0,0 +1,262 @@ +/** + * Define the service provider interface, construction and feature + */ +import { v4 } from "uuid"; +import { z } from "zod"; +import { extract } from "./extractor"; +import libsaml from "./libsaml"; +import { SSOServiceConfig } from "./types"; +import { namespace } from "./urn"; +import xml from 'xml'; + +const SignatureConfig = z.object({ + prefix: z.string().optional(), + location: z.object({ + reference: z.string().optional(), + action: z.enum(['append', 'prepend', 'before', 'after']).optional() + }).optional() +}).optional(); + +export type SignatureConfig = z.infer; + +export const CreateProps = z.object({ + authnRequestsSigned: z.boolean().default(false), + wantAssertionsSigned: z.boolean().default(false), + wantMessageSigned: z.boolean().default(false), + entityId: z.string().optional().default(v4()), + signingCert: z.string().or(z.instanceof(Buffer)).optional(), + encryptCert: z.string().or(z.instanceof(Buffer)).optional(), + nameIDFormat: z.array(z.string()).optional().default([ + namespace.format.emailAddress + ]), + assertionConsumerService: SSOServiceConfig(1), + singleLogoutService: SSOServiceConfig(0), + signatureConfig: SignatureConfig, + elementsOrder: z.array(z.string()).optional().default([]) +}); + +export type CreateProps = z.infer; + +const LoadProps = z.object({ + metadata: z.string().startsWith('http').or(z.string()), + extractions: z.array( + z.object({ + key: z.string(), + localPath: z.array(z.string()), + attributes: z.array(z.string()), + index: z.array(z.string()).optional(), + attributePath: z.array(z.string()).optional(), + context: z.boolean().optional() + }) + ).default([]) +}); + +export type LoadProps = z.infer; + +export interface Metadata { + wantAuthnRequestsSigned?: boolean; + entityDescriptor?: any; + singleSignOnService?: any; + singleLogoutService?: any; + entityID?: any; + sharedCertificate?: any; + certificate?: { + signing: string; + encryption: string; + }; + nameIDFormat?: any; +} + +/** + * Easier interface to get access to essential props defined in metadata + * + * @param xmlString + * @returns + */ +const fetchEssentials = (xmlString: string): Metadata => { + const metadata: Metadata = extract(xmlString, [ + { + key: 'spSSODescriptor', + localPath: ['EntityDescriptor', 'SPSSODescriptor'], + attributes: ['WantAssertionsSigned', 'AuthnRequestsSigned'], + }, + { + key: 'assertionConsumerService', + localPath: ['EntityDescriptor', 'SPSSODescriptor', 'AssertionConsumerService'], + attributes: ['Binding', 'Location', 'isDefault', 'index'], + }, + { + key: 'entityDescriptor', + localPath: ['EntityDescriptor'], + attributes: [], + context: true + }, + { + key: 'entityID', + localPath: ['EntityDescriptor'], + attributes: ['entityID'] + }, + { + // shared certificate for both encryption and signing + key: 'sharedCertificate', + localPath: ['EntityDescriptor', '~SSODescriptor', 'KeyDescriptor', 'KeyInfo', 'X509Data', 'X509Certificate'], + attributes: [] + }, + { + // explicit certificate declaration for encryption and signing + key: 'certificate', + localPath: ['EntityDescriptor', '~SSODescriptor', 'KeyDescriptor'], + index: ['use'], + attributePath: ['KeyInfo', 'X509Data', 'X509Certificate'], + attributes: [] + }, + { + key: 'singleLogoutService', + localPath: ['EntityDescriptor', '~SSODescriptor', 'SingleLogoutService'], + attributes: ['Binding', 'Location'] + }, + { + key: 'nameIDFormat', + localPath: ['EntityDescriptor', '~SSODescriptor', 'NameIDFormat'], + attributes: [], + } + ]); + + if (metadata.sharedCertificate) { + metadata.certificate = { + signing: metadata.sharedCertificate, + encryption: metadata.sharedCertificate + }; + } + + return metadata; + +}; + +export interface ServiceProvider { + id: string; + metadata: Metadata; + rawMetadata: string; +}; + +/** + * Create function and returns a set of helper functions + * + * @param props + * @returns + */ +export const create = (props: CreateProps): ServiceProvider => { + + props = CreateProps.parse(props); + + // Prepare the payload for metadata construction + let entityDescriptors: any = [{ + _attr: { + AuthnRequestsSigned: String(props.authnRequestsSigned), + WantAssertionsSigned: String(props.wantAssertionsSigned), + protocolSupportEnumeration: namespace.names.protocol, + }, + }]; + + if (props.signingCert) { + entityDescriptors.push(libsaml.createKeySection('signing', props.signingCert)); + } + + if (props.encryptCert) { + entityDescriptors.push(libsaml.createKeySection('encryption', props.encryptCert)); + } + + if (props.nameIDFormat.length > 0) { + props.nameIDFormat.forEach(f => entityDescriptors.push({ NameIDFormat: f })); + } + + props.assertionConsumerService.forEach((a, indexCount) => { + entityDescriptors.push({ + AssertionConsumerService: [{ + _attr: { + index: indexCount, + Binding: a.binding, + Location: a.location, + isDefault: a.isDefault + } + }] + }); + }); + + props.singleLogoutService.forEach((a, indexCount) => { + entityDescriptors.push({ + SingleLogoutService: [{ + _attr: { + Binding: a.binding, + Location: a.location, + isDefault: a.isDefault + } + }] + }); + }); + + // Logic to reorder the elements + let reorderedEntityDescriptors = entityDescriptors; + for (let element of props.elementsOrder) { + const section = Object.keys(entityDescriptors)[element]; + if (section) { + reorderedEntityDescriptors.push(entityDescriptors[element]); + } + } + entityDescriptors = reorderedEntityDescriptors; + + // Build the metadata xml based on the pass-in props + const metadataXml = xml([{ + EntityDescriptor: [{ + _attr: { + entityID: props.entityId, + 'xmlns': namespace.names.metadata, + 'xmlns:assertion': namespace.names.assertion, + 'xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', + }, + }, { SPSSODescriptor: entityDescriptors }], + }]); + + return { + id: props.entityId, + metadata: fetchEssentials(metadataXml.toString()), + rawMetadata: metadataXml.toString() + }; + +} + +/** + * Create an idp by import a metadata, we separate the creation via metadata or create via object + * + * @param props + * @returns + */ +export const load = (props: LoadProps): ServiceProvider => { + + props = LoadProps.parse(props); + + // Load from url or file + const online = props.metadata.startsWith('http'); + + let xmlString: string = ''; + + // Get the metadata file from online + if (online) { + // TODO + } + + // Load the metadata file as xml string + if (!online) { + xmlString = props.metadata.toString(); + } + + // Fetch essential from its metadata + const metadata: Metadata = fetchEssentials(xmlString); + + return { + id: metadata.entityID, + metadata: metadata, + rawMetadata: xmlString + } + +}; \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 92ca7ef7..d6520877 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ +import { z } from 'zod'; import { LoginResponseTemplate } from './libsaml'; export { IdentityProvider as IdentityProviderConstructor } from './entity-idp'; @@ -6,44 +7,6 @@ export { IdpMetadata as IdentityProviderMetadata } from './metadata-idp'; export { ServiceProvider as ServiceProviderConstructor } from './entity-sp'; export { SpMetadata as ServiceProviderMetadata } from './metadata-sp'; -export type MetadataFile = string | Buffer; - -type SSOService = { - isDefault?: boolean; - Binding: string; - Location: string; -}; - -export interface MetadataIdpOptions { - entityID?: string; - signingCert?: string | Buffer; - encryptCert?: string | Buffer; - wantAuthnRequestsSigned?: boolean; - nameIDFormat?: string[]; - singleSignOnService?: SSOService[]; - singleLogoutService?: SSOService[]; - requestSignatureAlgorithm?: string; -} - -export type MetadataIdpConstructor = - | MetadataIdpOptions - | MetadataFile; - -export interface MetadataSpOptions { - entityID?: string; - signingCert?: string | Buffer; - encryptCert?: string | Buffer; - authnRequestsSigned?: boolean; - wantAssertionsSigned?: boolean; - wantMessageSigned?: boolean; - signatureConfig?: { [key: string]: any }; - nameIDFormat?: string[]; - singleSignOnService?: SSOService[]; - singleLogoutService?: SSOService[]; - assertionConsumerService?: SSOService[]; - elementsOrder?: string[]; -} - export type MetadataSpConstructor = | MetadataSpOptions | MetadataFile; @@ -51,11 +14,6 @@ export type MetadataSpConstructor = export type EntitySetting = ServiceProviderSettings & IdentityProviderSettings; export interface SignatureConfig { - prefix?: string; - location?: { - reference?: string; - action?: 'append' | 'prepend' | 'before' | 'after'; - }; } export interface SAMLDocumentTemplate { @@ -76,9 +34,6 @@ export type ServiceProviderSettings = { requestSignatureAlgorithm?: string; encPrivateKey?: string | Buffer; encPrivateKeyPass?: string | Buffer; - assertionConsumerService?: SSOService[]; - singleLogoutService?: SSOService[]; - signatureConfig?: SignatureConfig; loginRequestTemplate?: SAMLDocumentTemplate; logoutRequestTemplate?: SAMLDocumentTemplate; signingCert?: string | Buffer; @@ -125,3 +80,9 @@ export type IdentityProviderSettings = { wantLogoutRequestSignedResponseSigned?: boolean; tagPrefix?: { [key: string]: string }; }; + +export const SSOServiceConfig = (minConfig: number = 1) => z.array(z.object({ + isDefault: z.boolean().optional().default(false), + binding: z.string(), + location: z.string() +})).refine((arg) => arg.length >= minConfig); diff --git a/yarn.lock b/yarn.lock index 4e98e85c..2507c2c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2488,3 +2488,8 @@ yocto-queue@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== + +zod@^3.17.10: + version "3.17.10" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.17.10.tgz#8716a05e6869df6faaa878a44ffe3c79e615defb" + integrity sha512-IHXnQYQuOOOL/XgHhgl8YjNxBHi3xX0mVcHmqsvJgcxKkEczPshoWdxqyFwsARpf41E0v9U95WUROqsHHxt0UQ==