diff --git a/README.md b/README.md index 681c7f71..5371a083 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,41 @@ const saml = new SAML(options); - `additionalLogoutParams`: dictionary of additional query params to add to 'logout' requests - `logoutCallbackUrl`: The value with which to populate the `Location` attribute in the `SingleLogoutService` elements in the generated service provider metadata. +- **SAML Authn Request Extensions** +- `samlAuthnRequestExtensions`: Optional, The SAML extension provides a more flexible structure for expressing which combination of Attributes are requested by service providers in comparison to the existing mechanisms, [More about extensions](https://docs.oasis-open.org/security/saml-protoc-req-attr-req/v1.0/saml-protoc-req-attr-req-v1.0.html). There are many possible values for the `samlAuthnRequestExtensions` element. It accepts fully customize [XMLBuilder](https://www.npmjs.com/package/xmlbuilder) type. + +```javascript +// Example +samlAuthnRequestExtensions: { + "md:RequestedAttribute": { + "@isRequired": "true", + "@Name": "Lastname", + "@xmlns:md": "urn:oasis:names:tc:SAML:2.0:metadata" + }, + vetuma: { + "@xmlns": "urn:vetuma:SAML:2.0:extensions", + LG: { + "#text": "sv", + }, + }, +}, +``` + +- **SAML Logout Request Extensions** +- `samlLogoutRequestExtensions`: Optional, The SAML extension provides a more flexible structure for expressing which combination of Attributes are requested by service providers in comparison to the existing mechanisms, [More about extensions](https://docs.oasis-open.org/security/saml-protoc-req-attr-req/v1.0/saml-protoc-req-attr-req-v1.0.html). There are many possible values for the `samlLogoutRequestExtensions` element. It accepts fully customize [XMLBuilder](https://www.npmjs.com/package/xmlbuilder) type. + +```javascript +// Example +samlLogoutRequestExtensions: { + vetuma: { + "@xmlns": "urn:vetuma:SAML:2.0:extensions", + LG: { + "#text": "sv", + }, + }, +}, +``` + ### generateServiceProviderMetadata( decryptionCert, signingCert ) As a convenience, the strategy object exposes a `generateServiceProviderMetadata` method which will generate a service provider metadata document suitable for supplying to an identity provider. This method will only work on strategies which are configured with a `callbackUrl` (since the relative path for the callback is not sufficient information to generate a complete metadata document). diff --git a/src/saml.ts b/src/saml.ts index 0808b324..49843b1b 100644 --- a/src/saml.ts +++ b/src/saml.ts @@ -261,6 +261,17 @@ class SAML { request["samlp:AuthnRequest"]["@AssertionConsumerServiceURL"] = this.getCallbackUrl(host); } + const samlAuthnRequestExtensions = this.options.samlAuthnRequestExtensions; + if (samlAuthnRequestExtensions != null) { + if (typeof samlAuthnRequestExtensions != "object") { + throw new TypeError("samlAuthnRequestExtensions should be Object"); + } + request["samlp:AuthnRequest"]["samlp:Extensions"] = { + "@xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol", + ...samlAuthnRequestExtensions, + }; + } + if (this.options.identifierFormat != null) { request["samlp:AuthnRequest"]["samlp:NameIDPolicy"] = { "@xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol", @@ -372,6 +383,7 @@ class SAML { "@xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion", "#text": this.options.issuer, }, + "samlp:Extensions": {}, "saml:NameID": { "@Format": user.nameIDFormat, "#text": user.nameID, @@ -379,6 +391,19 @@ class SAML { }, } as LogoutRequestXML; + const samlLogoutRequestExtensions = this.options.samlLogoutRequestExtensions; + if (samlLogoutRequestExtensions != null) { + if (typeof samlLogoutRequestExtensions != "object") { + throw new TypeError("samlLogoutRequestExtensions should be Object"); + } + request["samlp:LogoutRequest"]["samlp:Extensions"] = { + "@xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol", + ...samlLogoutRequestExtensions, + }; + } else { + delete request["samlp:LogoutRequest"]["samlp:Extensions"]; + } + if (user.nameQualifier != null) { request["samlp:LogoutRequest"]["saml:NameID"]["@NameQualifier"] = user.nameQualifier; } diff --git a/src/types.ts b/src/types.ts index 36fe4867..018e4d15 100644 --- a/src/types.ts +++ b/src/types.ts @@ -132,6 +132,8 @@ export interface SamlOptions extends Partial, MandatorySamlO // extras disableRequestAcsUrl: boolean; + samlAuthnRequestExtensions?: Record; + samlLogoutRequestExtensions?: Record; } export interface StrategyOptions { diff --git a/test/samlRequest.spec.ts b/test/samlRequest.spec.ts new file mode 100644 index 00000000..52525417 --- /dev/null +++ b/test/samlRequest.spec.ts @@ -0,0 +1,121 @@ +import { SAML } from "../src/saml"; +import { FAKE_CERT } from "./types"; +import * as zlib from "zlib"; +import * as should from "should"; +import { parseStringPromise } from "xml2js"; +import { assertRequired } from "../src/utility"; +import { SamlConfig } from "../src/types"; +import * as assert from "assert"; + +describe("SAML request", function () { + it("Config with Extensions", function () { + const config: SamlConfig = { + entryPoint: "https://wwwexampleIdp.com/saml", + cert: FAKE_CERT, + samlAuthnRequestExtensions: { + "md:RequestedAttribute": { + "@isRequired": "true", + "@Name": "Lastname", + "@xmlns:md": "urn:oasis:names:tc:SAML:2.0:metadata", + }, + vetuma: { + "@xmlns": "urn:vetuma:SAML:2.0:extensions", + LG: { + "#text": "sv", + }, + }, + }, + }; + + const result = { + "samlp:AuthnRequest": { + $: { + "xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol", + Version: "2.0", + ProtocolBinding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + AssertionConsumerServiceURL: "http://localhost/saml/consume", + Destination: "https://wwwexampleIdp.com/saml", + }, + "saml:Issuer": [ + { _: "onelogin_saml", $: { "xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion" } }, + ], + "samlp:Extensions": [ + { + $: { + "xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol", + }, + "md:RequestedAttribute": [ + { + $: { + isRequired: "true", + Name: "Lastname", + "xmlns:md": "urn:oasis:names:tc:SAML:2.0:metadata", + }, + }, + ], + vetuma: [ + { + $: { xmlns: "urn:vetuma:SAML:2.0:extensions" }, + LG: ["sv"], + }, + ], + }, + ], + "samlp:NameIDPolicy": [ + { + $: { + "xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol", + Format: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + AllowCreate: "true", + }, + }, + ], + "samlp:RequestedAuthnContext": [ + { + $: { "xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol", Comparison: "exact" }, + "saml:AuthnContextClassRef": [ + { + _: "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", + $: { "xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion" }, + }, + ], + }, + ], + }, + }; + + const oSAML = new SAML(config); + oSAML + .getAuthorizeFormAsync("http://localhost/saml/consume") + .then((formBody) => { + formBody.should.match(/[^]*/); + const samlRequestMatchValues = formBody.match(/ { + delete doc["samlp:AuthnRequest"]["$"]["ID"]; + delete doc["samlp:AuthnRequest"]["$"]["IssueInstant"]; + doc.should.eql(result); + }); + }); + + it("should throw error when samlAuthnRequestExtensions is not a object", async function () { + const config: any = { + entryPoint: "https://wwwexampleIdp.com/saml", + cert: FAKE_CERT, + samlAuthnRequestExtensions: "anyvalue", + }; + + const oSAML = new SAML(config); + await assert.rejects(oSAML.getAuthorizeFormAsync("http://localhost/saml/consume"), { + message: "samlAuthnRequestExtensions should be Object", + }); + }); +}); diff --git a/test/tests.spec.ts b/test/tests.spec.ts index 8b548efd..2ed58b3b 100644 --- a/test/tests.spec.ts +++ b/test/tests.spec.ts @@ -2,10 +2,10 @@ import { SAML } from "../src/saml"; import url = require("url"); import * as querystring from "querystring"; -import { parseString } from "xml2js"; +import { parseString, parseStringPromise } from "xml2js"; import * as fs from "fs"; import * as sinon from "sinon"; -import { SamlConfig } from "../src/types.js"; +import { Profile, SamlConfig } from "../src/types.js"; import { RacComparision } from "../src/types.js"; import * as should from "should"; import assert = require("assert"); @@ -134,6 +134,95 @@ describe("node-saml /", function () { } }); + it("_generateLogoutRequest should throw error when samlLogoutRequestExtensions is not a object", async function () { + const config: any = { + entryPoint: "https://wwwexampleIdp.com/saml", + cert: FAKE_CERT, + samlLogoutRequestExtensions: "anyvalue", + }; + const samlObj = new SAML(config); + const profile: Profile = { + issuer: "https://test,com", + nameIDFormat: "foo", + nameID: "bar", + }; + await assert.rejects(samlObj._generateLogoutRequest(profile), { + message: "samlLogoutRequestExtensions should be Object", + }); + }); + + it("_generateLogoutRequest should return extensions element when samlLogoutRequestExtensions is configured", function (done) { + try { + const expectedRequest = { + "samlp:LogoutRequest": { + $: { + "xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol", + "xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion", + //ID: '_85ba0a112df1ffb57805', + Version: "2.0", + //IssueInstant: '2014-05-29T03:32:23Z', + Destination: "foo", + }, + "saml:Issuer": [ + { _: "onelogin_saml", $: { "xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion" } }, + ], + "samlp:Extensions": [ + { + $: { + "xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol", + }, + vetuma: [ + { + $: { xmlns: "urn:vetuma:SAML:2.0:extensions" }, + LG: ["sv"], + }, + ], + }, + ], + "saml:NameID": [{ _: "bar", $: { Format: "foo" } }], + }, + }; + + const samlObj = new SAML({ + entryPoint: "foo", + cert: FAKE_CERT, + samlLogoutRequestExtensions: { + vetuma: { + "@xmlns": "urn:vetuma:SAML:2.0:extensions", + LG: { + "#text": "sv", + }, + }, + }, + }); + const profile: Profile = { + issuer: "https://test.com", + nameIDFormat: "foo", + nameID: "bar", + }; + const logoutRequestPromise = samlObj._generateLogoutRequest(profile); + + logoutRequestPromise + .then(function (logoutRequest) { + parseStringPromise(logoutRequest) + .then(function (doc) { + delete doc["samlp:LogoutRequest"]["$"]["ID"]; + delete doc["samlp:LogoutRequest"]["$"]["IssueInstant"]; + doc.should.eql(expectedRequest); + done(); + }) + .catch((err: Error) => { + done(err); + }); + }) + .catch((err: Error) => { + done(err); + }); + } catch (err3) { + done(err3); + } + }); + it("_generateLogoutResponse success", function (done) { const expectedResponse = { "samlp:LogoutResponse": {