Skip to content

Commit

Permalink
fix(backend): Split jwt assertions to separate module
Browse files Browse the repository at this point in the history
This change is an attempt to improve readability and testability of
the code.
  • Loading branch information
dimkl committed Sep 20, 2023
1 parent 086a2e0 commit 40ea407
Show file tree
Hide file tree
Showing 4 changed files with 467 additions and 134 deletions.
5 changes: 5 additions & 0 deletions .changeset/lazy-papayas-vanish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Refactor the internal jwt assertions in separate module to improve testability and changed dates to UTC in jwt verification error messages
306 changes: 293 additions & 13 deletions packages/backend/src/tokens/jwt/assertions.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,26 @@
import type QUnit from 'qunit';
import sinon from 'sinon';

import { assertAudienceClaim } from './assertions';
import {
assertActivationClaim,
assertAudienceClaim,
assertAuthorizedPartiesClaim,
assertExpirationClaim,
assertHeaderAlgorithm,
assertHeaderType,
assertIssuedAtClaim,
assertIssuerClaim,
assertSubClaim,
} from './assertions';

export default (QUnit: QUnit) => {
const { module, test } = QUnit;
const { module, test, hooks } = QUnit;

function formatToUTCString(ts: number) {
const tsDate = new Date(0);
tsDate.setUTCSeconds(ts);
return tsDate.toUTCString();
}

module('assertAudienceClaim(audience?, aud?)', () => {
const audience = 'http://audience.example';
Expand Down Expand Up @@ -67,42 +84,305 @@ export default (QUnit: QUnit) => {
test('throws error when audience does not match aud', assert => {
assert.raises(
() => assertAudienceClaim(audience, invalidAudience),
`Invalid JWT audience claim (aud) ${audience}. Is not included in "[${invalidAudience}]".`,
new Error(
`Invalid JWT audience claim (aud) "${audience}". Is not included in "${JSON.stringify([invalidAudience])}".`,
),
);
});

test('throws error when audience is substring of aud', assert => {
assert.raises(
() => assertAudienceClaim(audience, audience.slice(0, -2)),
`Invalid JWT audience claim (aud) ${audience}. Is not included in "${audience.slice(0, -2)}".`,
new Error(
`Invalid JWT audience claim (aud) "${audience}". Is not included in "${JSON.stringify([
audience.slice(0, -2),
])}".`,
),
);
});

test('throws error when audience is substring of an aud when aud is a string[]', assert => {
assert.raises(
() => assertAudienceClaim([audience, otherAudience], audience.slice(0, -2)),
`Invalid JWT audience claim (aud) ${[audience, otherAudience]}. Is not included in "[${audience.slice(
0,
-2,
)}]".`,
new Error(
`Invalid JWT audience claim array (aud) ${JSON.stringify([
audience,
otherAudience,
])}. Is not included in "${JSON.stringify([audience.slice(0, -2)])}".`,
),
);
});

test('throws error when aud is a substring of audience', assert => {
assert.raises(
() => assertAudienceClaim(audience.slice(0, -2), audience),
`Invalid JWT audience claim (aud) ${audience.slice(0, -2)}. Is not included in "${audience}".`,
new Error(
`Invalid JWT audience claim (aud) "${audience.slice(0, -2)}". Is not included in "${JSON.stringify([
audience,
])}".`,
),
);
});

test('throws error when aud is substring of an audience when audience is a string[]', assert => {
assert.raises(
() => assertAudienceClaim(audience.slice(0, -2), [audience, otherAudience]),
`Invalid JWT audience claim (aud) ${audience.slice(0, -2)}. Is not included in "[${[
audience,
otherAudience,
]}]".`,
new Error(
`Invalid JWT audience claim (aud) "${audience.slice(0, -2)}". Is not included in "${JSON.stringify([
audience,
otherAudience,
])}".`,
),
);
});
});

module('assertHeaderType(typ?)', () => {
test('does not throw error if type is missing', assert => {
assert.equal(undefined, assertHeaderType(undefined));
});

test('throws error if type is not JWT', assert => {
assert.raises(() => assertHeaderType(''), new Error(`Invalid JWT type "". Expected "JWT".`));
assert.raises(() => assertHeaderType('Aloha'), new Error(`Invalid JWT type "Aloha". Expected "JWT".`));
});
});

module('assertHeaderAlgorithm(alg)', () => {
test('does not throw if algorithm is supported', assert => {
assert.equal(undefined, assertHeaderAlgorithm('RS256'));
assert.equal(undefined, assertHeaderAlgorithm('RS384'));
assert.equal(undefined, assertHeaderAlgorithm('RS512'));
assert.equal(undefined, assertHeaderAlgorithm('ES256'));
assert.equal(undefined, assertHeaderAlgorithm('ES384'));
assert.equal(undefined, assertHeaderAlgorithm('ES512'));
});

test('throws error if algorithm is missing', assert => {
assert.raises(
() => assertHeaderAlgorithm(''),
new Error(`Invalid JWT algorithm "". Supported: RS256,RS384,RS512,ES256,ES384,ES512.`),
);
});

test('throws error if algorithm is not supported', assert => {
assert.raises(
() => assertHeaderAlgorithm('PS512'),
new Error(`Invalid JWT algorithm "PS512". Supported: RS256,RS384,RS512,ES256,ES384,ES512.`),
);
assert.raises(
() => assertHeaderAlgorithm('Aloha'),
new Error(`Invalid JWT algorithm "Aloha". Supported: RS256,RS384,RS512,ES256,ES384,ES512.`),
);
});
});

module('assertSubClaim(sub?)', () => {
test('does not throw if sub exists', assert => {
assert.equal(undefined, assertSubClaim(''));
});

test('throws error if sub is missing', assert => {
assert.raises(
() => assertSubClaim(),
new Error(`Subject claim (sub) is required and must be a string. Received undefined.`),
);
assert.raises(
() => assertSubClaim(undefined),
new Error('Subject claim (sub) is required and must be a string. Received undefined.'),
);
});
});

module('assertAuthorizedPartiesClaim(azp?, authorizedParties?)', () => {
test('does not throw if azp missing or empty', assert => {
assert.equal(undefined, assertAuthorizedPartiesClaim());
assert.equal(undefined, assertAuthorizedPartiesClaim(''));
assert.equal(undefined, assertAuthorizedPartiesClaim(undefined));
});

test('does not throw if authorizedParties missing or empty', assert => {
assert.equal(undefined, assertAuthorizedPartiesClaim('azp'));
assert.equal(undefined, assertAuthorizedPartiesClaim('azp', []));
assert.equal(undefined, assertAuthorizedPartiesClaim('azp', undefined));
});

test('throws error if azp is not included in authorizedParties', assert => {
assert.raises(
() => assertAuthorizedPartiesClaim('azp', ['']),
new Error(`Invalid JWT Authorized party claim (azp) "azp". Expected "".`),
);
assert.raises(
() => assertAuthorizedPartiesClaim('azp', ['azp-1']),
new Error(`Invalid JWT Authorized party claim (azp) "azp". Expected "azp-1".`),
);
});

test('does not throw if azp is included in authorizedParties ', assert => {
assert.equal(undefined, assertAuthorizedPartiesClaim('azp', ['azp']));
});
});

module('assertIssuerClaim(iss, issuer)', () => {
test('does not throw if issuer is null', assert => {
assert.equal(undefined, assertIssuerClaim('', null));
});

test('throws error if iss does not match with issuer string', assert => {
assert.raises(
() => assertIssuerClaim('issuer', ''),
new Error(`Invalid JWT issuer claim (iss) "issuer". Expected "".`),
);
assert.raises(
() => assertIssuerClaim('issuer', 'issuer-2'),
new Error(`Invalid JWT issuer claim (iss) "issuer". Expected "issuer-2".`),
);
});

test('throws error if iss does not match with issuer function result', assert => {
assert.raises(
() => assertIssuerClaim('issuer', () => false),
new Error(`Failed JWT issuer resolver. Make sure that the resolver returns a truthy value.`),
);
});

test('does not throw if iss matches issuer ', assert => {
assert.equal(undefined, assertIssuerClaim('issuer', 'issuer'));
assert.equal(
undefined,
assertIssuerClaim('issuer', s => s === 'issuer'),
);
assert.equal(
undefined,
assertIssuerClaim('issuer', () => true),
);
});
});

module('assertExpirationClaim(exp, clockSkewInMs)', () => {
test('throws err if exp is in the past', assert => {
const nowInSeconds = Date.now() / 1000;
const exp = nowInSeconds - 5;
assert.raises(
() => assertExpirationClaim(exp, 0),
new Error(
`JWT is expired. Expiry date: ${formatToUTCString(exp)}, Current date: ${formatToUTCString(nowInSeconds)}.`,
),
);
});

test('does not throw error if exp is in the past but less than clock skew', assert => {
const nowInSeconds = Date.now() / 1000;
assert.equal(undefined, assertExpirationClaim(nowInSeconds - 5, 6000));
});

test('throws err if exp is in now', assert => {
const nowInSeconds = Date.now() / 1000;
assert.raises(
() => assertExpirationClaim(nowInSeconds, 0),
new Error(
`JWT is expired. Expiry date: ${formatToUTCString(nowInSeconds)}, Current date: ${formatToUTCString(
nowInSeconds,
)}.`,
),
);
});

test('does not throw error if exp is now but there is clock skew', assert => {
const nowInSeconds = Date.now() / 1000;
assert.equal(undefined, assertExpirationClaim(nowInSeconds, 1000));
});

test('does not throw error if exp is in the future', assert => {
const nowInSeconds = Date.now() / 1000;
assert.equal(undefined, assertExpirationClaim(nowInSeconds + 5, 0));
assert.equal(undefined, assertExpirationClaim(nowInSeconds + 5, 6000));
});
});

module('assertActivationClaim(nbf, clockSkewInMs)', () => {
test('does not throw error if nbf is undefined', assert => {
assert.equal(undefined, assertActivationClaim(undefined, 0));
});

test('does not throw error if nbf is in the past', assert => {
const nowInSeconds = Date.now() / 1000;
assert.equal(undefined, assertActivationClaim(nowInSeconds - 5, 0));
});

test('does not throw err if nbf is in the past but less than clock skew', assert => {
const nowInSeconds = Date.now() / 1000;
assert.equal(undefined, assertActivationClaim(nowInSeconds - 5, 6000));
});

test('does not throw error if nbf is now', assert => {
const nowInSeconds = Date.now() / 1000;
assert.equal(undefined, assertActivationClaim(nowInSeconds, 0));
});

test('does not throw error if nbf is now and there is clock skew', assert => {
const nowInSeconds = Date.now() / 1000;
assert.equal(undefined, assertActivationClaim(nowInSeconds, 1));
});

test('throws error if nbf is in the future', assert => {
const nowInSeconds = Date.now() / 1000;
assert.raises(
() => assertActivationClaim(nowInSeconds + 5, 0),
new Error(
`JWT cannot be used prior to not before date claim (nbf). Not before date: ${formatToUTCString(
nowInSeconds + 5,
)}; Current date: ${formatToUTCString(nowInSeconds)};`,
),
);
});

test('does not throw error if nbf is in the future but less than clock skew', assert => {
const nowInSeconds = Date.now() / 1000;
assert.equal(undefined, assertActivationClaim(nowInSeconds + 5, 6000));
});
});

module('assertIssuedAtClaim(iat, clockSkewInMs)', () => {
test('does not throw error if iat is undefined', assert => {
assert.equal(undefined, assertIssuedAtClaim(undefined, 0));
});

test('does not throw error if iat is in the past', assert => {
const nowInSeconds = Date.now() / 1000;
assert.equal(undefined, assertIssuedAtClaim(nowInSeconds - 5, 0));
});

test('does not throw err if iat is in the past but less than clock skew', assert => {
const nowInSeconds = Date.now() / 1000;
assert.equal(undefined, assertIssuedAtClaim(nowInSeconds - 5, 6000));
});

test('does not throw error if iat is now', assert => {
const nowInSeconds = Date.now() / 1000;
assert.equal(undefined, assertIssuedAtClaim(nowInSeconds, 0));
});

test('does not throw error if iat is now and there is clock skew', assert => {
const nowInSeconds = Date.now() / 1000;
assert.equal(undefined, assertIssuedAtClaim(nowInSeconds, 1));
});

test('throws error if iat is in the future', assert => {
const nowInSeconds = Date.now() / 1000;
assert.raises(
() => assertIssuedAtClaim(nowInSeconds + 5, 0),
new Error(
`JWT issued at date claim (iat) is in the future. Issued at date: ${formatToUTCString(
nowInSeconds + 5,
)}; Current date: ${formatToUTCString(nowInSeconds)};`,
),
);
});

test('does not throw error if nbf is in the future but less than clock skew', assert => {
const nowInSeconds = Date.now() / 1000;
assert.equal(undefined, assertIssuedAtClaim(nowInSeconds + 5, 6000));
});
});
};
Loading

0 comments on commit 40ea407

Please sign in to comment.