-
Notifications
You must be signed in to change notification settings - Fork 253
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore(backend): Extract jwt audience assertion & add unit tests
Also there was some changes in the code: - aud claim with empty values does NOT throw verification error
- Loading branch information
Showing
5 changed files
with
202 additions
and
39 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
]}]".`, | ||
); | ||
}); | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
)}".`, | ||
}); | ||
} | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters