Skip to content

Commit

Permalink
chore(backend): Extract jwt audience assertion & add unit tests
Browse files Browse the repository at this point in the history
Also there was some changes in the code:
- aud claim with empty values does NOT throw verification error
  • Loading branch information
dimkl committed May 10, 2023
1 parent b61ac62 commit 84f0ea2
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 39 deletions.
108 changes: 108 additions & 0 deletions packages/backend/src/tokens/jwt/assertions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type QUnit from 'qunit';

import { assertAudienceClaim } from './assertions';

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

module('assertAudienceClaim(audience?, aud?)', () => {
const audience = 'http://audience.example';
const otherAudience = 'http://audience-other.example';
const invalidAudience = 'http://invalid-audience.example';

test('does not throw error if aud is missing', assert => {
assert.equal(undefined, assertAudienceClaim());
assert.equal(undefined, assertAudienceClaim(undefined));
assert.equal(undefined, assertAudienceClaim(undefined, audience));
assert.equal(undefined, assertAudienceClaim(undefined, [audience]));
assert.equal(undefined, assertAudienceClaim(''));
assert.equal(undefined, assertAudienceClaim('', audience));
assert.equal(undefined, assertAudienceClaim('', [audience]));
assert.equal(undefined, assertAudienceClaim('', [audience, otherAudience]));
});

test('does not throw error if audience is missing', assert => {
assert.equal(undefined, assertAudienceClaim(undefined, undefined));
assert.equal(undefined, assertAudienceClaim(audience, undefined));
assert.equal(undefined, assertAudienceClaim([audience], undefined));

assert.equal(undefined, assertAudienceClaim(undefined, ''));
assert.equal(undefined, assertAudienceClaim(audience, ''));
assert.equal(undefined, assertAudienceClaim([audience], ''));
assert.equal(undefined, assertAudienceClaim([audience, otherAudience], ''));
});

test('does not throw error if aud contains empty values', assert => {
assert.equal(undefined, assertAudienceClaim([], audience));
assert.equal(undefined, assertAudienceClaim([undefined, undefined], audience));
assert.equal(undefined, assertAudienceClaim([null, null], audience));
assert.equal(undefined, assertAudienceClaim([false, false], audience));
assert.equal(undefined, assertAudienceClaim(['', ''], [audience]));
assert.equal(undefined, assertAudienceClaim(['', ''], [audience, otherAudience]));
});

test('does not throw error if audience is empty or contains empty values', assert => {
assert.equal(undefined, assertAudienceClaim(audience, []));
assert.equal(undefined, assertAudienceClaim(audience, [undefined, undefined]));
assert.equal(undefined, assertAudienceClaim(audience, [null, null]));
assert.equal(undefined, assertAudienceClaim(audience, [false, false]));
assert.equal(undefined, assertAudienceClaim(audience, ['', '']));
assert.equal(undefined, assertAudienceClaim([audience], ['', '']));
assert.equal(undefined, assertAudienceClaim([audience, otherAudience], ['', '']));
});

test('does not throw error when audience matches aud', assert => {
assert.equal(undefined, assertAudienceClaim(audience, audience));
});

test('does not throw error when audience list contains aud', assert => {
assert.equal(undefined, assertAudienceClaim(audience, [audience, otherAudience]));
});

test('does not throw error when audience string[] has intersection with aud string[]', assert => {
assert.equal(undefined, assertAudienceClaim([audience], [audience, otherAudience]));
assert.equal(undefined, assertAudienceClaim([audience, otherAudience], [audience]));
});

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}]".`,
);
});

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)}".`,
);
});

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,
)}]".`,
);
});

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}".`,
);
});

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,
]}]".`,
);
});
});
};
45 changes: 45 additions & 0 deletions packages/backend/src/tokens/jwt/assertions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors';

const isArrayString = (s: unknown): s is string[] => {
return Array.isArray(s) && s.length > 0 && s.every(a => typeof a === 'string');
};

export const assertAudienceClaim = (aud?: unknown, audience?: unknown) => {
const audienceList = [audience].flat().filter(a => !!a);
const audList = [aud].flat().filter(a => !!a);
const shouldVerifyAudience = audienceList.length > 0 && audList.length > 0;

if (!shouldVerifyAudience) {
// Notice: Clerk JWTs use AZP claim instead of Audience
//
// return {
// valid: false,
// reason: `Invalid JWT audience claim (aud) ${JSON.stringify(
// aud,
// )}. Expected a string or a non-empty array of strings.`,
// };
return;
}

if (typeof aud === 'string') {
if (!audienceList.includes(aud)) {
throw new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenVerificationFailed,
message: `Invalid JWT audience claim (aud) ${JSON.stringify(aud)}. Is not included in "${JSON.stringify(
audienceList,
)}".`,
});
}
} else if (isArrayString(aud)) {
if (!aud.some(a => audienceList.includes(a))) {
throw new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenVerificationFailed,
message: `Invalid JWT audience claim array (aud) ${JSON.stringify(aud)}. Is not included in "${JSON.stringify(
audienceList,
)}".`,
});
}
}
};
45 changes: 45 additions & 0 deletions packages/backend/src/tokens/jwt/hasValidSignature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Jwt } from '@clerk/types';

