diff --git a/.changeset/cold-bags-watch.md b/.changeset/cold-bags-watch.md new file mode 100644 index 00000000000..6c6c52d1417 --- /dev/null +++ b/.changeset/cold-bags-watch.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Added \_\_unstable\_\_signJwt diff --git a/packages/backend/src/exports.test.ts b/packages/backend/src/exports.test.ts index 493624c141b..0e2f76f2f0e 100644 --- a/packages/backend/src/exports.test.ts +++ b/packages/backend/src/exports.test.ts @@ -32,6 +32,7 @@ export default (QUnit: QUnit) => { 'Token', 'User', 'Verification', + '__unstable__signJwt', 'buildRequestUrl', 'constants', 'createAuthenticateRequest', diff --git a/packages/backend/src/tokens/fixtures.ts b/packages/backend/src/tokens/fixtures.ts index 88443d0e584..f375dfd91f3 100644 --- a/packages/backend/src/tokens/fixtures.ts +++ b/packages/backend/src/tokens/fixtures.ts @@ -60,6 +60,35 @@ export const mockPEMJwtKey = '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 @@ -80,6 +109,22 @@ 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, diff --git a/packages/backend/src/tokens/jwt/index.ts b/packages/backend/src/tokens/jwt/index.ts index 63c06ad2187..3ccac552ada 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 { __unstable__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..9919f5ae65d --- /dev/null +++ b/packages/backend/src/tokens/jwt/signJwt.test.ts @@ -0,0 +1,37 @@ +import type QUnit from 'qunit'; + +import { + mockJwtHeader, + mockJwtPayload, + pemEncodedPublicKey, + pemEncodedSignKey, + publicJwks, + signingJwks, +} from '../fixtures'; +import { hasValidSignature } from './hasValidSignature'; +import { __unstable__signJwt } from './signJwt'; +import { decodeJwt } from './verifyJwt'; + +export default (QUnit: QUnit) => { + const { module, test } = QUnit; + + module('signJwt(payload, options)', () => { + test('signs a JWT with a JWK formatted secret', async assert => { + const jwt = await __unstable__signJwt(mockJwtPayload, signingJwks, { + algorithm: mockJwtHeader.alg, + header: mockJwtHeader, + }); + + assert.true(await hasValidSignature(decodeJwt(jwt), publicJwks)); + }); + + test('signs a JWT with a pkcs8 formatted secret', async assert => { + const jwt = await __unstable__signJwt(mockJwtPayload, pemEncodedSignKey, { + algorithm: mockJwtHeader.alg, + header: mockJwtHeader, + }); + + assert.true(await hasValidSignature(decodeJwt(jwt), pemEncodedPublicKey)); + }); + }); +}; diff --git a/packages/backend/src/tokens/jwt/signJwt.ts b/packages/backend/src/tokens/jwt/signJwt.ts new file mode 100644 index 00000000000..5729c8faa1c --- /dev/null +++ b/packages/backend/src/tokens/jwt/signJwt.ts @@ -0,0 +1,43 @@ +import runtime from '../../runtime'; +import { base64url } from '../../util/rfc4648'; +import { getCryptoAlgorithm, importKey } from './algorithms'; + +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 }); +} + +export async function __unstable__signJwt( + payload: Record, + secret: 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(secret, algorithm, 'sign'); + const header = options.header || { typ: 'JWT' }; + header.alg = options.algorithm; + + 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/tests/suites.ts b/packages/backend/tests/suites.ts index 6d9091e8627..5f6ec01998c 100644 --- a/packages/backend/tests/suites.ts +++ b/packages/backend/tests/suites.ts @@ -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 signJwtTest from './dist/tokens/jwt/signJwt.test.js'; import hasValidSignatureTest from './dist/tokens/jwt/hasValidSignature.test.js'; import interstitialRequestTest from './dist/tokens/interstitial.test.js'; @@ -30,6 +31,7 @@ const suites = [ verifyTest, pathTest, verifyJwtTest, + signJwtTest, hasValidSignatureTest, factoryTest, redirectTest, 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..9b220594a68 100644 --- a/packages/nextjs/src/api/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/nextjs/src/api/__tests__/__snapshots__/exports.test.ts.snap @@ -26,6 +26,7 @@ exports[`/api public exports should not include a breaking change 1`] = ` "Token", "User", "Verification", + "__unstable__signJwt", "allowlistIdentifiers", "buildRequestUrl", "clerkClient", 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..bbed3a5532e 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 @@ -26,6 +26,7 @@ exports[`/edge-middleware public exports should not include a breaking change 1` "Token", "User", "Verification", + "__unstable__signJwt", "allowlistIdentifiers", "buildRequestUrl", "clerkApi", 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..e59e1689cdf 100644 --- a/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/nextjs/src/server/__tests__/__snapshots__/exports.test.ts.snap @@ -40,6 +40,7 @@ exports[`/server public exports should not include a breaking change 1`] = ` "Token", "User", "Verification", + "__unstable__signJwt", "auth", "authMiddleware", "buildClerkProps", 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..d970852e0c7 100644 --- a/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/sdk-node/src/__tests__/__snapshots__/exports.test.ts.snap @@ -28,6 +28,7 @@ exports[`module exports should not change unless explicitly set 1`] = ` "Token", "User", "Verification", + "__unstable__signJwt", "allowlistIdentifiers", "buildRequestUrl", "clerkClient",