Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: add facility in config to add <Extensions> element in SAML request #11

Merged
merged 23 commits into from Oct 27, 2021
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
54d40b0
test(samlRequst.spec.js): add test to check extensions element in sam…
kdhttps Jul 6, 2021
f632cfa
feat(saml.ts): add config and saml extensions element in saml request
kdhttps Jul 6, 2021
76b6b45
docs(README.md): add docs for samlExtensions config
kdhttps Jul 6, 2021
d6bf124
docs(README.md): add docs for samlExtensions config
kdhttps Jul 6, 2021
2fea050
feat(utility.ts): add test and validation, element should have namespace
kdhttps Aug 4, 2021
d3ed7ec
typo fix.
markstos Aug 4, 2021
0d5e644
test(utility.Test.Js): fix type and update mocha config
kdhttps Aug 5, 2021
6fd46a9
test(samlrequest.Spec.Ts): fix test case values and update readme.md
kdhttps Aug 5, 2021
c73d46a
docs(readme.md): update extensions example in readme.md
kdhttps Aug 5, 2021
6d6f096
fix(utility.Test.Ts): remove validation on extensions value
kdhttps Aug 11, 2021
09ae19d
fix(types.Ts): remove saml schema exception class
kdhttps Aug 11, 2021
40cf837
fix(saml.Ts): add a check saml extensions should be object
kdhttps Aug 11, 2021
56dc546
fix(types.Ts): update type for saml extensions
kdhttps Aug 11, 2021
ca9d940
refactor(saml.Ts): refactor check saml extensions type
kdhttps Aug 11, 2021
9d2da8c
Refactor tests to throw more actionable errors and use promises
cjbarth Aug 27, 2021
939865e
test(samlRequest.spec.ts): add object check test
kdhttps Aug 31, 2021
afe78d1
refactor(saml.ts): rename saml extension property
kdhttps Sep 7, 2021
283d84c
test(tests.spec.ts): test saml logout extensions
kdhttps Sep 13, 2021
5c1dcb0
feat(saml.ts): add logout requrst extensions feature
kdhttps Sep 13, 2021
71d9fe5
docs(README.md): docs about logout requrst extensions feature
kdhttps Sep 13, 2021
9b104ac
test(tests.spec.ts): remove callback and handle using promise
kdhttps Sep 15, 2021
7c8eca9
Merge branch 'master' of github.com:kdhttps/node-saml into feature_sa…
kdhttps Oct 27, 2021
070f7f4
refactor(tests): refactor logout test to use profile type
kdhttps Oct 27, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Expand Up @@ -101,6 +101,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).
Expand Down
25 changes: 25 additions & 0 deletions src/saml.ts
Expand Up @@ -265,6 +265,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",
Expand Down Expand Up @@ -376,13 +387,27 @@ class SAML {
"@xmlns:saml": "urn:oasis:names:tc:SAML:2.0:assertion",
"#text": this.options.issuer,
},
"samlp:Extensions": {},
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you add the empty value here and then delete it later when not needed? The pattern you used previously just adds the value if you need it and skips the extra logic to delete if not needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is because strick type of NameID here

"saml:NameID": XMLInput;

and samlp:Extensions element should be placed before NameID. There is other way also but I choose and Like to keep this one for safe type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cjbarth once you get time, please review it

"saml:NameID": {
"@Format": user!.nameIDFormat,
"#text": user!.nameID,
},
},
} 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;
}
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Expand Up @@ -123,6 +123,8 @@ export interface SamlOptions extends Partial<SamlSigningOptions>, MandatorySamlO

// extras
disableRequestAcsUrl: boolean;
samlAuthnRequestExtensions?: Record<string, unknown>;
samlLogoutRequestExtensions?: Record<string, unknown>;
}

export interface StrategyOptions {
Expand Down
121 changes: 121 additions & 0 deletions test/samlRequest.spec.ts
@@ -0,0 +1,121 @@
import { SAML } from "../src/saml";
cjbarth marked this conversation as resolved.
Show resolved Hide resolved
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(/<!DOCTYPE html>[^]*<input.*name="SAMLRequest"[^]*<\/html>/);
const samlRequestMatchValues = formBody.match(/<input.*name="SAMLRequest" value="([^"]*)"/);
const encodedSamlRequest = assertRequired(samlRequestMatchValues?.[1]);

let buffer = Buffer.from(encodedSamlRequest, "base64");
if (!config.skipRequestCompression) {
buffer = zlib.inflateRawSync(buffer);
}

return parseStringPromise(buffer.toString());
})
.then((doc) => {
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",
});
});
});
90 changes: 90 additions & 0 deletions test/tests.spec.ts
Expand Up @@ -136,6 +136,96 @@ 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);

await assert.rejects(
samlObj._generateLogoutRequest({
nameIDFormat: "foo",
nameID: "bar",
}),
{
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 logoutRequestPromise = samlObj._generateLogoutRequest({
nameIDFormat: "foo",
nameID: "bar",
});

logoutRequestPromise
.then(function (logoutRequest) {
parseString(logoutRequest, function (err, doc) {
kdhttps marked this conversation as resolved.
Show resolved Hide resolved
try {
delete doc["samlp:LogoutRequest"]["$"]["ID"];
delete doc["samlp:LogoutRequest"]["$"]["IssueInstant"];
doc.should.eql(expectedRequest);
done();
} catch (err2) {
done(err2);
}
});
})
.catch((err: Error) => {
done(err);
});
} catch (err3) {
done(err3);
}
});

it("_generateLogoutResponse", function (done) {
const expectedResponse = {
"samlp:LogoutResponse": {
Expand Down