// DO NOT CHANGE: Runtime needs to be imported as a default export so that we can stub its dependencies with Sinon.js
// For more information refer to https://sinonjs.org/how-to/stub-dependency/
import runtime from '../../runtime';

const algToHash: Record<string, string> = {
RS256: 'SHA-256',
RS384: 'SHA-384',
RS512: 'SHA-512',
ES256: 'SHA-256',
ES384: 'SHA-384',
ES512: 'SHA-512',
};

const RSA_ALGORITHM_NAME = 'RSASSA-PKCS1-v1_5';
const EC_ALGORITHM_NAME = 'ECDSA';

const jwksAlgToCryptoAlg: Record<string, string> = {
RS256: RSA_ALGORITHM_NAME,
RS384: RSA_ALGORITHM_NAME,
RS512: RSA_ALGORITHM_NAME,
ES256: EC_ALGORITHM_NAME,
ES384: EC_ALGORITHM_NAME,
ES512: EC_ALGORITHM_NAME,
};

export async function hasValidSignature(jwt: Jwt, jwk: JsonWebKey) {
const { header, signature, raw } = jwt;
const encoder = new TextEncoder();
const data = encoder.encode([raw.header, raw.payload].join('.'));

const cryptoKey = await runtime.crypto.subtle.importKey(
'jwk',
jwk,
{
name: jwksAlgToCryptoAlg[header.alg],
hash: algToHash[header.alg],
},
false,
['verify'],
);

return runtime.crypto.subtle.verify('RSASSA-PKCS1-v1_5', cryptoKey, signature, data);
}
41 changes: 2 additions & 39 deletions packages/backend/src/tokens/jwt/verifyJwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Jwt, JwtPayload } from '@clerk/types';
import runtime from '../../runtime';
import { base64url } from '../../util/rfc4648';
import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors';
import { assertAudienceClaim } from './assertions';

type IssuerResolver = string | ((iss: string) => boolean);

Expand Down Expand Up @@ -33,10 +34,6 @@ const jwksAlgToCryptoAlg: Record<string, string> = {

const algs = Object.keys(algToHash);

const isArrayString = (s: unknown): s is string[] => {
return Array.isArray(s) && s.length > 0 && s.every(a => typeof a === 'string');
};

export async function hasValidSignature(jwt: Jwt, jwk: JsonWebKey) {
const { header, signature, raw } = jwt;
const encoder = new TextEncoder();
Expand Down Expand Up @@ -151,41 +148,7 @@ export async function verifyJwt(
});
}

// Verify audience claim (aud)
const audiences = [audience].flat().filter(a => !!a);
const shouldVerifyAudience = audiences.length > 0 && aud;

if (!shouldVerifyAudience) {
// Avoid verifying aud claim & audience param
// Notice: Clerk JWTs use AZP claim instead of Audience
//
// return {
// valid: false,
// reason: `Invalid JWT audience claim (aud) ${JSON.stringify(
// aud,
// )}. Expected a string or a non-empty array of strings.`,
// };
} else if (typeof aud === 'string') {
if (!audiences.includes(aud)) {
throw new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenVerificationFailed,
message: `Invalid JWT audience claim (aud) ${JSON.stringify(aud)}. Is not included in "${JSON.stringify(
audiences,
)}".`,
});
}
} else if (isArrayString(aud)) {
if (!aud.some(a => audiences.includes(a))) {
throw new TokenVerificationError({
action: TokenVerificationErrorAction.EnsureClerkJWT,
reason: TokenVerificationErrorReason.TokenVerificationFailed,
message: `Invalid JWT audience claim array (aud) ${JSON.stringify(aud)}. Is not included in "${JSON.stringify(
audiences,
)}".`,
});
}
}
assertAudienceClaim([aud], [audience]);

// Verify authorized parties claim (azp)
if (azp && authorizedParties && authorizedParties.length > 0 && !authorizedParties.includes(azp)) {
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/tests/suites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import keysTest from './dist/tokens/keys.test.js';
import pathTest from './dist/util/path.test.js';
import verifyTest from './dist/tokens/verify.test.js';
import verifyJwtTest from './dist/tokens/jwt/verifyJwt.test.js';
import jwtAssertionsTest from './dist/tokens/jwt/assertions.test.js';

import utilRequestTest from './dist/util/request.test.js';
import factoryTest from './dist/api/factory.test.js';
Expand All @@ -18,6 +19,7 @@ import redirectTest from './dist/redirections.test.js';
const suites = [
apiTest,
exportsTest,
jwtAssertionsTest,
requestTest,
utilRequestTest,
keysTest,
Expand Down

0 comments on commit 84f0ea2

Please sign in to comment.