diff --git a/lib/response.ts b/lib/response.ts index 4c207bd2..b536ae87 100644 --- a/lib/response.ts +++ b/lib/response.ts @@ -283,6 +283,13 @@ const randomId = () => { return '_' + crypto.randomBytes(10).toString('hex'); }; +const flattenedArray = (arr: string[]) => { + const escArr = arr.map((val) => { + return val.replace(/,/g, '%2C'); + }); + return [escArr.join(',')]; +}; + // Create SAML Response and sign it const createSAMLResponse = async ({ audience, @@ -292,6 +299,7 @@ const createSAMLResponse = async ({ requestId, privateKey, publicKey, + flattenArray = false, }: { audience: string; issuer: string; @@ -300,6 +308,7 @@ const createSAMLResponse = async ({ requestId: string; privateKey: string; publicKey: string; + flattenArray?: boolean; }): Promise => { const authDate = new Date(); const authTimestamp = authDate.toISOString(); @@ -378,16 +387,25 @@ const createSAMLResponse = async ({ }, 'saml:AttributeStatement': { '@xmlns:saml': 'urn:oasis:names:tc:SAML:2.0:assertion', - 'saml:Attribute': Object.keys(claims.raw).map((attributeName) => { + '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': 'urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified', - 'saml:AttributeValue': { - '@xmlns:xs': 'http://www.w3.org/2001/XMLSchema', - '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - '@xsi:type': 'xs:string', - '#text': claims.raw[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, + }; + }), }; }), }, diff --git a/test/lib/response.spec.ts b/test/lib/response.spec.ts index 1f4c0ace..2f03df48 100644 --- a/test/lib/response.spec.ts +++ b/test/lib/response.spec.ts @@ -1,5 +1,5 @@ import assert from 'assert'; -import { parse, parseIssuer, validate } from '../../lib/response'; +import { parse, parseIssuer, validate, createSAMLResponse } from '../../lib/response'; import fs from 'fs'; const rawResponse = fs.readFileSync('./test/assets/saml20.validResponseSignedMessage.xml').toString(); @@ -23,6 +23,9 @@ const invalidToken = fs.readFileSync('./test/assets/saml20.invalidToken.xml').to const invalidWrappedToken = fs.readFileSync('./test/assets/saml20.invalidWrappedToken.xml').toString(); const validAssertion = fs.readFileSync('./test/assets/saml20.validAssertion.xml').toString(); +const oktaPublicKey = fs.readFileSync('./test/assets/certificates/oktaPublicKey.crt').toString(); +const oktaPrivateKey = fs.readFileSync('./test/assets/certificates/oktaPrivateKey.pem').toString(); + describe('response.ts', function () { it('RAW response ok', async function () { const response = await parse(rawResponse); @@ -242,4 +245,61 @@ describe('response.ts', function () { 'permanent-id' ); }); + + it('Should create a SAML response', async function () { + const json = { + audience: 'http://sp.example.com/demo1/metadata.php', + issuer: 'http://idp.example.com/metadata.php', + acsUrl: 'http://sp.example.com/demo1/index.php?acs', + claims: { + raw: { + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier': + '_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': 'jackson@example.com', + groups: ['admin,owner', 'user'], + }, + }, + requestId: 'ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685', + privateKey: oktaPrivateKey, + publicKey: oktaPublicKey, + }; + + const response = await createSAMLResponse(json); + + const parsed = await parse(response); + + assert.strictEqual(parsed.issuer, json.issuer); + assert.strictEqual(parsed.audience, json.audience); + assert.strictEqual(parsed.sessionIndex, json.requestId); + assert.deepStrictEqual(parsed.claims, json.claims.raw); + }); + + it('Should create a SAML response, flattenArray=true', async function () { + const json = { + audience: 'http://sp.example.com/demo1/metadata.php', + issuer: 'http://idp.example.com/metadata.php', + acsUrl: 'http://sp.example.com/demo1/index.php?acs', + claims: { + raw: { + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier': + '_ce3d2948b4cf20146dee0a0b3dd6f69b6cf86f62d7', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress': 'jackson@example.com', + groups: ['admin,owner', 'user'], + }, + }, + requestId: 'ONELOGIN_4fee3b046395c4e751011e97f8900b5273d56685', + privateKey: oktaPrivateKey, + publicKey: oktaPublicKey, + flattenArray: true, + }; + + const response = await createSAMLResponse(json); + + const parsed = await parse(response); + + assert.strictEqual(parsed.issuer, json.issuer); + assert.strictEqual(parsed.audience, json.audience); + assert.strictEqual(parsed.sessionIndex, json.requestId); + assert.deepStrictEqual(parsed.claims, { ...json.claims.raw, groups: 'admin%2Cowner,user' }); + }); });