Skip to content

Commit 187f8ae

Browse files
authored
Require document signature; add option disable (#83)
1 parent 2f2f11d commit 187f8ae

File tree

5 files changed

+66
-11
lines changed

5 files changed

+66
-11
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const saml = new SAML(options);
6060
- `allowCreate`: grants permission to the identity provider to create a new subject identifier (default: `true`)
6161
- `spNameQualifier`: optionally specifies that the assertion subject's identifier be returned (or created) in the namespace of another service provider, or in the namespace of an affiliation of service providers
6262
- `wantAssertionsSigned`: if truthy, add `WantAssertionsSigned="true"` to the metadata, to specify that the IdP should always sign the assertions.
63+
- `wantAuthnResponseSigned`: if true, require that all incoming authentication response messages be signed at the top level, not just at the assertions. It is on by default.
6364
- `acceptedClockSkewMs`: Time in milliseconds of skew that is acceptable between client and server when checking `OnBefore` and `NotOnOrAfter` assertion condition validity timestamps. Setting to `-1` will disable checking these conditions entirely. Default is `0`.
6465
- `maxAssertionAgeMs`: Amount of time after which the framework should consider an assertion expired. If the limit imposed by this variable is stricter than the limit imposed by `NotOnOrAfter`, this limit will be used when determining if an assertion is expired.
6566
- `attributeConsumingServiceIndex`: optional `AttributeConsumingServiceIndex` attribute to add to AuthnRequest to instruct the IDP which attribute set to attach to the response ([link](http://blog.aniljohn.com/2014/01/data-minimization-front-channel-saml-attribute-requests.html))

src/saml.ts

+6
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ class SAML {
7777
assertBooleanIfPresent(ctorOptions.disableRequestAcsUrl);
7878
assertBooleanIfPresent(ctorOptions.allowCreate);
7979
assertBooleanIfPresent(ctorOptions.wantAssertionsSigned);
80+
assertBooleanIfPresent(ctorOptions.wantAuthnResponseSigned);
8081
assertBooleanIfPresent(ctorOptions.signMetadata);
8182

8283
const options: SamlOptions = {
@@ -102,6 +103,7 @@ class SAML {
102103
allowCreate: ctorOptions.allowCreate ?? true,
103104
spNameQualifier: ctorOptions.spNameQualifier,
104105
wantAssertionsSigned: ctorOptions.wantAssertionsSigned ?? false,
106+
wantAuthnResponseSigned: ctorOptions.wantAuthnResponseSigned ?? true,
105107
authnContext: ctorOptions.authnContext ?? [
106108
"urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport",
107109
],
@@ -692,6 +694,10 @@ class SAML {
692694
validSignature = true;
693695
}
694696

697+
if (this.options.wantAuthnResponseSigned === true && validSignature === false) {
698+
throw new Error("Invalid document signature");
699+
}
700+
695701
const assertions = xpath.selectElements(
696702
doc,
697703
"/*[local-name()='Response']/*[local-name()='Assertion']"

src/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export interface SamlOptions extends Partial<SamlSigningOptions>, MandatorySamlO
135135
audience: string | false;
136136
scoping?: SamlScopingConfig;
137137
wantAssertionsSigned: boolean;
138+
wantAuthnResponseSigned: boolean;
138139
maxAssertionAgeMs: number;
139140
generateUniqueId: () => string;
140141
signMetadata: boolean;

test/test-signatures.spec.ts

+36-9
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const cert = fs.readFileSync(__dirname + "/static/cert.pem", "ascii");
1010

1111
describe("Signatures", function () {
1212
const INVALID_SIGNATURE = "Invalid signature",
13+
INVALID_DOCUMENT_SIGNATURE = "Invalid document signature",
1314
INVALID_ENCRYPTED_SIGNATURE = "Invalid signature from encrypted assertion",
1415
INVALID_TOO_MANY_TRANSFORMS = "Invalid signature, too many transforms",
1516
createBody = (pathToXml: string) => ({
@@ -22,18 +23,26 @@ describe("Signatures", function () {
2223
options: Partial<SamlConfig> = {}
2324
) => {
2425
//== Instantiate new instance before every test
25-
const samlObj = new SAML({ cert, issuer: options.issuer ?? "onesaml_login", ...options });
26+
const samlObj = new SAML({
27+
cert,
28+
issuer: options.issuer ?? "onesaml_login",
29+
wantAuthnResponseSigned: false,
30+
...options,
31+
});
32+
2633
//== Spy on `validateSignature` to be able to count how many times it has been called
2734
const validateSignatureSpy = sinon.spy(xml, "validateSignature");
2835

29-
//== Run the test in `func`
30-
await assert.rejects(samlObj.validatePostResponseAsync(samlResponseBody), {
31-
message: shouldErrorWith || "SAML assertion expired: clocks skewed too much",
32-
});
33-
//== Assert times `validateSignature` was called
34-
expect(validateSignatureSpy.callCount).to.equal(amountOfSignatureChecks);
35-
36-
validateSignatureSpy.restore();
36+
try {
37+
//== Run the test in `func`
38+
await assert.rejects(samlObj.validatePostResponseAsync(samlResponseBody), {
39+
message: shouldErrorWith || "SAML assertion expired: clocks skewed too much",
40+
});
41+
//== Assert times `validateSignature` was called
42+
expect(validateSignatureSpy.callCount).to.equal(amountOfSignatureChecks);
43+
} finally {
44+
validateSignatureSpy.restore();
45+
}
3746
},
3847
testOneResponse = (
3948
pathToXml: string,
@@ -57,6 +66,12 @@ describe("Signatures", function () {
5766
"R1A - both signed => valid",
5867
testOneResponse("/valid/response.root-signed.assertion-signed.xml", false, 1)
5968
);
69+
it(
70+
"R1A - root signed, root signiture required => valid",
71+
testOneResponse("/valid/response.root-signed.assertion-unsigned.xml", false, 1, {
72+
issuer: "onesaml_login",
73+
})
74+
);
6075
it(
6176
"R1A - root signed => valid",
6277
testOneResponse("/valid/response.root-signed.assertion-unsigned.xml", false, 1)
@@ -67,6 +82,18 @@ describe("Signatures", function () {
6782
);
6883

6984
//== INVALID
85+
it(
86+
"R1A - root not signed, but required, asrt signed => error",
87+
testOneResponse(
88+
"/valid/response.root-unsigned.assertion-signed.xml",
89+
INVALID_DOCUMENT_SIGNATURE,
90+
1,
91+
{
92+
wantAuthnResponseSigned: true,
93+
issuer: "onesaml_login",
94+
}
95+
)
96+
);
7097
it(
7198
"R1A - none signed => error",
7299
testOneResponse(

test/tests.spec.ts

+22-2
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,7 @@ describe("node-saml /", function () {
700700
const samlObj = new SAML({
701701
cert: "-----BEGIN CERTIFICATE-----" + TEST_CERT + "-----END CERTIFICATE-----",
702702
issuer: "onesaml_login",
703+
wantAuthnResponseSigned: false,
703704
});
704705
await assert.rejects(samlObj.validatePostResponseAsync(container), {
705706
message: /Responder.*Required NameID format not supported/,
@@ -711,7 +712,11 @@ describe("node-saml /", function () {
711712
'<?xml version="1.0" encoding="UTF-8"?><saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="http://localhost/browserSamlLogin" ID="_6a377272c8662561acf1056274ef3f81" InResponseTo="_4324fb0d00661146f7dc" IssueInstant="2014-07-02T18:16:31.278Z" Version="2.0"><saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">https://idp.testshib.org/idp/shibboleth</saml2:Issuer><saml2p:Status><saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Responder"><saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:InvalidNameIDPolicy"/></saml2p:StatusCode></saml2p:Status></saml2p:Response>';
712713
const base64xml = Buffer.from(xml).toString("base64");
713714
const container = { SAMLResponse: base64xml };
714-
const samlObj = new SAML({ cert: FAKE_CERT, issuer: "onesaml_login" });
715+
const samlObj = new SAML({
716+
cert: FAKE_CERT,
717+
issuer: "onesaml_login",
718+
wantAuthnResponseSigned: false,
719+
});
715720
await assert.rejects(samlObj.validatePostResponseAsync(container), {
716721
message: /Responder.*InvalidNameIDPolicy/,
717722
});
@@ -911,6 +916,7 @@ describe("node-saml /", function () {
911916
cert: TEST_CERT,
912917
validateInResponseTo,
913918
issuer: "onesaml_login",
919+
wantAuthnResponseSigned: false,
914920
};
915921
const samlObj = new SAML(samlConfig);
916922

@@ -948,11 +954,13 @@ describe("node-saml /", function () {
948954
cert: TEST_CERT,
949955
audience: false,
950956
issuer: "onesaml_login",
957+
wantAuthnResponseSigned: false,
951958
};
952959
const noAudienceSamlConfig: SamlConfig = {
953960
entryPoint: "https://app.onelogin.com/trust/saml2/http-post/sso/371755",
954961
cert: TEST_CERT,
955962
issuer: "onesaml_login",
963+
wantAuthnResponseSigned: false,
956964
};
957965
const noCertSamlConfig: SamlConfig = {
958966
entryPoint: "https://app.onelogin.com/trust/saml2/http-post/sso/371755",
@@ -1199,6 +1207,7 @@ describe("node-saml /", function () {
11991207
cert: [ALT_TEST_CERT, TEST_CERT],
12001208
audience: false,
12011209
issuer: "onesaml_login",
1210+
wantAuthnResponseSigned: false,
12021211
};
12031212
const xml =
12041213
'<samlp:Response xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="R689b0733bccca22a137e3654830312332940b1be" Version="2.0" IssueInstant="2014-05-28T00:16:08Z" Destination="{recipient}" InResponseTo="_a6fc46be84e1e3cf3c50"><saml:Issuer>https://app.onelogin.com/saml/metadata/371755</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>' +
@@ -1222,6 +1231,7 @@ describe("node-saml /", function () {
12221231
},
12231232
audience: false,
12241233
issuer: "onesaml_login",
1234+
wantAuthnResponseSigned: false,
12251235
};
12261236
const xml =
12271237
'<samlp:Response xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="R689b0733bccca22a137e3654830312332940b1be" Version="2.0" IssueInstant="2014-05-28T00:16:08Z" Destination="{recipient}" InResponseTo="_a6fc46be84e1e3cf3c50"><saml:Issuer>https://app.onelogin.com/saml/metadata/371755</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>' +
@@ -1245,6 +1255,7 @@ describe("node-saml /", function () {
12451255
},
12461256
audience: false,
12471257
issuer: "onesaml_login",
1258+
wantAuthnResponseSigned: false,
12481259
};
12491260
const xml =
12501261
'<samlp:Response xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="R689b0733bccca22a137e3654830312332940b1be" Version="2.0" IssueInstant="2014-05-28T00:16:08Z" Destination="{recipient}" InResponseTo="_a6fc46be84e1e3cf3c50"><saml:Issuer>https://app.onelogin.com/saml/metadata/371755</saml:Issuer><samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>' +
@@ -1357,7 +1368,11 @@ describe("node-saml /", function () {
13571368
"</Response>";
13581369
const base64xml = Buffer.from(xml).toString("base64");
13591370
const container = { SAMLResponse: base64xml };
1360-
const samlObj = new SAML({ cert: TEST_CERT, issuer: "onesaml_login" });
1371+
const samlObj = new SAML({
1372+
cert: TEST_CERT,
1373+
issuer: "onesaml_login",
1374+
wantAuthnResponseSigned: false,
1375+
});
13611376
await assert.rejects(samlObj.validatePostResponseAsync(container), {
13621377
message: "Invalid signature",
13631378
});
@@ -1820,6 +1835,7 @@ describe("node-saml /", function () {
18201835
validateInResponseTo,
18211836
audience: false,
18221837
issuer: "onesaml_login",
1838+
wantAuthnResponseSigned: false,
18231839
};
18241840
const samlObj = new SAML(samlConfig);
18251841

@@ -1939,6 +1955,7 @@ describe("node-saml /", function () {
19391955
validateInResponseTo,
19401956
audience: false,
19411957
issuer: "onesaml_login",
1958+
wantAuthnResponseSigned: false,
19421959
};
19431960
const samlObj = new SAML(samlConfig);
19441961

@@ -1999,6 +2016,7 @@ describe("node-saml /", function () {
19992016
validateInResponseTo: ValidateInResponseTo.never,
20002017
audience: false,
20012018
issuer: "onesaml_login",
2019+
wantAuthnResponseSigned: false,
20022020
};
20032021
const samlObj = new SAML(samlConfig);
20042022

@@ -2051,6 +2069,7 @@ describe("node-saml /", function () {
20512069
cert: TEST_CERT,
20522070
audience: false,
20532071
issuer: "onesaml_login",
2072+
wantAuthnResponseSigned: false,
20542073
};
20552074
let fakeClock: sinon.SinonFakeTimers;
20562075

@@ -2197,6 +2216,7 @@ describe("node-saml /", function () {
21972216
acceptedClockSkewMs: -1,
21982217
audience: false,
21992218
issuer: "onesaml_login",
2219+
wantAuthnResponseSigned: false,
22002220
};
22012221
const samlObj = new SAML(samlConfig);
22022222

0 commit comments

Comments
 (0)