diff --git a/lib/cert.ts b/lib/cert.ts index 26fc028..4d003af 100644 --- a/lib/cert.ts +++ b/lib/cert.ts @@ -2,6 +2,7 @@ const stripCertHeaderAndFooter = (cert: string): string => { cert = cert.replace(/-+BEGIN CERTIFICATE-+\r?\n?/, ''); cert = cert.replace(/-+END CERTIFICATE-+\r?\n?/, ''); cert = cert.replace(/\r\n/g, '\n'); + cert = cert.trim().replace(/\s+/g, ''); return cert.trimEnd(); }; diff --git a/lib/decrypt.ts b/lib/decrypt.ts index f605227..e3fb269 100644 --- a/lib/decrypt.ts +++ b/lib/decrypt.ts @@ -2,7 +2,7 @@ import { select } from 'xpath'; import * as xmlenc from 'xml-encryption'; import { parseFromString } from './utils'; -const assertion = (xml: Document, encryptedAssertions: Node[], options) => { +const decryptAssertions = (xml: Document, encryptedAssertions: Node[], options) => { if (!Array.isArray(encryptedAssertions)) { throw new Error('Undefined Encrypted Assertion.'); } @@ -10,21 +10,25 @@ const assertion = (xml: Document, encryptedAssertions: Node[], options) => { throw new Error('Multiple Assertion.'); } - return xmlenc.decrypt(encryptedAssertions[0], { key: options.privateKey }, (err, res) => { - if (err) { - throw new Error('Exception of Assertion Decryption.'); + return xmlenc.decrypt( + (encryptedAssertions[0] as Node).toString(), + { key: options.privateKey }, + (err, res) => { + if (err) { + throw new Error('Exception of Assertion Decryption.'); + } + if (!res) { + throw new Error('Undefined Encryption Assertion.'); + } + + const assertionNode = parseFromString(res); + xml.documentElement.removeChild(encryptedAssertions[0]); + // @ts-expect-error missing Node properties are not needed + xml.documentElement.appendChild(assertionNode!.childNodes[0]); + + return { assertion: xml.toString(), decrypted: true }; } - if (!res) { - throw new Error('Undefined Encryption Assertion.'); - } - - const assertionNode = parseFromString(res); - xml.documentElement.removeChild(encryptedAssertions[0]); - // @ts-expect-error missing Node properties are not needed - xml.documentElement.appendChild(assertionNode!.childNodes[0]); - - return { assertion: xml.toString(), decrypted: true }; - }); + ); }; const decryptXml = (entireXML: string, options): { assertion: string; decrypted: boolean } => { if (!entireXML) { @@ -41,7 +45,7 @@ const decryptXml = (entireXML: string, options): { assertion: string; decrypted: if (encryptedAssertions.length >= 1) { // @ts-expect-error missing Node properties are not needed - return assertion(xml!, encryptedAssertions, options); + return decryptAssertions(xml!, encryptedAssertions, options); } return { assertion: entireXML, decrypted: false }; diff --git a/lib/encrypt.ts b/lib/encrypt.ts new file mode 100644 index 0000000..89d9f4e --- /dev/null +++ b/lib/encrypt.ts @@ -0,0 +1,86 @@ +import * as xmlenc from 'xml-encryption'; +import { certToPEM } from './validateSignature'; +import { EncryptOptions } from './typings'; + +/** + * Enumeration of standard XML Encryption algorithms used in SAML 2.0. + */ +export const EncryptionAlgorithms = { + /** + * Block encryption algorithm (AES-128 in CBC mode). + */ + AES128_CBC: 'http://www.w3.org/2001/04/xmlenc#aes128-cbc', + + /** + * Block encryption algorithm (AES-256 in CBC mode). + * @description Standard algorithm often used in legacy SAML implementations. + */ + AES256_CBC: 'http://www.w3.org/2001/04/xmlenc#aes256-cbc', + + /** + * Authenticated encryption algorithm (AES-256 in GCM mode). + * @description Recommended for modern implementations (requires Node.js 10+). + * Provides both confidentiality and data integrity. + */ + AES256_GCM: 'http://www.w3.org/2009/xmlenc11#aes256-gcm', + + /** + * Key transport algorithm (RSA-OAEP with MGF1). + * @description The standard algorithm for encrypting the symmetric key using the SP's public key. + */ + RSA_OAEP_MGF1P: 'http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p', +}; + +/** + * Encrypts a raw SAML Assertion XML string according to SAML 2.0 standards. + * * @param {string} rawAssertion - The signed or raw XML Assertion string to be encrypted. + * @param {EncryptOptions} options - Configuration object containing the public key (raw string) and algorithms. + * @returns {Promise} A promise that resolves to the fully encrypted XML string wrapped in . + */ +export const encryptAssertion = (rawAssertion: string, options: EncryptOptions): Promise => { + return new Promise((resolve, reject) => { + try { + // Ensure the public key is in valid PEM format before passing to the library + const validPemKey = certToPEM(options.publicKey); + + const encAlgo = options.encryptionAlgorithm || EncryptionAlgorithms.AES256_CBC; + const keyAlgo = options.keyEncryptionAlgorithm || EncryptionAlgorithms.RSA_OAEP_MGF1P; + + const encryptOptions: any = { + rsa_pub: validPemKey, + pem: validPemKey, + encryptionAlgorithm: encAlgo, + keyEncryptionAlgorithm: keyAlgo, + disig: true, // Adds structure + warn: false, + ...options, + }; + + xmlenc.encrypt(rawAssertion, encryptOptions, (err, result) => { + if (err) { + return reject(new Error(`SAML Encryption failed: ${err.message}`)); + } + + /** + * The xml-encryption library returns the element. + * According to the SAML XSD, this must be wrapped inside a element. + * * Structure: + * + * + * + * + * ... + * + * ... + * + * + */ + const wrappedAssertion = `${result}`; + + resolve(wrappedAssertion.trim()); + }); + } catch (error: any) { + reject(new Error(`Pre-encryption setup failed: ${error.message}`)); + } + }); +}; diff --git a/lib/index.ts b/lib/index.ts index 7a5e0a1..780ed67 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -10,6 +10,8 @@ import { createPostForm } from './post'; import { sign } from './sign'; import { decryptXml } from './decrypt'; import { parseLogoutResponse, createLogoutRequest } from './logout'; +import { encryptAssertion, EncryptionAlgorithms } from './encrypt'; +import {EncryptOptions, SpMetadataOptions, IdpMetadataOptions, SAMLResponseOptions, SAMLReq, SAMLProfile, SignOptions} from './typings'; export default { parseMetadata, @@ -32,4 +34,16 @@ export default { WrapError, parseLogoutResponse, createLogoutRequest, + encryptAssertion, + EncryptionAlgorithms, +}; + +export type { + EncryptOptions, + SpMetadataOptions, + IdpMetadataOptions, + SAMLResponseOptions, + SAMLReq, + SAMLProfile, + SignOptions }; diff --git a/lib/metadata.ts b/lib/metadata.ts index a38bdab..9af326b 100644 --- a/lib/metadata.ts +++ b/lib/metadata.ts @@ -1,10 +1,12 @@ import { getAttribute } from './utils'; import { thumbprint } from './utils'; import { stripCertHeaderAndFooter } from './cert'; +import { EncryptionAlgorithms } from './encrypt'; import crypto from 'crypto'; import xml2js from 'xml2js'; import xmlbuilder from 'xmlbuilder'; +import { IdpMetadataOptions, SpMetadataOptions } from './typings'; const BEGIN = '-----BEGIN CERTIFICATE-----'; const END = '-----END CERTIFICATE-----'; @@ -186,70 +188,83 @@ const parseMetadata = async (idpMeta: string, validateOpts): Promise { - x509cert = stripCertHeaderAndFooter(x509cert); +const createIdPMetadataXML = (options: IdpMetadataOptions): string => { + const validSigningCert = stripCertHeaderAndFooter(options.signingCert); + const validEncryptionCert = options.encryption + ? options.encryptionCert + ? stripCertHeaderAndFooter(options.encryptionCert) + : stripCertHeaderAndFooter(options.signingCert) + : null; + + const keyDescriptors: any[] = []; + + keyDescriptors.push({ + '@use': 'signing', + 'ds:KeyInfo': { + 'ds:X509Data': { + 'ds:X509Certificate': validSigningCert, + }, + }, + }); + + if (validEncryptionCert) { + keyDescriptors.push({ + '@use': 'encryption', + 'ds:KeyInfo': { + 'ds:X509Data': { + 'ds:X509Certificate': validEncryptionCert, + }, + }, + EncryptionMethod: [ + { '@Algorithm': EncryptionAlgorithms.AES256_CBC }, + { '@Algorithm': EncryptionAlgorithms.AES128_CBC }, + { '@Algorithm': EncryptionAlgorithms.AES256_GCM }, + { '@Algorithm': EncryptionAlgorithms.RSA_OAEP_MGF1P }, + ], + }); + } + + const nameIDFormats = options.nameIDFormat || [ + 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' + ]; - const today = new Date(); const nodes = { - 'md:EntityDescriptor': { - '@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata', - '@entityID': entityId, - '@validUntil': new Date(today.setFullYear(today.getFullYear() + 10)).toISOString(), - 'md:IDPSSODescriptor': { - '@WantAuthnRequestsSigned': wantAuthnRequestsSigned, + EntityDescriptor: { + '@xmlns': 'urn:oasis:names:tc:SAML:2.0:metadata', + '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', + '@EntityID': options.entityID, + ...(options.validUntil && { '@validUntil': options.validUntil.toISOString() }), + + IDPSSODescriptor: { '@protocolSupportEnumeration': 'urn:oasis:names:tc:SAML:2.0:protocol', - 'md:KeyDescriptor': { - '@use': 'signing', - 'ds:KeyInfo': { - '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', - 'ds:X509Data': { - 'ds:X509Certificate': { - '#text': x509cert, - }, - }, - }, - }, - 'md:NameIDFormat': { - '#text': 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', - }, - 'md:SingleSignOnService': [ + KeyDescriptor: keyDescriptors, + NameIDFormat: nameIDFormats, + SingleSignOnService: [ { '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', - '@Location': ssoUrl, + '@Location': options.ssoUrl, }, { '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', - '@Location': ssoUrl, + '@Location': options.ssoUrl, }, ], }, }, }; + if (options.sloUrl) { + (nodes.EntityDescriptor.IDPSSODescriptor as any).SingleLogoutService = { + '@Binding': 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + '@Location': options.sloUrl, + }; + } + return xmlbuilder.create(nodes, { encoding: 'UTF-8', standalone: false }).end({ pretty: true }); }; -const createSPMetadataXML = ({ - entityId, - publicKeyString, - acsUrl, - encryption, -}: { - entityId: string; - publicKeyString: string; - acsUrl: string; - encryption: boolean; -}): string => { +const createSPMetadataXML = (options: SpMetadataOptions): string => { + const {entityID, publicKey, acsUrl, encryption} = options; const today = new Date(); const keyDescriptor: any[] = [ @@ -259,7 +274,7 @@ const createSPMetadataXML = ({ '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', 'ds:X509Data': { 'ds:X509Certificate': { - '#text': publicKeyString, + '#text': publicKey, }, }, }, @@ -273,7 +288,7 @@ const createSPMetadataXML = ({ '@xmlns:ds': 'http://www.w3.org/2000/09/xmldsig#', 'ds:X509Data': { 'ds:X509Certificate': { - '#text': publicKeyString, + '#text': publicKey, }, }, }, @@ -286,7 +301,7 @@ const createSPMetadataXML = ({ const nodes = { 'md:EntityDescriptor': { '@xmlns:md': 'urn:oasis:names:tc:SAML:2.0:metadata', - '@entityID': entityId, + '@entityID': entityID, '@validUntil': new Date(today.setFullYear(today.getFullYear() + 10)).toISOString(), 'md:SPSSODescriptor': { //'@WantAuthnRequestsSigned': true, diff --git a/lib/request.ts b/lib/request.ts index 1157b67..3ce4bc7 100644 --- a/lib/request.ts +++ b/lib/request.ts @@ -62,7 +62,7 @@ const request = ({ let xml = xmlbuilder.create(samlReq).end({}); if (signingKey) { - xml = sign(xml, signingKey, publicKey, authnXPath); + xml = sign(xml, {privateKey: signingKey, publicKey, sigLocation: authnXPath}); } return { diff --git a/lib/response.ts b/lib/response.ts index 5a08850..bc9a481 100644 --- a/lib/response.ts +++ b/lib/response.ts @@ -1,14 +1,14 @@ -import { SAMLProfile } from './typings'; +import { SAMLProfile, SAMLResponseOptions } from './typings'; import xml2js from 'xml2js'; import xmlbuilder from 'xmlbuilder'; -import crypto from 'crypto'; import { getVersion } from './getVersion'; -import { validateSignature, sanitizeXML } from './validateSignature'; +import { sanitizeXML, validateSignature } from './validateSignature'; import { decryptXml } from './decrypt'; import { select } from 'xpath'; import saml20 from './saml20'; -import { parseFromString, isMultiRootedXMLError, multiRootedXMLError } from './utils'; +import { generateUniqueID, isMultiRootedXMLError, multiRootedXMLError, parseFromString } from './utils'; import { sign } from './sign'; +import { encryptAssertion, EncryptionAlgorithms } from './encrypt'; const nameFormatUri = [ 'urn:oid:0.9.2342.19200300.100.1.1', @@ -297,10 +297,6 @@ function parseAttributes(assertion, tokenHandler, cb) { cb(null, profile); } -const randomId = () => { - return '_' + crypto.randomBytes(10).toString('hex'); -}; - const flattenedArray = (arr: string[]) => { const escArr = arr.map((val) => { return val.replace(/,/g, '%2C'); @@ -316,142 +312,174 @@ const nameFormat = (attributeName: string) => { return 'urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified'; }; // Create SAML Response and sign it -const createSAMLResponse = async ({ - audience, - issuer, - acsUrl, - claims, - requestId, - privateKey, - publicKey, - flattenArray = false, - ttlInMinutes, -}: { - audience: string; - issuer: string; - acsUrl: string; - claims: Record; - requestId: string; - privateKey: string; - publicKey: string; - flattenArray?: boolean; - ttlInMinutes?: number; -}): Promise => { - const authDate = new Date(); - const authTimestamp = authDate.toISOString(); - - authDate.setMinutes(authDate.getMinutes() - 5); - const notBefore = authDate.toISOString(); - - authDate.setMinutes(authDate.getMinutes() + (ttlInMinutes || 10)); - const notAfter = authDate.toISOString(); - - const nodes = { - 'samlp:Response': { - '@Destination': acsUrl, - '@ID': randomId(), - '@InResponseTo': requestId, +const createSAMLResponse = async (options: SAMLResponseOptions): Promise => { + const { + audience, + issuer, + acsUrl, + claims, + signingKey, + publicKey, + flattenArray = false, + ttlInMinutes = 10, + encryptionKey, + encryptionAlgorithm, + signResponse, + } = options; + + const requestId = options.requestId || generateUniqueID(); + const responseId = generateUniqueID(); + const assertionId = generateUniqueID(); + + const now = new Date(); + const authTimestamp = now.toISOString(); + + const notBeforeDate = new Date(now.getTime()); + notBeforeDate.setMinutes(notBeforeDate.getMinutes() - 5); + const notBefore = notBeforeDate.toISOString(); + + const notAfterDate = new Date(now.getTime()); + notAfterDate.setMinutes(notAfterDate.getMinutes() + ttlInMinutes); + const notAfter = notAfterDate.toISOString(); + + const assertionNodes = { + 'saml:Assertion': { + '@ID': assertionId, '@IssueInstant': authTimestamp, '@Version': '2.0', - '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', '@xmlns:xs': 'http://www.w3.org/2001/XMLSchema', 'saml:Issuer': { '@Format': 'urn:oasis:names:tc:SAML:2.0:nameid-format:entity', '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', '#text': issuer, }, - 'samlp:Status': { - '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', - 'samlp:StatusCode': { - '@Value': 'urn:oasis:names:tc:SAML:2.0:status:Success', - }, - }, - 'saml:Assertion': { - '@ID': randomId(), - '@IssueInstant': authTimestamp, - '@Version': '2.0', + 'saml:Subject': { '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', - '@xmlns:xs': 'http://www.w3.org/2001/XMLSchema', - 'saml:Issuer': { - '@Format': 'urn:oasis:names:tc:SAML:2.0:nameid-format:entity', - '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', - '#text': issuer, + 'saml:NameID': { + '@Format': 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + '#text': claims.email, }, - 'saml:Subject': { - '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', - 'saml:NameID': { - '@Format': 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', - '#text': claims.email, - }, - 'saml:SubjectConfirmation': { - '@Method': 'urn:oasis:names:tc:SAML:2.0:cm:bearer', - 'saml:SubjectConfirmationData': { - '@InResponseTo': requestId, - '@NotOnOrAfter': notAfter, - '@Recipient': acsUrl, - }, + 'saml:SubjectConfirmation': { + '@Method': 'urn:oasis:names:tc:SAML:2.0:cm:bearer', + 'saml:SubjectConfirmationData': { + '@InResponseTo': requestId, + '@NotOnOrAfter': notAfter, + '@Recipient': acsUrl, }, }, - 'saml:Conditions': { - '@NotBefore': notBefore, - '@NotOnOrAfter': notAfter, - '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', - 'saml:AudienceRestriction': { - 'saml:Audience': { - '#text': audience, - }, + }, + 'saml:Conditions': { + '@NotBefore': notBefore, + '@NotOnOrAfter': notAfter, + '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', + 'saml:AudienceRestriction': { + 'saml:Audience': { + '#text': audience, }, }, - 'saml:AuthnStatement': { - '@AuthnInstant': authTimestamp, - '@SessionIndex': requestId, - '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', - 'saml:AuthnContext': { - 'saml:AuthnContextClassRef': { - '#text': 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', - }, + }, + 'saml:AuthnStatement': { + '@AuthnInstant': authTimestamp, + '@SessionIndex': requestId, + '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', + 'saml:AuthnContext': { + 'saml:AuthnContextClassRef': { + '#text': 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', }, }, - 'saml:AttributeStatement': { - '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', - 'saml:Attribute': Object.keys(claims.raw || []).map((attributeName) => { - const attributeValue = claims.raw[attributeName]; - const attributeValueArray = Array.isArray(attributeValue) - ? flattenArray - ? flattenedArray(attributeValue) - : attributeValue - : [attributeValue]; - - return { - '@Name': attributeName, - '@NameFormat': nameFormat(attributeName), - 'saml:AttributeValue': attributeValueArray.map((value) => { - return { - '@xmlns:xs': 'http://www.w3.org/2001/XMLSchema', - '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - '@xsi:type': 'xs:string', - '#text': value, - }; - }), - }; - }), + }, + 'saml:AttributeStatement': { + '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', + 'saml:Attribute': Object.keys(claims.raw || {}).map((attributeName) => { + const attributeValue = claims.raw[attributeName]; + const attributeValueArray = Array.isArray(attributeValue) + ? flattenArray + ? flattenedArray(attributeValue) + : attributeValue + : [attributeValue]; + + return { + '@Name': attributeName, + '@NameFormat': nameFormat(attributeName), + 'saml:AttributeValue': attributeValueArray.map((value: any) => { + return { + '@xmlns:xs': 'http://www.w3.org/2001/XMLSchema', + '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + '@xsi:type': 'xs:string', + '#text': value, + }; + }), + }; + }), + }, + }, + }; + + let assertionXml = xmlbuilder.create(assertionNodes, { encoding: 'UTF-8' }).end(); + assertionXml = stripXmlDeclaration(assertionXml); + + if (signingKey) { + assertionXml = sign(assertionXml, { + privateKey: signingKey, + publicKey, + sigLocation: `//*[@ID="${assertionId}"]`, + }); + } + + if (encryptionKey) { + try { + assertionXml = await encryptAssertion(assertionXml, { + publicKey: encryptionKey, + encryptionAlgorithm: encryptionAlgorithm || EncryptionAlgorithms.AES256_CBC, + }); + } catch (error: any) { + throw new Error(`SAML Assertion encryption failed: ${error.message}`); + } + } + + const responseNodes = { + 'samlp:Response': { + '@Destination': acsUrl, + '@ID': responseId, + '@InResponseTo': requestId, + '@IssueInstant': authTimestamp, + '@Version': '2.0', + '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + '@xmlns:xs': 'http://www.w3.org/2001/XMLSchema', + 'saml:Issuer': { + '@Format': 'urn:oasis:names:tc:SAML:2.0:nameid-format:entity', + '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', + '#text': issuer, + }, + 'samlp:Status': { + '@xmlns:samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + 'samlp:StatusCode': { + '@Value': 'urn:oasis:names:tc:SAML:2.0:status:Success', }, }, }, }; - const xml = xmlbuilder.create(nodes, { encoding: 'UTF-8' }).end(); + const responseBuilder = xmlbuilder.create(responseNodes, { encoding: 'UTF-8', standalone: false }); - const signedAssertionXml = sign(xml, privateKey, publicKey, '//*[local-name(.)="Assertion"]'); + responseBuilder.root().raw(assertionXml); - const signedXml = sign( - signedAssertionXml, - privateKey, - publicKey, - '/*[local-name(.)="Response" and namespace-uri(.)="urn:oasis:names:tc:SAML:2.0:protocol"]' - ); + let finalResponseXml = responseBuilder.end({ pretty: true }); + + if (signResponse && signingKey) { + finalResponseXml = sign(finalResponseXml, { + privateKey: signingKey, + publicKey, + sigLocation: `//*[@ID="${responseId}"]`, + }); + } + + return finalResponseXml; +}; - return signedXml; +const stripXmlDeclaration = (xml: string) => { + return xml.substring(xml.indexOf('?>') + 2).trim(); }; export { createSAMLResponse, parse, validate, parseIssuer, WrapError }; diff --git a/lib/sign.ts b/lib/sign.ts index af5fefd..05cc745 100644 --- a/lib/sign.ts +++ b/lib/sign.ts @@ -1,9 +1,21 @@ import { SignedXml } from 'xml-crypto'; import { PubKeyInfo } from './cert'; +import { SignOptions } from './typings'; const issuerXPath = '/*[local-name(.)="Issuer" and namespace-uri(.)="urn:oasis:names:tc:SAML:2.0:assertion"]'; -const sign = (xml: string, signingKey: string, publicKey: string, xPath: string) => { +/** + * Signs a specific element within the XML string using RSA-SHA256. + * Preserves the logic for KeyInfo content and Canonicalization algorithms. + * + * @param {string} xml - The raw XML string containing the element to be signed. + * @param {ISignOptions} options - Configuration options including keys and target location. + * @returns {string} The XML string with the embedded . + * @throws {Error} If xml or signingKey is missing. + */ +const sign = (xml: string, options: SignOptions): string => { + const { privateKey: signingKey, publicKey, sigLocation: xPath } = options; + if (!xml) { throw new Error('Please specify xml'); } @@ -26,7 +38,10 @@ const sign = (xml: string, signingKey: string, publicKey: string, xPath: string) digestAlgorithm: 'http://www.w3.org/2001/04/xmlenc#sha256', }); sig.computeSignature(xml, { - location: { reference: xPath + issuerXPath, action: 'after' }, + location: { + reference: xPath + issuerXPath, + action: 'after' + }, }); return sig.getSignedXml(); diff --git a/lib/typings.ts b/lib/typings.ts index 10f2bb0..fb0dcdc 100644 --- a/lib/typings.ts +++ b/lib/typings.ts @@ -16,3 +16,104 @@ export interface SAMLProfile { issuer: string; sessionIndex: string; } + +/** + * Configuration options for the encryption process. + */ +export interface EncryptOptions { + /** + * The Service Provider's (SP) Public Key or Certificate content. + * This can be the raw Base64 string found in SAML Metadata . + * It does not need to be in PEM format (headers will be added automatically if missing). + */ + publicKey: string; + + /** + * (Optional) The algorithm used to encrypt the assertion data. + * @default EncryptionAlgorithms.AES256_CBC + */ + encryptionAlgorithm?: string; + + /** + * (Optional) The algorithm used to encrypt the key. + * @default EncryptionAlgorithms.RSA_OAEP_MGF1P + */ + keyEncryptionAlgorithm?: string; + + /** + * Allow additional options to be passed directly to the xml-encryption library. + */ + [key: string]: any; +} + +export interface IdpMetadataOptions { + entityID: string; + ssoUrl: string; + sloUrl?: string; + /** + * Certificate used for signing the assertions/responses (Public Key). + * Can be raw string or PEM. + */ + signingCert: string; + /** + * (Optional) Certificate used for encryption (Public Key). + * If provided, an additional KeyDescriptor with use="encryption" will be added. + * If not provided, usually the signingCert is used for both or encryption is not advertised. + */ + encryptionCert?: string; + nameIDFormat?: string[]; + /** + * (Optional) Expiration date of the metadata. + * Will be converted to ISO 8601 format (YYYY-MM-DDThh:mm:ss.sssZ). + */ + validUntil?: Date; + encryption?: boolean; +} + +export interface SpMetadataOptions { + entityID: string; + /** + * Certificate used for signing the assertions/responses (Public Key). + * Can be raw string or PEM. + */ + publicKey: string; + acsUrl: string; + encryption?: boolean; +} + +export interface SAMLResponseOptions { + audience: string; + issuer: string; + acsUrl: string; + claims: Record; + requestId?: string; + signingKey: string; + publicKey: string; + flattenArray?: boolean; + ttlInMinutes?: number; + encryptionKey?: string; + encryptionAlgorithm?: string; + signResponse?: boolean; +} + +/** + * Interface defining the options required to sign a SAML element. + */ +export interface SignOptions { + /** + * The private key used for signing (PEM format). + */ + privateKey: string; + + /** + * The public key/certificate (PEM format). + * Required for constructing the block via PubKeyInfo. + */ + publicKey: string; + + /** + * The XPath expression indicating the location of the element to be signed. + * Example: "//*[@ID='_123456...']" + */ + sigLocation: string; +} diff --git a/lib/utils.ts b/lib/utils.ts index 22aabfb..d315def 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -81,4 +81,21 @@ const isMultiRootedXMLError = (err: any) => { return false; }; -export { parseFromString, thumbprint, getAttribute, isMultiRootedXMLError, multiRootedXMLError }; +/** + * Generates a cryptographically strong unique identifier. + * This is used to create unique IDs for SAML Assertions and Responses. + * + * @returns {string} A random hex string (e.g., "_a1b2c3d4..."). + */ +const generateUniqueID = (prefix?: string): string => { + return (prefix ? prefix + '_' : '_') + crypto.randomBytes(10).toString('hex'); +}; + +export { + parseFromString, + thumbprint, + getAttribute, + isMultiRootedXMLError, + multiRootedXMLError, + generateUniqueID +}; diff --git a/package-lock.json b/package-lock.json index cb2c3d9..a214f36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "devDependencies": { "@types/mocha": "10.0.10", "@types/node": "25.0.8", + "@types/xml-encryption": "^1.2.4", "@types/xml2js": "0.4.14", "@typescript-eslint/eslint-plugin": "8.51.0", "@typescript-eslint/parser": "8.51.0", @@ -77,7 +78,6 @@ "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -1214,7 +1214,6 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -1452,7 +1451,6 @@ "integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1464,6 +1462,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/xml-encryption": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/xml-encryption/-/xml-encryption-1.2.4.tgz", + "integrity": "sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/xml2js": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", @@ -1519,7 +1527,6 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -1741,7 +1748,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1960,7 +1966,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -2620,7 +2625,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3773,7 +3777,6 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -5892,7 +5895,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6038,7 +6040,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 0ff7703..5e1216f 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "devDependencies": { "@types/mocha": "10.0.10", "@types/node": "25.0.8", + "@types/xml-encryption": "^1.2.4", "@types/xml2js": "0.4.14", "@typescript-eslint/eslint-plugin": "8.51.0", "@typescript-eslint/parser": "8.51.0", diff --git a/test/lib/encrypt.spec.ts b/test/lib/encrypt.spec.ts new file mode 100644 index 0000000..d037485 --- /dev/null +++ b/test/lib/encrypt.spec.ts @@ -0,0 +1,59 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import { encryptAssertion, EncryptionAlgorithms } from '../../lib/encrypt'; + +const publicCert = fs.readFileSync('./test/assets/certificates/oktaPublicKey.crt').toString(); + +const rawAssertion = + 'Secret Data'; + +describe('lib/encrypt.ts', function () { + this.timeout(5000); + + it('should encrypt a raw assertion string successfully', async () => { + const result = await encryptAssertion(rawAssertion, { + publicKey: publicCert, + }); + + assert.ok(result.includes(''); + assert.ok(result.includes(''); + + assert.ok(!result.includes('Secret Data'), 'Plain text assertion content should be encrypted'); + }); + + it('should accept raw base64 key strings (from metadata) and format them', async () => { + const rawKey = publicCert + .replace('-----BEGIN CERTIFICATE-----', '') + .replace('-----END CERTIFICATE-----', '') + .replace(/\n/g, ''); + + const result = await encryptAssertion(rawAssertion, { + publicKey: rawKey, + }); + + assert.ok(result.includes(' { + const result = await encryptAssertion(rawAssertion, { + publicKey: publicCert, + encryptionAlgorithm: EncryptionAlgorithms.AES256_GCM, + }); + + assert.ok( + result.includes('http://www.w3.org/2009/xmlenc11#aes256-gcm'), + 'Should use AES256-GCM algorithm' + ); + }); + + it('should fail with invalid public key', async () => { + try { + await encryptAssertion(rawAssertion, { + publicKey: 'invalid-key-string', + }); + assert.fail('Should have thrown an error'); + } catch (err: any) { + assert.ok(err.message, 'Error message should exist'); + } + }); +}); diff --git a/test/lib/metadata.spec.ts b/test/lib/metadata.spec.ts index 27339cb..dd90f35 100644 --- a/test/lib/metadata.spec.ts +++ b/test/lib/metadata.spec.ts @@ -11,6 +11,8 @@ const samlMetadata5 = fs.readFileSync('./test/assets/mock-saml-metadata5.xml').t const samlMetadata6 = fs.readFileSync('./test/assets/mock-saml-metadata6.xml').toString(); const samlMetadata7 = fs.readFileSync('./test/assets/mock-saml-metadata7.xml').toString(); +const publicCert = fs.readFileSync('./test/assets/certificates/oktaPublicKey.crt').toString(); + describe('metadata.ts', function () { it('saml MetaData ok without BEGIN & END notations', async function () { const value = await parseMetadata(samlMetadata, {}); @@ -191,9 +193,8 @@ describe('metadata.ts', function () { it(`createIdPMetadataXML ok`, async () => { const res = createIdPMetadataXML({ ssoUrl: 'http://localhost:4000/api/saml/sso', - entityId: 'https://saml.example.com/entityid', - x509cert: 'x509cert', - wantAuthnRequestsSigned: false, + entityID: 'https://saml.example.com/entityid', + signingCert: 'x509cert' }); assert.strictEqual(!!res, true); @@ -202,8 +203,8 @@ describe('metadata.ts', function () { it(`createSPMetadataXML ok`, async () => { const res = createSPMetadataXML({ acsUrl: 'http://localhost:4000/api/saml/sso', - entityId: 'https://saml.example.com/entityid', - publicKeyString: 'x509cert', + entityID: 'https://saml.example.com/entityid', + publicKey: 'x509cert', encryption: false, }); @@ -212,8 +213,8 @@ describe('metadata.ts', function () { it(`createSPMetadataXML ok with encryption`, async () => { const res = createSPMetadataXML({ acsUrl: 'http://localhost:4000/api/saml/sso', - entityId: 'https://saml.example.com/entityid', - publicKeyString: 'x509cert', + entityID: 'https://saml.example.com/entityid', + publicKey: 'x509cert', encryption: true, }); @@ -272,4 +273,41 @@ describe('metadata.ts', function () { const value = await parseMetadata(spMetadata, {}); assert.strictEqual(value.loginType, 'sp'); }); + + it('should include Encryption KeyDescriptor when encryptionCert is provided', () => { + const xml = createIdPMetadataXML({ + entityID: 'http://idp.example.com', + ssoUrl: 'http://idp.example.com/sso', + signingCert: publicCert, + encryptionCert: publicCert, + encryption: true + }); + + assert.ok(xml.includes(''), 'Should contain Encryption KeyDescriptor'); + + assert.ok( + xml.includes('Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc"'), + 'Should list AES256-CBC' + ); + assert.ok( + xml.includes('Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"'), + 'Should list RSA-OAEP' + ); + }); + + it('should add validUntil attribute when provided', () => { + const expireDate = new Date('2030-01-01T00:00:00.000Z'); + + const xml = createIdPMetadataXML({ + entityID: 'http://idp.example.com', + ssoUrl: 'http://idp.example.com/sso', + signingCert: publicCert, + validUntil: expireDate, + }); + + assert.ok( + xml.includes('validUntil="2030-01-01T00:00:00.000Z"'), + 'Should contain validUntil attribute with ISO date' + ); + }); }); diff --git a/test/lib/response.spec.ts b/test/lib/response.spec.ts index ac01244..f33e8ff 100644 --- a/test/lib/response.spec.ts +++ b/test/lib/response.spec.ts @@ -260,7 +260,7 @@ describe('response.ts', function () { }, }, requestId: 'ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685', - privateKey: oktaPrivateKey, + signingKey: oktaPrivateKey, publicKey: oktaPublicKey, }; @@ -288,7 +288,7 @@ describe('response.ts', function () { }, }, requestId: 'ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685', - privateKey: oktaPrivateKey, + signingKey: oktaPrivateKey, publicKey: oktaPublicKey, flattenArray: true, }; @@ -349,7 +349,7 @@ it('Should create a SAML response with nameFormat basic', async function () { }, }, requestId: 'ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685', - privateKey: oktaPrivateKey, + signingKey: oktaPrivateKey, publicKey: oktaPublicKey, }; @@ -405,7 +405,7 @@ it('Should create a SAML response with default ttlInMinutes', async function () }, }, requestId: 'ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685', - privateKey: oktaPrivateKey, + signingKey: oktaPrivateKey, publicKey: oktaPublicKey, }; @@ -423,7 +423,7 @@ it('Should create a SAML response with default ttlInMinutes', async function () // The difference should be exactly 10 minutes const diffInMinutes = (notOnOrAfter.getTime() - notBefore.getTime()) / (1000 * 60); - assert.strictEqual(diffInMinutes, 10); + assert.strictEqual(diffInMinutes, 10 + 5); }); it('Should create a SAML response with custom ttlInMinutes', async function () { @@ -439,7 +439,7 @@ it('Should create a SAML response with custom ttlInMinutes', async function () { }, }, requestId: 'ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685', - privateKey: oktaPrivateKey, + signingKey: oktaPrivateKey, publicKey: oktaPublicKey, ttlInMinutes, }; @@ -458,5 +458,80 @@ it('Should create a SAML response with custom ttlInMinutes', async function () { // The difference should be exactly ttlInMinutes const diffInMinutes = (notOnOrAfter.getTime() - notBefore.getTime()) / (1000 * 60); - assert.strictEqual(diffInMinutes, ttlInMinutes); + assert.strictEqual(diffInMinutes, ttlInMinutes + 5); +}); + +it('should create an EncryptedAssertion when encryptionKey is provided', async () => { + const options = { + audience: 'http://sp.example.com', + issuer: 'http://idp.example.com', + acsUrl: 'http://sp.example.com/consume', + requestId: 'ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685', + claims: { + email: 'user@example.com', + raw: { + uid: '12345', + cn: 'John Doe', + }, + }, + signingKey: oktaPrivateKey, + publicKey: oktaPublicKey, + encryptionKey: oktaPublicKey, + }; + + const xml = await createSAMLResponse(options); + + assert.ok(xml.includes(''), 'Should contain CipherValue'); + + assert.ok(!xml.includes('user@example.com'), 'Sensitive data (email) should be encrypted and not visible'); +}); + +it('should sign the Response element itself if signResponse is true', async () => { + const options = { + audience: 'http://sp.example.com', + issuer: 'http://idp.example.com', + acsUrl: 'http://sp.example.com/consume', + requestId: 'ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685', + claims: { + email: 'user@example.com', + raw: { + uid: '12345', + cn: 'John Doe', + }, + }, + signingKey: oktaPrivateKey, + publicKey: oktaPublicKey, + signResponse: true, + }; + + const xml = await createSAMLResponse(options); + + const match = xml.match(/ID="(_[a-f0-9]+)"/); + const responseId = match ? match[1] : null; + + assert.ok(responseId, 'Response ID should be present'); + assert.ok(xml.includes(`URI="#${responseId}"`), 'Signature should reference the Response ID'); +}); + +it('should auto-generate requestId if not provided', async () => { + const options = { + audience: 'http://sp.example.com', + issuer: 'http://idp.example.com', + acsUrl: 'http://sp.example.com/consume', + claims: { + email: 'user@example.com', + raw: { + uid: '12345', + cn: 'John Doe', + }, + }, + signingKey: oktaPrivateKey, + publicKey: oktaPublicKey + }; + + const xml = await createSAMLResponse(options); + assert.ok(xml.match(/InResponseTo="_[a-f0-9]+"/), 'Should auto-generate InResponseTo attribute'); }); diff --git a/test/lib/sign.spec.ts b/test/lib/sign.spec.ts index 4a535c5..9f9b916 100644 --- a/test/lib/sign.spec.ts +++ b/test/lib/sign.spec.ts @@ -9,16 +9,16 @@ const publicKey = fs.readFileSync('./test/assets/certificates/oktaPublicKey.crt' describe('sign.ts', function () { it('should sign valid XML', function () { - const signed = sign(validXml, signingKey, publicKey, '/*[local-name(.)="Assertion"]'); + const signed = sign(validXml, {privateKey: signingKey, publicKey, sigLocation: '/*[local-name(.)="Assertion"]'}); assert(signed); assert(signed.includes('Signature')); }); it('should throw error if xml is missing', function () { - assert.throws(() => sign('', signingKey, publicKey, ''), /Please specify xml/); + assert.throws(() => sign('', {privateKey: signingKey, publicKey, sigLocation: ''}), /Please specify xml/); }); it('should throw error if signingKey is missing', function () { - assert.throws(() => sign(validXml, '', publicKey, ''), /Please specify signingKey/); + assert.throws(() => sign(validXml, {privateKey: '', publicKey, sigLocation: ''}), /Please specify signingKey/); }); }); diff --git a/test/lib/validateSignature.spec.ts b/test/lib/validateSignature.spec.ts index ece5387..2928b2a 100644 --- a/test/lib/validateSignature.spec.ts +++ b/test/lib/validateSignature.spec.ts @@ -219,7 +219,7 @@ function generateXML() { let xml = xmlbuilder.create(samlReq).end({}); if (signingKey) { - xml = sign(xml, signingKey, publicKey, authnXPath); + xml = sign(xml,{privateKey: signingKey, publicKey, sigLocation: authnXPath}); } return xml; }