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

Add optional setting to set a ceiling on how old a SAML response is allowed to be #577

Merged
merged 10 commits into from Apr 28, 2021
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
53 changes: 49 additions & 4 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 @@ -1242,7 +1260,11 @@ class SAML {
if (notOnOrAfter) {
const notOnOrAfterMs = Date.parse(notOnOrAfter);
if (nowMs - this.options.acceptedClockSkewMs >= notOnOrAfterMs)
return new Error("SAML assertion expired");
return new Error("SAML assertion expired: clocks skewed too much");
}
if (maxTimeLimitMs) {
if (nowMs - this.options.acceptedClockSkewMs >= maxTimeLimitMs)
return new Error("SAML assertion expired: assertion too old");
}

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