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 9 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
6 changes: 3 additions & 3 deletions .mocharc.json
@@ -1,10 +1,10 @@
{
"diff": true,
"extension": "spec.ts",
"extension": ".ts",
kdhttps marked this conversation as resolved.
Show resolved Hide resolved
"package": "./package.json",
"recursive": true,
"reporter": "spec",
"require": ["choma", "ts-node/register"],
"files": "test/**/*.spec.ts",
"watch-files": "test/**/*.spec.ts"
"files": "test/**/*.[spec|test].ts",
"watch-files": "test/**/*.[spec|test].ts"
}
20 changes: 20 additions & 0 deletions README.md
Expand Up @@ -101,6 +101,26 @@ 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 Extensions**
- `samlExtensions`: 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 `samlExtensions` element. It accepts fully customize [XMLBuilder](https://www.npmjs.com/package/xmlbuilder) type.
cjbarth marked this conversation as resolved.
Show resolved Hide resolved

```javascript
// Example
samlExtensions: {
"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",
},
},
},
```

### 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
12 changes: 11 additions & 1 deletion src/saml.ts
Expand Up @@ -27,7 +27,7 @@ import {
XMLOutput,
} from "./types";
import { AuthenticateOptions, AuthorizeOptions } from "./passport-saml-types";
import { assertRequired } from "./utility";
import { assertRequired, validateSAMLExtensionsElement } from "./utility";
import {
buildXml2JsObject,
buildXmlBuilderObject,
Expand Down Expand Up @@ -265,6 +265,16 @@ class SAML {
request["samlp:AuthnRequest"]["@AssertionConsumerServiceURL"] = this.getCallbackUrl(host);
}

const samlExtensions = this.options.samlExtensions;
if (samlExtensions != null) {
cjbarth marked this conversation as resolved.
Show resolved Hide resolved
if (validateSAMLExtensionsElement(samlExtensions)) {
request["samlp:AuthnRequest"]["samlp:Extensions"] = {
"@xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
...samlExtensions,
};
}
}

if (this.options.identifierFormat != null) {
request["samlp:AuthnRequest"]["samlp:NameIDPolicy"] = {
"@xmlns:samlp": "urn:oasis:names:tc:SAML:2.0:protocol",
Expand Down
10 changes: 10 additions & 0 deletions src/types.ts
Expand Up @@ -123,6 +123,7 @@ export interface SamlOptions extends Partial<SamlSigningOptions>, MandatorySamlO

// extras
disableRequestAcsUrl: boolean;
samlExtensions?: any;
kdhttps marked this conversation as resolved.
Show resolved Hide resolved
}

export interface StrategyOptions {
Expand Down Expand Up @@ -157,3 +158,12 @@ export class ErrorWithXmlStatus extends Error {
super(message);
}
}

/**
* XML Schema Error on JSON Data
*/
export class JSONXMLSchemaError extends Error {
constructor(message: string) {
super(message);
}
}
59 changes: 59 additions & 0 deletions src/utility.ts
@@ -1,5 +1,6 @@
import { SamlSigningOptions } from "./types";
import { signXml } from "./xml";
import { JSONXMLSchemaError } from "./types";

export function assertRequired<T>(value: T | null | undefined, error?: string): T {
if (value === undefined || value === null || (typeof value === "string" && value.length === 0)) {
Expand All @@ -20,3 +21,61 @@ export function signXmlResponse(samlMessage: string, options: SamlSigningOptions
options
);
}

export function assertObjectAndNotEmpty(value: any, error?: string): any {
if (typeof value !== "object" || JSON.stringify(value) === JSON.stringify({})) {
throw new TypeError(error ?? "value does not exist");
} else {
return value;
}
}

export function validateSAMLExtensionsElement(jsonSAMLExtensionsElement: any): boolean {
jsonSAMLExtensionsElement = assertObjectAndNotEmpty(
jsonSAMLExtensionsElement,
`samlExtensions Element value should be object and not empty`
);
let result = true;
for (const subElementKey in jsonSAMLExtensionsElement) {
if (!validateXMLNamespace(subElementKey, jsonSAMLExtensionsElement[subElementKey])) {
result = false;
break;
}
}
return result;
}

/**
* Validate the XMLBuilder input JSON, wether it has proper namespece or not
* @param jsonXMLElementKey - Key of the element, need key to understand its namespace
* @param jsonXMLElement - Just JSON which we want to pass to xmlbuilder to make xml
* @returns boolean | JSONXMLSchemaError
*/
export function validateXMLNamespace(
cjbarth marked this conversation as resolved.
Show resolved Hide resolved
jsonXMLElementKey: string,
jsonXMLElementValue: any
): boolean | JSONXMLSchemaError {
jsonXMLElementKey = assertRequired(jsonXMLElementKey, `key should be defined`);

jsonXMLElementValue = assertObjectAndNotEmpty(
jsonXMLElementValue,
`${jsonXMLElementKey} XML Element value should be object and not empty`
);

// check namespace attribute
const elementKeyParts = jsonXMLElementKey.split(":");
let namespaceKey;
if (elementKeyParts && elementKeyParts.length > 1) {
const elementKeyPrefix = elementKeyParts[0];
namespaceKey = `@xmlns:${elementKeyPrefix}`;
} else {
namespaceKey = "@xmlns";
}
const namespaceValue = jsonXMLElementValue[namespaceKey];
if (!namespaceValue) {
throw new JSONXMLSchemaError(
`Namespace ${namespaceKey} is not defined for element ${jsonXMLElementKey}`
);
}
return true;
}
127 changes: 127 additions & 0 deletions test/samlRequest.spec.ts
@@ -0,0 +1,127 @@
import { SAML } from "../src/saml";
cjbarth marked this conversation as resolved.
Show resolved Hide resolved
import { FAKE_CERT, SamlCheck } from "./types";
import * as zlib from "zlib";
import * as should from "should";
import { parseString } from "xml2js";

const capturedSamlRequestChecks: SamlCheck[] = [
{
name: "Config with Extensions",
config: {
entryPoint: "https://wwwexampleIdp.com/saml",
cert: FAKE_CERT,
samlExtensions: {
"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",
},
},
},
},
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" },
},
],
},
],
},
},
},
];

describe("SAML request", function () {
function testForCheck(check: SamlCheck) {
return function (done: Mocha.Done) {
function helper(err: Error | null, samlRequest: Buffer) {
try {
should.not.exist(err);
parseString(samlRequest.toString(), function (err, doc) {
try {
should.not.exist(err);
delete doc["samlp:AuthnRequest"]["$"]["ID"];
delete doc["samlp:AuthnRequest"]["$"]["IssueInstant"];
doc.should.eql(check.result);
done();
} catch (err2) {
done(err2);
}
});
} catch (err3) {
done(err3);
}
}
const oSAML = new SAML(check.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 = samlRequestMatchValues && samlRequestMatchValues[1];

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const buffer = Buffer.from(encodedSamlRequest!, "base64");
if (check.config.skipRequestCompression) {
return helper(null, buffer);
} else {
return zlib.inflateRaw(buffer, helper);
}
});
};
}

capturedSamlRequestChecks.forEach(function (check) {
cjbarth marked this conversation as resolved.
Show resolved Hide resolved
it(check.name, testForCheck(check));
});
});