diff --git a/.changeset/rich-trees-smoke.md b/.changeset/rich-trees-smoke.md new file mode 100644 index 00000000000..2efaf1f1503 --- /dev/null +++ b/.changeset/rich-trees-smoke.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': patch +--- + +Switch to `crypto-es` and bundle the dependency to avoid downstream build issues diff --git a/integration/tests/next-build.test.ts b/integration/tests/next-build.test.ts index 43b3e081890..0062fe711a3 100644 --- a/integration/tests/next-build.test.ts +++ b/integration/tests/next-build.test.ts @@ -202,7 +202,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) // Get the indicator from the build output const indicator = getIndicator(app.buildOutput, type); - const pageLine = app.buildOutput.split('\n').find(msg => msg.includes(page)); + const pageLine = app.buildOutput.split('\n').find(msg => msg.includes(` ${page}`)); expect(pageLine).toContain(indicator); }); diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 337447885b5..bb05909fe2a 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -70,12 +70,11 @@ "@clerk/clerk-react": "workspace:^", "@clerk/shared": "workspace:^", "@clerk/types": "workspace:^", - "crypto-js": "4.2.0", "server-only": "0.0.1", "tslib": "catalog:repo" }, "devDependencies": { - "@types/crypto-js": "4.2.2", + "crypto-es": "^2.1.0", "next": "^14.2.24" }, "peerDependencies": { diff --git a/packages/nextjs/src/app-router/keyless-actions.ts b/packages/nextjs/src/app-router/keyless-actions.ts index 01d855fac6f..3219ff8155d 100644 --- a/packages/nextjs/src/app-router/keyless-actions.ts +++ b/packages/nextjs/src/app-router/keyless-actions.ts @@ -13,7 +13,7 @@ export async function syncKeylessConfigAction(args: AccountlessApplication & { r const cookieStore = await cookies(); const request = new Request('https://placeholder.com', { headers: await headers() }); - const keyless = getKeylessCookieValue(name => cookieStore.get(name)?.value); + const keyless = await getKeylessCookieValue(name => cookieStore.get(name)?.value); const pksMatch = keyless?.publishableKey === publishableKey; const sksMatch = keyless?.secretKey === secretKey; if (pksMatch && sksMatch) { @@ -22,7 +22,7 @@ export async function syncKeylessConfigAction(args: AccountlessApplication & { r } // Set the new keys in the cookie. - cookieStore.set(getKeylessCookieName(), JSON.stringify({ claimUrl, publishableKey, secretKey }), { + cookieStore.set(await getKeylessCookieName(), JSON.stringify({ claimUrl, publishableKey, secretKey }), { secure: true, httpOnly: true, }); @@ -64,7 +64,7 @@ export async function createOrReadKeylessAction(): Promise { it('returns a getAuth function', () => { diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 0d7c88a0941..dbec34e3cb7 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -104,7 +104,7 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { // Handles the case where `options` is a callback function to dynamically access `NextRequest` const resolvedParams = typeof params === 'function' ? await params(request) : params; - const keyless = getKeylessCookieValue(name => request.cookies.get(name)?.value); + const keyless = await getKeylessCookieValue(name => request.cookies.get(name)?.value); const publishableKey = assertKey( resolvedParams.publishableKey || PUBLISHABLE_KEY || keyless?.publishableKey, @@ -224,7 +224,7 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]) => { } const resolvedParams = typeof params === 'function' ? await params(request) : params; - const keyless = getKeylessCookieValue(name => request.cookies.get(name)?.value); + const keyless = await getKeylessCookieValue(name => request.cookies.get(name)?.value); const isMissingPublishableKey = !(resolvedParams.publishableKey || PUBLISHABLE_KEY || keyless?.publishableKey); /** * In keyless mode, if the publishable key is missing, let the request through, to render `` that will resume the flow gracefully. diff --git a/packages/nextjs/src/server/keyless.ts b/packages/nextjs/src/server/keyless.ts index 8cd71998dfd..ca40e33ca7b 100644 --- a/packages/nextjs/src/server/keyless.ts +++ b/packages/nextjs/src/server/keyless.ts @@ -1,12 +1,19 @@ import type { AccountlessApplication } from '@clerk/backend'; -import hex from 'crypto-js/enc-hex'; -import sha256 from 'crypto-js/sha256'; import { canUseKeyless } from '../utils/feature-flags'; const keylessCookiePrefix = `__clerk_keys_`; -const getKeylessCookieName = (): string => { +async function hashString(str: string) { + const encoder = new TextEncoder(); + const data = encoder.encode(str); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return hashHex.slice(0, 16); // Take only the first 16 characters +} + +async function getKeylessCookieName(): Promise { // eslint-disable-next-line turbo/no-undeclared-env-vars const PATH = process.env.PWD; @@ -18,21 +25,19 @@ const getKeylessCookieName = (): string => { const lastThreeDirs = PATH.split('/').filter(Boolean).slice(-3).reverse().join('/'); // Hash the resulting string - const hash = hashString(lastThreeDirs); + const hash = await hashString(lastThreeDirs); return `${keylessCookiePrefix}${hash}`; -}; - -function hashString(str: string) { - return sha256(str).toString(hex).slice(0, 16); // Take only the first 16 characters } -function getKeylessCookieValue(getter: (cookieName: string) => string | undefined): AccountlessApplication | undefined { +async function getKeylessCookieValue( + getter: (cookieName: string) => string | undefined, +): Promise { if (!canUseKeyless) { return undefined; } - const keylessCookieName = getKeylessCookieName(); + const keylessCookieName = await getKeylessCookieName(); let keyless; try { diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index eb79bb83ffb..43bbe3e5ab4 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -4,13 +4,11 @@ import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { logger } from '@clerk/shared/logger'; import { isHttpOrHttps } from '@clerk/shared/proxy'; import { handleValueOrFn, isProductionEnvironment } from '@clerk/shared/utils'; -import AES from 'crypto-js/aes'; -import encUtf8 from 'crypto-js/enc-utf8'; -import hmacSHA1 from 'crypto-js/hmac-sha1'; import { NextResponse } from 'next/server'; import { constants as nextConstants } from '../constants'; import { canUseKeyless } from '../utils/feature-flags'; +import { AES, HmacSHA1, Utf8 } from '../vendor/crypto-es'; import { DOMAIN, ENCRYPTION_KEY, IS_SATELLITE, PROXY_URL, SECRET_KEY, SIGN_IN_URL } from './constants'; import { authSignatureInvalid, @@ -34,7 +32,7 @@ export const setRequestHeadersOnNextResponse = ( if (!res.headers.get(OVERRIDE_HEADERS)) { // Emulate a user setting overrides by explicitly adding the required nextjs headers // https://github.com/vercel/next.js/pull/41380 - // @ts-expect-error + // @ts-expect-error -- property keys does not exist on type Headers res.headers.set(OVERRIDE_HEADERS, [...req.headers.keys()]); req.headers.forEach((val, key) => { res.headers.set(`${MIDDLEWARE_HEADER_PREFIX}-${key}`, val); @@ -160,7 +158,7 @@ export function assertKey(key: string | undefined, onError: () => never): string * Compute a cryptographic signature from a session token and provided secret key. Used to validate that the token has not been modified when transferring between middleware and the Next.js origin. */ function createTokenSignature(token: string, key: string): string { - return hmacSHA1(token, key).toString(); + return HmacSHA1(token, key).toString(); } /** @@ -258,6 +256,6 @@ function throwInvalidEncryptionKey(): never { function decryptData(data: string, key: string) { const decryptedBytes = AES.decrypt(data, key); - const encoded = decryptedBytes.toString(encUtf8); + const encoded = decryptedBytes.toString(Utf8); return JSON.parse(encoded); } diff --git a/packages/nextjs/src/vendor/README.md b/packages/nextjs/src/vendor/README.md new file mode 100644 index 00000000000..463fc75ab19 --- /dev/null +++ b/packages/nextjs/src/vendor/README.md @@ -0,0 +1,7 @@ +# Vendored dependencies + +The modules in this folder contain vendored dependencies that are built and inlined into the published package. + +## crypto-es + +Used as a synchronous replacement for the [Web Crypto APIs](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). Currently, we use crypto-es to encrypt and decrypt data transferred between Next.js middleware and the application server. diff --git a/packages/nextjs/src/vendor/crypto-es.js b/packages/nextjs/src/vendor/crypto-es.js new file mode 100644 index 00000000000..1db2952dd1b --- /dev/null +++ b/packages/nextjs/src/vendor/crypto-es.js @@ -0,0 +1,5 @@ +import { AES } from 'crypto-es/lib/aes'; +import { Utf8 } from 'crypto-es/lib/core'; +import { HmacSHA1 } from 'crypto-es/lib/sha1'; + +export { AES, HmacSHA1, Utf8 }; diff --git a/packages/nextjs/tsup.config.ts b/packages/nextjs/tsup.config.ts index 0873f41802c..294ddab325f 100644 --- a/packages/nextjs/tsup.config.ts +++ b/packages/nextjs/tsup.config.ts @@ -15,6 +15,7 @@ export default defineConfig(overrideOptions => { '!./src/**/*.test.{ts,tsx}', '!./src/**/server-actions.ts', '!./src/**/keyless-actions.ts', + '!./src/vendor/**', ], // We want to preserve original file structure // so that the "use client" directives are not lost @@ -43,6 +44,9 @@ export default defineConfig(overrideOptions => { outDir: './dist/cjs', }; + /** + * When server actions are built with sourcemaps, Next.js does not resolve the sourcemap URLs during build and so browser dev tools attempt to load source maps for these files from incorrect locations + */ const serverActionsEsm: Options = { ...esm, entry: ['./src/**/server-actions.ts', './src/**/keyless-actions.ts'], @@ -55,6 +59,31 @@ export default defineConfig(overrideOptions => { sourcemap: false, }; + /** + * We vendor certain dependencies to control the output and minimize transitive dependency surface area. + */ + const vendorsEsm: Options = { + ...esm, + bundle: true, + minify: true, + entry: ['./src/vendor/*.js'], + outDir: './dist/esm/vendor', + legacyOutput: false, + outExtension: () => ({ + js: '.js', + }), + sourcemap: false, + }; + + const vendorsCjs: Options = { + ...cjs, + bundle: true, + minify: true, + entry: ['./src/vendor/*.js'], + outDir: './dist/cjs/vendor', + sourcemap: false, + }; + const copyPackageJson = (format: 'esm' | 'cjs') => `cp ./package.${format}.json ./dist/${format}/package.json`; // Tsup will not output the generated file in the same location as the source file // So we need to move the server-actions.js file to the app-router folder manually @@ -73,5 +102,5 @@ export default defineConfig(overrideOptions => { moveKeylessActions('esm'), moveKeylessActions('cjs'), shouldPublish && 'pnpm publish:local', - ])(esm, cjs, serverActionsEsm, serverActionsCjs); + ])(esm, cjs, serverActionsEsm, serverActionsCjs, vendorsEsm, vendorsCjs); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12fd38607b9..36bac02b680 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,9 +712,6 @@ importers: '@clerk/types': specifier: workspace:^ version: link:../types - crypto-js: - specifier: 4.2.0 - version: 4.2.0 react: specifier: catalog:peer-react version: 18.3.1 @@ -728,9 +725,9 @@ importers: specifier: catalog:repo version: 2.4.1 devDependencies: - '@types/crypto-js': - specifier: 4.2.2 - version: 4.2.2 + crypto-es: + specifier: ^2.1.0 + version: 2.1.0 next: specifier: ^14.2.24 version: 14.2.24(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.44.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -5594,9 +5591,6 @@ packages: '@types/cross-spawn@6.0.3': resolution: {integrity: sha512-BDAkU7WHHRHnvBf5z89lcvACsvkz/n7Tv+HyD/uW76O29HoH1Tk/W6iQrepaZVbisvlEek4ygwT8IW7ow9XLAA==} - '@types/crypto-js@4.2.2': - resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} - '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -7541,6 +7535,9 @@ packages: crypt@0.0.2: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} + crypto-es@2.1.0: + resolution: {integrity: sha512-C5Dbuv4QTPGuloy5c5Vv/FZHtmK+lobLAypFfuRaBbwCsk3qbCWWESCH3MUcBsrgXloRNMrzwUAiPg4U6+IaKA==} + crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} @@ -21729,8 +21726,6 @@ snapshots: dependencies: '@types/node': 22.13.4 - '@types/crypto-js@4.2.2': {} - '@types/debug@4.1.12': dependencies: '@types/ms': 0.7.34 @@ -24209,6 +24204,8 @@ snapshots: crypt@0.0.2: {} + crypto-es@2.1.0: {} + crypto-js@4.2.0: {} crypto-random-string@1.0.0: {}