diff --git a/.changeset/cold-bags-watch.md b/.changeset/cold-bags-watch.md new file mode 100644 index 00000000000..9e1a1bec65e --- /dev/null +++ b/.changeset/cold-bags-watch.md @@ -0,0 +1,7 @@ +--- +'@clerk/backend': patch +--- + +Added new function `signJwt(payload, key, options)` for JWT token signing. +Also updated the existing `hasValidSignature` and `verifyJwt` method to handle PEM-formatted keys directly (previously they had to be converted to jwks). +For key compatibility, support is specifically confined to `RSA` types and formats `jwk, pkcs8, spki`. diff --git a/packages/backend/README.md b/packages/backend/README.md index b2c94a03f03..5d063ab895a 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -129,7 +129,7 @@ Verifies a Clerk generated JWT (i.e. Clerk Session JWT and Clerk JWT templates). import { verifyJwt } from '@clerk/backend'; verifyJwt(token, { - key: JsonWebKey, + key: JsonWebKey | string, issuer: '...', authorizedParties: '...', }); @@ -145,7 +145,7 @@ import { decodeJwt } from '@clerk/backend'; decodeJwt(token); ``` -#### hasValidSignature(jwt: Jwt, jwk: JsonWebKey) +#### hasValidSignature(jwt: Jwt, key: JsonWebKey | string) Verifies that the JWT has a valid signature. The key needs to be provided. diff --git a/packages/backend/src/exports.test.ts b/packages/backend/src/exports.test.ts index 493624c141b..5f43b14475f 100644 --- a/packages/backend/src/exports.test.ts +++ b/packages/backend/src/exports.test.ts @@ -45,6 +45,7 @@ export default (QUnit: QUnit) => { 'prunePrivateMetadata', 'redirect', 'sanitizeAuthObject', + 'signJwt', 'signedInAuthObject', 'signedOutAuthObject', 'verifyJwt', diff --git a/packages/backend/src/tokens/fixtures.ts b/packages/backend/src/tokens/fixtures.ts index 37119e1c105..1ab0471e109 100644 --- a/packages/backend/src/tokens/fixtures.ts +++ b/packages/backend/src/tokens/fixtures.ts @@ -59,3 +59,81 @@ export const mockPEMJwtKey = 'qpj5Cb+sxfFI+Vhf1GB1bNeOLPR10nkSMJ74HB0heHi/SsM83JiGekv0CpZPCC8j\n' + 'cQIDAQAB\n' + '-----END PUBLIC KEY-----'; + +export const pemEncodedSignKey = `-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpjLcxjx86d4TL +M0O72WnqIZcqQ1QX6791SeuWRp1ljIHfl5/MoUkSv19+2Za/k9SPW5EdNDduHpfV +xx45iwiPLTTx0dZkmwqEY7GB1ON4r3WuNqSXG3u3IVcSIocg6vUtKArOikKU58Ii +PEr+g9Q5/fylWHtad6RxIFCZTl+oD4TMoyLqT1XC9vOoqVkzhdCyIXKfbx31W5sl +aNNTyc2i3SfU0T72TpPEzyeCUzhHCQEfg2LgHQuEoo45X4Aby0E2JlWKXjXl2kGV +2Yn+PTCTsB3hUWL16fdnXugIqv4r7O5Pu8owpJvXnjx2TS+eaLS+PZAZdKj7rXAz +nJBamTqxAgMBAAECggEAB/SNR+sCORkQhwRBwleiK5Ul5ZrBIFo0Yol0X1my2ufr +1BTmL5DFv/ZwwZ/t/dEu4QcX2PnxO959m087cNHANg+V8164I4JOzQVsd74Iako5 +SFJSCLEGbgJHdpdeJcJAfLzrPOOp2hjBuB+CGU0QMSRkrVFogEcq1RACGB9gR59X +Kft9GC+iZowLUwwlUWpUpPK94ZIfxFflJdBSl9DPSjUq9lNPWhy2/qwjkDluKIG1 +9p4gmRRNT1vSwBmwfq74jrB+rSYL6+IpmSw0PX41pSkuuNPQ0LgrtM7+9dr9tNVP +Wxc1HVZYj8r0FF3Yr5JFlHy9nxf/XMzQxNhZpaNRXQKBgQDirp04vgu3TB08UoKo +jovEzMT/jBwGkP1lV48MsjM9iiNZZ2mz01T4mvE70GwuMBwOwtjUuNPogoJT+i6I +dnaPCinr3JwMW1UUFSa/4b15nDsPxFZit1nficJXKMc0c5VxFn2Xpbcq6aeif/ny +a6bI1vh5N/CYIroZXqays4XbuwKBgQC/enY3H/clEVRGGytnqz/5JBncskCU0F/A +RsbYBPUg3tPZoBURTcLRPsCDWZKXCl2MLzP8h0ia1hMQiz88tsZl8PS5IYB4eEfy +iEpwuU7q4pNJDgiZzMIs7h7KlKJOGv56HCQfWW/9HUpyZA634IIN+TnCD5YCoNLo +IoqYoz++gwKBgFHZmwuSE8jrwuK1KFiUoAM/rSJZBQWZ9OVS6GQ9NCNUbc8qeBBm +jpf12oUujOFgncD2ujSVSG78MPMBsyuzGrwrf1ebIP2VPPMzb/p5GGGA+BKJYmfi +rKD6rSGrp8JYue1Loa3QOINWOyGB9E6EcIS0mqOqf0VvxKLEeoysJflhAoGAMPYp +gFMGKU5TFFIiOTIK+7QFgO97oBHgShRPCDHMVIll9oH+oRwXMtYu9+dRmpml7hCr +5GjbYexXl6VjmCzMcoi4qxYr+aIYE6ZSEpzv1xP0wXt7K4i2JjMFYJu9HOe+Jo9H +lVSTVE/HF5UKRm58EwKliD/gBfAFviIG+pzT0e0CgYBjVfmflnceTDiWGUqZB/6K +VemEqCD+L3Pf6FIGI0h2RfFcLowvyOC3qwINTrYXdNUHtLI1BDUkMYBv6YJdI4E/ +EJa5L6umCqdlL4+iL3FKgnsVSkb7io8+n1XLF+qrbRjWpEDuSn8ICC+k/fea/mvj +5gTPwKCFpNesz5MP8D2kRg== +-----END PRIVATE KEY-----`; + +export const pemEncodedPublicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqYy3MY8fOneEyzNDu9lp +6iGXKkNUF+u/dUnrlkadZYyB35efzKFJEr9fftmWv5PUj1uRHTQ3bh6X1cceOYsI +jy008dHWZJsKhGOxgdTjeK91rjaklxt7tyFXEiKHIOr1LSgKzopClOfCIjxK/oPU +Of38pVh7WnekcSBQmU5fqA+EzKMi6k9VwvbzqKlZM4XQsiFyn28d9VubJWjTU8nN +ot0n1NE+9k6TxM8nglM4RwkBH4Ni4B0LhKKOOV+AG8tBNiZVil415dpBldmJ/j0w +k7Ad4VFi9en3Z17oCKr+K+zuT7vKMKSb1548dk0vnmi0vj2QGXSo+61wM5yQWpk6 +sQIDAQAB +-----END PUBLIC KEY-----`; + +export const someOtherPublicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5wdNEDm/HUAO6xarLs6e +cS0/J8GencMs5I6rYS825knb8jsNbfoukYfBiK81vQy1/eK5gdWrEprpXQrmIcwG +akdeUhybYlK68UhHNA5+TAmZ+ReLTJ2QDk5YU4I1NlRRq/bqtEhWsBDOCCkpVsC4 +OLnUpsZKGUwpCrE/8stMSJ6Xx+TzBlDe21cV1j0gn5CWswrrXo7m8OIZ9xkRnNn4 +fTNypMSCbx6BS7fgmer6Efx9HOu9UIKgXD/29q3pEpFXiHRdQRbVoAc9vEZl0QIw +PSNjILVJLKvb6MhKoQMyaP5k0c1rEkVJr9jQk5Z/6WPklCNK3oT5+gh2lgi7ZxBd +oQIDAQAB +-----END PUBLIC KEY-----`; + +// These should be identical to the pem encoded keys above +export const signingJwks = { + key_ops: ['sign'], + ext: true, + kty: 'RSA', + n: 'qYy3MY8fOneEyzNDu9lp6iGXKkNUF-u_dUnrlkadZYyB35efzKFJEr9fftmWv5PUj1uRHTQ3bh6X1cceOYsIjy008dHWZJsKhGOxgdTjeK91rjaklxt7tyFXEiKHIOr1LSgKzopClOfCIjxK_oPUOf38pVh7WnekcSBQmU5fqA-EzKMi6k9VwvbzqKlZM4XQsiFyn28d9VubJWjTU8nNot0n1NE-9k6TxM8nglM4RwkBH4Ni4B0LhKKOOV-AG8tBNiZVil415dpBldmJ_j0wk7Ad4VFi9en3Z17oCKr-K-zuT7vKMKSb1548dk0vnmi0vj2QGXSo-61wM5yQWpk6sQ', + e: 'AQAB', + d: 'B_SNR-sCORkQhwRBwleiK5Ul5ZrBIFo0Yol0X1my2ufr1BTmL5DFv_ZwwZ_t_dEu4QcX2PnxO959m087cNHANg-V8164I4JOzQVsd74Iako5SFJSCLEGbgJHdpdeJcJAfLzrPOOp2hjBuB-CGU0QMSRkrVFogEcq1RACGB9gR59XKft9GC-iZowLUwwlUWpUpPK94ZIfxFflJdBSl9DPSjUq9lNPWhy2_qwjkDluKIG19p4gmRRNT1vSwBmwfq74jrB-rSYL6-IpmSw0PX41pSkuuNPQ0LgrtM7-9dr9tNVPWxc1HVZYj8r0FF3Yr5JFlHy9nxf_XMzQxNhZpaNRXQ', + p: '4q6dOL4Lt0wdPFKCqI6LxMzE_4wcBpD9ZVePDLIzPYojWWdps9NU-JrxO9BsLjAcDsLY1LjT6IKCU_ouiHZ2jwop69ycDFtVFBUmv-G9eZw7D8RWYrdZ34nCVyjHNHOVcRZ9l6W3Kumnon_58mumyNb4eTfwmCK6GV6msrOF27s', + q: 'v3p2Nx_3JRFURhsrZ6s_-SQZ3LJAlNBfwEbG2AT1IN7T2aAVEU3C0T7Ag1mSlwpdjC8z_IdImtYTEIs_PLbGZfD0uSGAeHhH8ohKcLlO6uKTSQ4ImczCLO4eypSiThr-ehwkH1lv_R1KcmQOt-CCDfk5wg-WAqDS6CKKmKM_voM', + dp: 'UdmbC5ITyOvC4rUoWJSgAz-tIlkFBZn05VLoZD00I1Rtzyp4EGaOl_XahS6M4WCdwPa6NJVIbvww8wGzK7MavCt_V5sg_ZU88zNv-nkYYYD4EoliZ-KsoPqtIaunwli57UuhrdA4g1Y7IYH0ToRwhLSao6p_RW_EosR6jKwl-WE', + dq: 'MPYpgFMGKU5TFFIiOTIK-7QFgO97oBHgShRPCDHMVIll9oH-oRwXMtYu9-dRmpml7hCr5GjbYexXl6VjmCzMcoi4qxYr-aIYE6ZSEpzv1xP0wXt7K4i2JjMFYJu9HOe-Jo9HlVSTVE_HF5UKRm58EwKliD_gBfAFviIG-pzT0e0', + qi: 'Y1X5n5Z3Hkw4lhlKmQf-ilXphKgg_i9z3-hSBiNIdkXxXC6ML8jgt6sCDU62F3TVB7SyNQQ1JDGAb-mCXSOBPxCWuS-rpgqnZS-Poi9xSoJ7FUpG-4qPPp9Vyxfqq20Y1qRA7kp_CAgvpP33mv5r4-YEz8CghaTXrM-TD_A9pEY', + alg: 'RS256', +}; + +export const publicJwks = { + key_ops: ['verify'], + ext: true, + kty: 'RSA', + n: 'qYy3MY8fOneEyzNDu9lp6iGXKkNUF-u_dUnrlkadZYyB35efzKFJEr9fftmWv5PUj1uRHTQ3bh6X1cceOYsIjy008dHWZJsKhGOxgdTjeK91rjaklxt7tyFXEiKHIOr1LSgKzopClOfCIjxK_oPUOf38pVh7WnekcSBQmU5fqA-EzKMi6k9VwvbzqKlZM4XQsiFyn28d9VubJWjTU8nNot0n1NE-9k6TxM8nglM4RwkBH4Ni4B0LhKKOOV-AG8tBNiZVil415dpBldmJ_j0wk7Ad4VFi9en3Z17oCKr-K-zuT7vKMKSb1548dk0vnmi0vj2QGXSo-61wM5yQWpk6sQ', + e: 'AQAB', + alg: 'RS256', +}; + +// this jwt has be signed with the keys above. The payload is mockJwtPayload and the header is mockJwtHeader +export const signedJwt = + 'eyJhbGciOiJSUzI1NiIsImtpZCI6Imluc18yR0lvUWhiVXB5MGhYN0IyY1ZrdVRNaW5Yb0QiLCJ0eXAiOiJKV1QifQ.eyJhenAiOiJodHRwczovL2FjY291bnRzLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsImV4cCI6MTY2NjY0ODMxMCwiaWF0IjoxNjY2NjQ4MjUwLCJpc3MiOiJodHRwczovL2NsZXJrLmluc3BpcmVkLnB1bWEtNzQubGNsLmRldiIsIm5iZiI6MTY2NjY0ODI0MCwic2lkIjoic2Vzc18yR2JEQjRlbk5kQ2E1dlMxenBDM1h6Zzl0SzkiLCJzdWIiOiJ1c2VyXzJHSXBYT0VwVnlKdzUxcmtabjlLbW5jNlN4ciJ9.j3rB92k32WqbQDkFB093H4GoQsBVLH4HLGF6ObcwUaVGiHC8SEu6T31FuPf257SL8A5sSGtWWM1fqhQpdLohgZb_hbJswGBuYI-Clxl9BtpIRHbWFZkLBIj8yS9W9aVtD3fWBbF6PHx7BY1udio-rbGWg1YAOZNtVcxF02p-MvX-8XIK92Vwu3Un5zyfCoVIg__qo3Xntzw3tznsZ4XDe212c6kVz1R_L1d5DKjeWXpjUPAS_zFeZSIJEQLf4JNr4JCY38tfdnc3ajfDA3p36saf1XwmTdWXQKCXi75c2TJAXROs3Pgqr5Kw_5clygoFuxN5OEMhFWFSnvIBdi3M6w'; diff --git a/packages/backend/src/tokens/jwt/algorithms.ts b/packages/backend/src/tokens/jwt/algorithms.ts new file mode 100644 index 00000000000..2fdfa0edc79 --- /dev/null +++ b/packages/backend/src/tokens/jwt/algorithms.ts @@ -0,0 +1,28 @@ +const algToHash: Record = { + RS256: 'SHA-256', + RS384: 'SHA-384', + RS512: 'SHA-512', +}; +const RSA_ALGORITHM_NAME = 'RSASSA-PKCS1-v1_5'; + +const jwksAlgToCryptoAlg: Record = { + RS256: RSA_ALGORITHM_NAME, + RS384: RSA_ALGORITHM_NAME, + RS512: RSA_ALGORITHM_NAME, +}; + +export const algs = Object.keys(algToHash); + +export function getCryptoAlgorithm(algorithmName: string): RsaHashedImportParams { + const hash = algToHash[algorithmName]; + const name = jwksAlgToCryptoAlg[algorithmName]; + + if (!hash || !name) { + throw new Error(`Unsupported algorithm ${algorithmName}, expected one of ${algs.join(',')}.`); + } + + return { + hash: { name: algToHash[algorithmName] }, + name: jwksAlgToCryptoAlg[algorithmName], + }; +} diff --git a/packages/backend/src/tokens/jwt/assertions.test.ts b/packages/backend/src/tokens/jwt/assertions.test.ts index 85459dc594a..71b95e7066d 100644 --- a/packages/backend/src/tokens/jwt/assertions.test.ts +++ b/packages/backend/src/tokens/jwt/assertions.test.ts @@ -153,26 +153,35 @@ export default (QUnit: QUnit) => { 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.`), + new Error(`Invalid JWT algorithm "". Supported: RS256,RS384,RS512.`), ); }); test('throws error if algorithm is not supported', assert => { + assert.raises( + () => assertHeaderAlgorithm('ES256'), + new Error(`Invalid JWT algorithm "ES256". Supported: RS256,RS384,RS512.`), + ); + assert.raises( + () => assertHeaderAlgorithm('ES384'), + new Error(`Invalid JWT algorithm "ES384". Supported: RS256,RS384,RS512.`), + ); + assert.raises( + () => assertHeaderAlgorithm('ES512'), + new Error(`Invalid JWT algorithm "ES512". Supported: RS256,RS384,RS512.`), + ); assert.raises( () => assertHeaderAlgorithm('PS512'), - new Error(`Invalid JWT algorithm "PS512". Supported: RS256,RS384,RS512,ES256,ES384,ES512.`), + new Error(`Invalid JWT algorithm "PS512". Supported: RS256,RS384,RS512.`), ); assert.raises( () => assertHeaderAlgorithm('Aloha'), - new Error(`Invalid JWT algorithm "Aloha". Supported: RS256,RS384,RS512,ES256,ES384,ES512.`), + new Error(`Invalid JWT algorithm "Aloha". Supported: RS256,RS384,RS512.`), ); }); }); diff --git a/packages/backend/src/tokens/jwt/assertions.ts b/packages/backend/src/tokens/jwt/assertions.ts index 7cefc89ad36..aeb4029fe45 100644 --- a/packages/backend/src/tokens/jwt/assertions.ts +++ b/packages/backend/src/tokens/jwt/assertions.ts @@ -1,18 +1,8 @@ import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors'; +import { algs } from './algorithms'; export type IssuerResolver = string | ((iss: string) => boolean); -export const algToHash: Record = { - RS256: 'SHA-256', - RS384: 'SHA-384', - RS512: 'SHA-512', - ES256: 'SHA-256', - ES384: 'SHA-384', - ES512: 'SHA-512', -}; - -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'); }; @@ -72,7 +62,7 @@ export const assertHeaderType = (typ?: unknown) => { }; export const assertHeaderAlgorithm = (alg: string) => { - if (!algToHash[alg]) { + if (!algs.includes(alg)) { throw new TokenVerificationError({ action: TokenVerificationErrorAction.EnsureClerkJWT, reason: TokenVerificationErrorReason.TokenInvalidAlgorithm, diff --git a/packages/backend/src/tokens/jwt/cryptoKeys.test.ts b/packages/backend/src/tokens/jwt/cryptoKeys.test.ts new file mode 100644 index 00000000000..ad707d3c1f6 --- /dev/null +++ b/packages/backend/src/tokens/jwt/cryptoKeys.test.ts @@ -0,0 +1,44 @@ +import type QUnit from 'qunit'; + +import { pemEncodedPublicKey, pemEncodedSignKey, publicJwks, signingJwks } from '../fixtures'; +import { importKey } from './cryptoKeys'; + +export default (QUnit: QUnit) => { + const { module, test } = QUnit; + + module('importKey(key, options)', () => { + const algorithm = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }; + + test('imports a JWK formatted private key', async assert => { + assert.ok(await importKey(signingJwks, algorithm, 'sign')); + }); + + test('imports a JWK formatted public key', async assert => { + assert.ok(await importKey(publicJwks, algorithm, 'verify')); + }); + + test('imports a pkcs8 formatted secret for signing', async assert => { + assert.ok(await importKey(pemEncodedSignKey, algorithm, 'sign')); + }); + + test('imports a pkcs8 formatted secret for verification', async assert => { + assert.ok(await importKey(pemEncodedPublicKey, algorithm, 'verify')); + }); + + test('throws an error if the key is string and not pem formatted', async assert => { + assert.rejects(importKey('not a key', algorithm, 'sign')); + }); + + test('throws an error if the key is not a JWK', async assert => { + assert.rejects(importKey({} as JsonWebKey, algorithm, 'sign')); + }); + + test('throws an error if a public key is imported for signing', async assert => { + assert.rejects(importKey(pemEncodedPublicKey, algorithm, 'sign')); + }); + + test('throws an error if a private key is imported for verification', async assert => { + assert.rejects(importKey(pemEncodedSignKey, algorithm, 'verify')); + }); + }); +}; diff --git a/packages/backend/src/tokens/jwt/cryptoKeys.ts b/packages/backend/src/tokens/jwt/cryptoKeys.ts new file mode 100644 index 00000000000..1223c7eb582 --- /dev/null +++ b/packages/backend/src/tokens/jwt/cryptoKeys.ts @@ -0,0 +1,37 @@ +import { isomorphicAtob } from '@clerk/shared'; + +import runtime from '../../runtime'; + +// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/importKey#pkcs_8_import +function pemToBuffer(secret: string): ArrayBuffer { + const trimmed = secret + .replace(/-----BEGIN.*?-----/g, '') + .replace(/-----END.*?-----/g, '') + .replace(/\s/g, ''); + + const decoded = isomorphicAtob(trimmed); + + const buffer = new ArrayBuffer(decoded.length); + const bufView = new Uint8Array(buffer); + + for (let i = 0, strLen = decoded.length; i < strLen; i++) { + bufView[i] = decoded.charCodeAt(i); + } + + return bufView; +} + +export function importKey( + key: JsonWebKey | string, + algorithm: RsaHashedImportParams, + keyUsage: 'verify' | 'sign', +): Promise { + if (typeof key === 'object') { + return runtime.crypto.subtle.importKey('jwk', key, algorithm, false, [keyUsage]); + } + + const keyData = pemToBuffer(key); + const format = keyUsage === 'sign' ? 'pkcs8' : 'spki'; + + return runtime.crypto.subtle.importKey(format, keyData, algorithm, false, [keyUsage]); +} diff --git a/packages/backend/src/tokens/jwt/hasValidSignature.ts b/packages/backend/src/tokens/jwt/hasValidSignature.ts deleted file mode 100644 index 1daac535656..00000000000 --- a/packages/backend/src/tokens/jwt/hasValidSignature.ts +++ /dev/null @@ -1,45 +0,0 @@ -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 = { - 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 = { - 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); -} diff --git a/packages/backend/src/tokens/jwt/index.ts b/packages/backend/src/tokens/jwt/index.ts index 63c06ad2187..a3cc2c5d109 100644 --- a/packages/backend/src/tokens/jwt/index.ts +++ b/packages/backend/src/tokens/jwt/index.ts @@ -1,3 +1,5 @@ export { hasValidSignature, decodeJwt, verifyJwt } from './verifyJwt'; +export { signJwt } from './signJwt'; export type { VerifyJwtOptions } from './verifyJwt'; +export type { SignJwtOptions } from './signJwt'; diff --git a/packages/backend/src/tokens/jwt/signJwt.test.ts b/packages/backend/src/tokens/jwt/signJwt.test.ts new file mode 100644 index 00000000000..5e97ec823cc --- /dev/null +++ b/packages/backend/src/tokens/jwt/signJwt.test.ts @@ -0,0 +1,48 @@ +import type { JwtPayload } from '@clerk/types'; +import type QUnit from 'qunit'; + +import { + mockJwtHeader, + mockJwtPayload, + pemEncodedPublicKey, + pemEncodedSignKey, + publicJwks, + signingJwks, +} from '../fixtures'; +import { signJwt } from './signJwt'; +import { verifyJwt } from './verifyJwt'; + +export default (QUnit: QUnit) => { + const { module, test } = QUnit; + + module('signJwt(payload, options)', hooks => { + let payload: JwtPayload; + + hooks.beforeEach(() => { + payload = { + ...mockJwtPayload, + exp: Date.now() + 1000 * 60 * 60 * 24 * 7, + } as JwtPayload; + }); + + test('signs a JWT with a JWK formatted secret', async assert => { + const jwt = await signJwt(payload, signingJwks, { + algorithm: mockJwtHeader.alg, + header: mockJwtHeader, + }); + + const verifiedPayload = await verifyJwt(jwt, { key: publicJwks, issuer: mockJwtPayload.iss }); + assert.deepEqual(verifiedPayload, payload); + }); + + test('signs a JWT with a pkcs8 formatted secret', async assert => { + const jwt = await signJwt(payload, pemEncodedSignKey, { + algorithm: mockJwtHeader.alg, + header: mockJwtHeader, + }); + + const verifiedPayload = await verifyJwt(jwt, { key: pemEncodedPublicKey, issuer: mockJwtPayload.iss }); + assert.deepEqual(verifiedPayload, payload); + }); + }); +}; diff --git a/packages/backend/src/tokens/jwt/signJwt.ts b/packages/backend/src/tokens/jwt/signJwt.ts new file mode 100644 index 00000000000..25d2ac08cfa --- /dev/null +++ b/packages/backend/src/tokens/jwt/signJwt.ts @@ -0,0 +1,59 @@ +import runtime from '../../runtime'; +import { base64url } from '../../util/rfc4648'; +import { getCryptoAlgorithm } from './algorithms'; +import { importKey } from './cryptoKeys'; + +export interface SignJwtOptions { + algorithm?: string; + header?: Record; +} + +function encodeJwtData(value: unknown): string { + const stringified = JSON.stringify(value); + const encoder = new TextEncoder(); + const encoded = encoder.encode(stringified); + return base64url.stringify(encoded, { pad: false }); +} + +/** + * Signs a JSON Web Token (JWT) with the given payload, key, and options. + * This function is intended to be used *internally* by other Clerk packages and typically + * should not be used directly. + * + * @internal + * @param payload The payload to include in the JWT. + * @param key The key to use for signing the JWT. Can be a string or a JsonWebKey. + * @param options The options to use for signing the JWT. + * @returns A Promise that resolves to the signed JWT string. + * @throws An error if no algorithm is specified or if the specified algorithm is unsupported. + * @throws An error if there is an issue with importing the key or signing the JWT. + */ +export async function signJwt( + payload: Record, + key: string | JsonWebKey, + options: SignJwtOptions, +): Promise { + if (!options.algorithm) { + throw new Error('No algorithm specified'); + } + const encoder = new TextEncoder(); + + const algorithm = getCryptoAlgorithm(options.algorithm); + if (!algorithm) { + throw new Error(`Unsupported algorithm ${options.algorithm}`); + } + + const cryptoKey = await importKey(key, algorithm, 'sign'); + const header = options.header || { typ: 'JWT' }; + + header.alg = options.algorithm; + payload.iat = Math.floor(Date.now() / 1000); + + const encodedHeader = encodeJwtData(header); + const encodedPayload = encodeJwtData(payload); + const firstPart = `${encodedHeader}.${encodedPayload}`; + + const signature = await runtime.crypto.subtle.sign(algorithm, cryptoKey, encoder.encode(firstPart)); + + return `${firstPart}.${base64url.stringify(new Uint8Array(signature), { pad: false })}`; +} diff --git a/packages/backend/src/tokens/jwt/verifyJwt.test.ts b/packages/backend/src/tokens/jwt/verifyJwt.test.ts index 5ab6e0dc979..8777cb655c1 100644 --- a/packages/backend/src/tokens/jwt/verifyJwt.test.ts +++ b/packages/backend/src/tokens/jwt/verifyJwt.test.ts @@ -1,12 +1,35 @@ import type QUnit from 'qunit'; import sinon from 'sinon'; -import { mockJwks, mockJwt, mockJwtHeader, mockJwtPayload } from '../fixtures'; -import { decodeJwt, verifyJwt } from './verifyJwt'; +import { + mockJwks, + mockJwt, + mockJwtHeader, + mockJwtPayload, + pemEncodedPublicKey, + publicJwks, + signedJwt, + someOtherPublicKey, +} from '../fixtures'; +import { decodeJwt, hasValidSignature, verifyJwt } from './verifyJwt'; export default (QUnit: QUnit) => { const { module, test } = QUnit; + module('hasValidSignature(jwt, key)', () => { + test('verifies the signature with a JWK formatted key', async assert => { + assert.true(await hasValidSignature(decodeJwt(signedJwt), publicJwks)); + }); + + test('verifies the signature with a PEM formatted key', async assert => { + assert.true(await hasValidSignature(decodeJwt(signedJwt), pemEncodedPublicKey)); + }); + + test('it returns false if the key is not correct', async assert => { + assert.false(await hasValidSignature(decodeJwt(signedJwt), someOtherPublicKey)); + }); + }); + module('decodeJwt(jwt)', () => { test('decodes a valid JWT', assert => { const { header, payload } = decodeJwt(mockJwt); diff --git a/packages/backend/src/tokens/jwt/verifyJwt.ts b/packages/backend/src/tokens/jwt/verifyJwt.ts index de0c64067fc..b5ab9f5e935 100644 --- a/packages/backend/src/tokens/jwt/verifyJwt.ts +++ b/packages/backend/src/tokens/jwt/verifyJwt.ts @@ -7,9 +7,9 @@ import runtime from '../../runtime'; import { base64url } from '../../util/rfc4648'; import { deprecated } from '../../util/shared'; import { TokenVerificationError, TokenVerificationErrorAction, TokenVerificationErrorReason } from '../errors'; +import { getCryptoAlgorithm } from './algorithms'; import type { IssuerResolver } from './assertions'; import { - algToHash, assertActivationClaim, assertAudienceClaim, assertAuthorizedPartiesClaim, @@ -20,38 +20,19 @@ import { assertIssuerClaim, assertSubClaim, } from './assertions'; +import { importKey } from './cryptoKeys'; const DEFAULT_CLOCK_SKEW_IN_SECONDS = 5 * 1000; -const RSA_ALGORITHM_NAME = 'RSASSA-PKCS1-v1_5'; -const EC_ALGORITHM_NAME = 'ECDSA'; - -const jwksAlgToCryptoAlg: Record = { - 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) { +export async function hasValidSignature(jwt: Jwt, key: JsonWebKey | string) { const { header, signature, raw } = jwt; const encoder = new TextEncoder(); const data = encoder.encode([raw.header, raw.payload].join('.')); + const algorithm = getCryptoAlgorithm(header.alg); - const cryptoKey = await runtime.crypto.subtle.importKey( - 'jwk', - jwk, - { - name: jwksAlgToCryptoAlg[header.alg], - hash: algToHash[header.alg], - }, - false, - ['verify'], - ); + const cryptoKey = await importKey(key, algorithm, 'verify'); - return runtime.crypto.subtle.verify('RSASSA-PKCS1-v1_5', cryptoKey, signature, data); + return runtime.crypto.subtle.verify(algorithm.name, cryptoKey, signature, data); } export function decodeJwt(token: string): Jwt { @@ -115,7 +96,7 @@ export type VerifyJwtOptions = { clockSkewInSeconds?: number; clockSkewInMs?: number; issuer: IssuerResolver | string | null; - key: JsonWebKey; + key: JsonWebKey | string; }; // TODO: Revise the return types. Maybe it's better to throw an error instead of return an object with a reason diff --git a/packages/backend/tests/suites.ts b/packages/backend/tests/suites.ts index 776d490a34f..6f9d836936a 100644 --- a/packages/backend/tests/suites.ts +++ b/packages/backend/tests/suites.ts @@ -1,21 +1,19 @@ // Import all suites // TODO: Automate this step using dynamic imports import apiTest from './dist/api/factory.test.js'; -import requestTest from './dist/tokens/request.test.js'; -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 interstitialRequestTest from './dist/tokens/interstitial.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'; - import exportsTest from './dist/exports.test.js'; - import redirectTest from './dist/redirections.test.js'; +import interstitialRequestTest from './dist/tokens/interstitial.test.js'; +import jwtAssertionsTest from './dist/tokens/jwt/assertions.test.js'; +import cryptoKeysTest from './dist/tokens/jwt/cryptoKeys.test.js'; +import signJwtTest from './dist/tokens/jwt/signJwt.test.js'; +import verifyJwtTest from './dist/tokens/jwt/verifyJwt.test.js'; +import keysTest from './dist/tokens/keys.test.js'; +import requestTest from './dist/tokens/request.test.js'; +import verifyTest from './dist/tokens/verify.test.js'; +import pathTest from './dist/util/path.test.js'; +import utilRequestTest from './dist/util/request.test.js'; import utilsTest from './dist/utils.test.js'; // Add them to the suite array @@ -29,6 +27,8 @@ const suites = [ verifyTest, pathTest, verifyJwtTest, + signJwtTest, + cryptoKeysTest, factoryTest, redirectTest, utilsTest, diff --git a/packages/nextjs/src/api/__tests__/__snapshots__/exports.test.ts.snap b/packages/nextjs/src/api/__tests__/__snapshots__/exports.test.ts.snap index 4ad3ce55684..332b415bd70 100644 --- a/packages/nextjs/src/api/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/nextjs/src/api/__tests__/__snapshots__/exports.test.ts.snap @@ -50,6 +50,7 @@ exports[`/api public exports should not include a breaking change 1`] = ` "requireAuth", "sanitizeAuthObject", "sessions", + "signJwt", "signedInAuthObject", "signedOutAuthObject", "smsMessages", diff --git a/packages/nextjs/src/edge-middleware/__tests__/__snapshots__/exports.test.ts.snap b/packages/nextjs/src/edge-middleware/__tests__/__snapshots__/exports.test.ts.snap index c55347c892b..d8da61fad66 100644 --- a/packages/nextjs/src/edge-middleware/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/nextjs/src/edge-middleware/__tests__/__snapshots__/exports.test.ts.snap @@ -52,6 +52,7 @@ exports[`/edge-middleware public exports should not include a breaking change 1` "requireEdgeMiddlewareAuth", "sanitizeAuthObject", "sessions", + "signJwt", "signedInAuthObject", "signedOutAuthObject", "smsMessages", diff --git a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap index dc44f186a2f..e5b9aa7de1f 100644 --- a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap @@ -63,6 +63,7 @@ exports[`/server public exports should not include a breaking change 1`] = ` "redirectToSignIn", "redirectToSignUp", "sanitizeAuthObject", + "signJwt", "signedInAuthObject", "signedOutAuthObject", "verifyJwt", diff --git a/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap index c3218eb0c11..3e396fa8449 100644 --- a/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap @@ -60,6 +60,7 @@ exports[`module exports should not change unless explicitly set 1`] = ` "setClerkApiVersion", "setClerkHttpOptions", "setClerkServerApiUrl", + "signJwt", "signedInAuthObject", "signedOutAuthObject", "smsMessages",