Skip to content

Commit

Permalink
Add ability to specify a maximum allowed assertion age
Browse files Browse the repository at this point in the history
  • Loading branch information
nickiepucel committed Mar 26, 2021
1 parent 2332a85 commit 352e059
Show file tree
Hide file tree
Showing 4 changed files with 95 additions and 3 deletions.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -128,6 +128,7 @@ type Profile = {
- `identifierFormat`: optional name identifier format to request from identity provider (default: `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress`)
- `wantAssertionsSigned`: if truthy, add `WantAssertionsSigned="true"` to the metadata, to specify that the IdP should always sign the assertions.
- `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`.
- `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.
- `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))
- `disableRequestedAuthnContext`: if truthy, do not request a specific authentication context. This is [known to help when authenticating against Active Directory](https://github.com/node-saml/passport-saml/issues/226) (AD FS) servers.
- `authnContext`: if truthy, name identifier format to request auth context (default: `urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport`); array of values is also supported
Expand Down
51 changes: 48 additions & 3 deletions src/passport-saml/saml.ts
Expand Up @@ -132,6 +132,7 @@ class SAML {
skipRequestCompression: ctorOptions.skipRequestCompression ?? false,
disableRequestAcsUrl: ctorOptions.disableRequestAcsUrl ?? false,
acceptedClockSkewMs: ctorOptions.acceptedClockSkewMs ?? 0,
maxAssertionAgeMs: ctorOptions.maxAssertionAgeMs ?? 0,
path: ctorOptions.path ?? "/saml/consume",
host: ctorOptions.host ?? "localhost",
issuer: ctorOptions.issuer ?? "onelogin_saml",
Expand Down Expand Up @@ -1116,11 +1117,17 @@ class SAML {
if (confirmData && confirmData.$) {
const subjectNotBefore = confirmData.$.NotBefore;
const subjectNotOnOrAfter = confirmData.$.NotOnOrAfter;
const maxTimeLimitMs = this.processMaxAgeAssertionTime(
this.options.maxAssertionAgeMs,
subjectNotOnOrAfter,
assertion.$.issueInstant
);

const subjErr = this.checkTimestampsValidityError(
nowMs,
subjectNotBefore,
subjectNotOnOrAfter
subjectNotOnOrAfter,
maxTimeLimitMs
);
if (subjErr) {
throw subjErr;
Expand Down Expand Up @@ -1167,10 +1174,16 @@ class SAML {
throw new Error(msg);
}
if (conditions && conditions.$) {
const maxTimeLimitMs = this.processMaxAgeAssertionTime(
this.options.maxAssertionAgeMs,
conditions.$.NotOnOrAfter,
assertion.$.IssueInstant
);
const conErr = this.checkTimestampsValidityError(
nowMs,
conditions.$.NotBefore,
conditions.$.NotOnOrAfter
conditions.$.NotOnOrAfter,
maxTimeLimitMs
);
if (conErr) throw conErr;
}
Expand Down Expand Up @@ -1231,7 +1244,12 @@ class SAML {
return { profile, loggedOut: false };
}

checkTimestampsValidityError(nowMs: number, notBefore: string, notOnOrAfter: string) {
checkTimestampsValidityError(
nowMs: number,
notBefore: string,
notOnOrAfter: string,
maxTimeLimitMs?: number
) {
if (this.options.acceptedClockSkewMs == -1) return null;

if (notBefore) {
Expand All @@ -1244,6 +1262,10 @@ class SAML {
if (nowMs - this.options.acceptedClockSkewMs >= notOnOrAfterMs)
return new Error("SAML assertion expired");
}
if (maxTimeLimitMs) {
if (nowMs - this.options.acceptedClockSkewMs >= maxTimeLimitMs)
return new Error("SAML assertion expired");
}

return null;
}
Expand Down Expand Up @@ -1461,6 +1483,29 @@ class SAML {
// https://github.com/node-saml/passport-saml/issues/431#issuecomment-718132752
return xml.replace(/\r\n?/g, "\n");
}

/**
* Process max age assertion and use it if it is more restrictive than the NotOnOrAfter age
* assertion received in the SAMLResponse.
*
* @param maxAssertionAgeMs Max time after IssueInstant that we will accept assertion, in Ms.
* @param notOnOrAfter Expiration provided in response.
* @param issueInstant Time when response was issued.
* @returns {*} The expiration time to be used, in Ms.
*/
processMaxAgeAssertionTime(
maxAssertionAgeMs: number,
notOnOrAfter: string,
issueInstant: string
): number {
const notOnOrAfterMs = Date.parse(notOnOrAfter);
if (maxAssertionAgeMs === 0) {
return notOnOrAfterMs;
}

const maxAssertionTimeMs = Date.parse(issueInstant) + maxAssertionAgeMs;
return maxAssertionTimeMs < notOnOrAfterMs ? maxAssertionTimeMs : notOnOrAfterMs;
}
}

export { SAML };
1 change: 1 addition & 0 deletions src/passport-saml/types.ts
Expand Up @@ -63,6 +63,7 @@ export interface SamlOptions extends SamlSigningOptions, MandatorySamlOptions {
audience?: string;
scoping?: SamlScopingConfig;
wantAssertionsSigned?: boolean;
maxAssertionAgeMs: number;

// InResponseTo Validation
validateInResponseTo: boolean;
Expand Down
45 changes: 45 additions & 0 deletions test/tests.spec.ts
Expand Up @@ -1658,6 +1658,51 @@ describe("passport-saml /", function () {
profile!.nameID!.should.startWith("ploer");
});

it("onelogin xml document with current time after MaxAssertionAge (minus default clock skew) should fail", async () => {
const xml =
'<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>' +
'<saml:Assertion xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" Version="2.0" ID="pfx3b63c7be-fe86-62fd-8cb5-16ab6273efaa" IssueInstant="2014-05-28T00:16:08Z"><saml:Issuer>https://app.onelogin.com/saml/metadata/371755</saml:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><ds:Reference URI="#pfx3b63c7be-fe86-62fd-8cb5-16ab6273efaa"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>DCnPTQYBb1hKspbe6fg1U3q8xn4=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>e0+aFomA0+JAY0f9tKqzIuqIVSSw7LiFUsneEDKPBWdiTz1sMdgr/2y1e9+rjaS2mRmCi/vSQLY3zTYz0hp6nJNU19+TWoXo9kHQyWT4KkeQL4Xs/gZ/AoKC20iHVKtpPps0IQ0Ml/qRoouSitt6Sf/WDz2LV/pWcH2hx5tv3xSw36hK2NQc7qw7r1mEXnvcjXReYo8rrVf7XHGGxNoRIEICUIi110uvsWemSXf0Z0dyb0FVYOWuSsQMDlzNpheADBifFO4UTfSEhFZvn8kVCGZUIwrbOhZ2d/+YEtgyuTg+qtslgfy4dwd4TvEcfuRzQTazeefprSFyiQckAXOjcw==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>' +
TEST_CERT +
'</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml:Subject><saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">ploer@subspacesw.com</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData NotOnOrAfter="2014-05-28T00:19:08Z" Recipient="{recipient}" InResponseTo="_a6fc46be84e1e3cf3c50"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="2014-05-28T00:13:08Z" NotOnOrAfter="2014-05-28T00:19:08Z"><saml:AudienceRestriction><saml:Audience>{audience}</saml:Audience></saml:AudienceRestriction></saml:Conditions><saml:AuthnStatement AuthnInstant="2014-05-28T00:16:07Z" SessionNotOnOrAfter="2014-05-29T00:16:08Z" SessionIndex="_30a4af50-c82b-0131-f8b5-782bcb56fcaa"><saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement></saml:Assertion>' +
"</samlp:Response>";
const base64xml = Buffer.from(xml).toString("base64");
const container = { SAMLResponse: base64xml };

// Set the maxAssertionAgeMs so that IssueInstant + maxAssertionAgeMs == 2014-05-28T00:16:09Z
// Note that NotOnOrAfter == 2015-05-28T00:19:08Z in the response
const samlObj = new SAML({ ...samlConfig, maxAssertionAgeMs: 1000 });

// Fake the current date to be after the time limit set by maxAssertionAgeMs,
// but before the limit set by NotOnOrAfter
fakeClock.restore();
fakeClock = sinon.useFakeTimers(Date.parse("2014-05-28T00:17:09Z"));
await assert.rejects(samlObj.validatePostResponseAsync(container), {
message: "SAML assertion expired",
});
});

it("onelogin xml document with current time before MaxAssertionAge (minus default clock skew) should pass", async () => {
const xml =
'<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>' +
'<saml:Assertion xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" Version="2.0" ID="pfx3b63c7be-fe86-62fd-8cb5-16ab6273efaa" IssueInstant="2014-05-28T00:16:08Z"><saml:Issuer>https://app.onelogin.com/saml/metadata/371755</saml:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/><ds:Reference URI="#pfx3b63c7be-fe86-62fd-8cb5-16ab6273efaa"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/><ds:DigestValue>DCnPTQYBb1hKspbe6fg1U3q8xn4=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>e0+aFomA0+JAY0f9tKqzIuqIVSSw7LiFUsneEDKPBWdiTz1sMdgr/2y1e9+rjaS2mRmCi/vSQLY3zTYz0hp6nJNU19+TWoXo9kHQyWT4KkeQL4Xs/gZ/AoKC20iHVKtpPps0IQ0Ml/qRoouSitt6Sf/WDz2LV/pWcH2hx5tv3xSw36hK2NQc7qw7r1mEXnvcjXReYo8rrVf7XHGGxNoRIEICUIi110uvsWemSXf0Z0dyb0FVYOWuSsQMDlzNpheADBifFO4UTfSEhFZvn8kVCGZUIwrbOhZ2d/+YEtgyuTg+qtslgfy4dwd4TvEcfuRzQTazeefprSFyiQckAXOjcw==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>' +
TEST_CERT +
'</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml:Subject><saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:transient">ploer@subspacesw.com</saml:NameID><saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml:SubjectConfirmationData NotOnOrAfter="2014-05-28T00:19:08Z" Recipient="{recipient}" InResponseTo="_a6fc46be84e1e3cf3c50"/></saml:SubjectConfirmation></saml:Subject><saml:Conditions NotBefore="2014-05-28T00:13:08Z" NotOnOrAfter="2014-05-28T00:19:08Z"><saml:AudienceRestriction><saml:Audience>{audience}</saml:Audience></saml:AudienceRestriction></saml:Conditions><saml:AuthnStatement AuthnInstant="2014-05-28T00:16:07Z" SessionNotOnOrAfter="2014-05-29T00:16:08Z" SessionIndex="_30a4af50-c82b-0131-f8b5-782bcb56fcaa"><saml:AuthnContext><saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef></saml:AuthnContext></saml:AuthnStatement></saml:Assertion>' +
"</samlp:Response>";
const base64xml = Buffer.from(xml).toString("base64");
const container = { SAMLResponse: base64xml };

// Set the maxAssertionAgeMs so that IssueInstant + maxAssertionAgeMs == 2014-05-28T00:16:09Z
// Note that NotOnOrAfter == 2015-05-28T00:19:08Z in the response
const samlObj = new SAML({ ...samlConfig, maxAssertionAgeMs: 1000 });

// Fake the current date to be before the time limit set by maxAssertionAgeMs
fakeClock.restore();
fakeClock = sinon.useFakeTimers(Date.parse("2014-05-28T00:16:08Z"));

const { profile } = await samlObj.validatePostResponseAsync(container);
profile!.nameID!.should.startWith("ploer");
});

it("onelogin xml document with audience and no AudienceRestriction should not pass", async () => {
const signingCert = fs.readFileSync(__dirname + "/static/cert.pem", "utf-8");
const xml = `<samlp:Response xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" ID="pfx1e2f568f-ba3e-9d81-af54-ab41fdbc648e" Version="2.0" IssueInstant="2014-05-28T00:16:08Z" Destination="{recipient}" InResponseTo="_a6fc46be84e1e3cf3c50">
Expand Down

0 comments on commit 352e059

Please sign in to comment.