diff --git a/.changeset/young-pigs-live.md b/.changeset/young-pigs-live.md new file mode 100644 index 0000000000..5e4ce82895 --- /dev/null +++ b/.changeset/young-pigs-live.md @@ -0,0 +1,11 @@ +--- +'@clerk/backend': minor +'@clerk/nextjs': minor +--- + +Introduces dynamic keys from `clerkMiddleware`, allowing access by server-side helpers like `auth`. Keys such as `signUpUrl`, `signInUrl`, `publishableKey` and `secretKey` are securely encrypted using AES algorithm. + +- When providing `secretKey`, `CLERK_ENCRYPTION_KEY` is required as the encryption key. If `secretKey` is not provided, `CLERK_SECRET_KEY` is used by default. +- `clerkClient` from `@clerk/nextjs` should now be called as a function, and its singleton form is deprecated. This change allows the Clerk backend client to read keys from the current request, which is necessary to support dynamic keys. + +For more information, refer to the documentation: https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e3973e515..ce95993ec2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,6 +206,7 @@ jobs: E2E_CLERK_VERSION: 'latest' E2E_NEXTJS_VERSION: ${{ matrix.next-version }} E2E_PROJECT: ${{ matrix.test-project }} + E2E_CLERK_ENCRYPTION_KEY: ${{ matrix.clerk-encryption-key }} INTEGRATION_INSTANCE_KEYS: ${{ secrets.INTEGRATION_INSTANCE_KEYS }} MAILSAC_API_KEY: ${{ secrets.MAILSAC_API_KEY }} diff --git a/integration/constants.ts b/integration/constants.ts index 0154f72f82..d58e396ed1 100644 --- a/integration/constants.ts +++ b/integration/constants.ts @@ -67,6 +67,11 @@ export const constants = { * The version of the dependency to use, controlled programmatically. */ E2E_CLERK_VERSION: process.env.E2E_CLERK_VERSION, + /** + * Key used to encrypt request data for Next.js dynamic keys. + * @ref https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys + */ + E2E_CLERK_ENCRYPTION_KEY: process.env.CLERK_ENCRYPTION_KEY, /** * PK and SK pairs from the env to use for integration tests. */ diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 55b086554b..ffa819df68 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -29,7 +29,8 @@ const withEmailCodes = environmentConfig() .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['with-email-codes'].pk) .setEnvVariable('public', 'CLERK_SIGN_IN_URL', '/sign-in') .setEnvVariable('public', 'CLERK_SIGN_UP_URL', '/sign-up') - .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js'); + .setEnvVariable('public', 'CLERK_JS_URL', constants.E2E_APP_CLERK_JS || 'http://localhost:18211/clerk.browser.js') + .setEnvVariable('private', 'CLERK_ENCRYPTION_KEY', constants.E2E_CLERK_ENCRYPTION_KEY); const withEmailLinks = environmentConfig() .setId('withEmailLinks') @@ -81,6 +82,12 @@ const withAPCore2ClerkV4 = environmentConfig() .setEnvVariable('private', 'CLERK_SECRET_KEY', envKeys['core-2-all-enabled'].sk) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', envKeys['core-2-all-enabled'].pk); +const withDynamicKeys = withEmailCodes + .clone() + .setId('withDynamicKeys') + .setEnvVariable('private', 'CLERK_SECRET_KEY', '') + .setEnvVariable('private', 'CLERK_DYNAMIC_SECRET_KEY', envKeys['with-email-codes'].sk); + export const envs = { withEmailCodes, withEmailLinks, @@ -90,4 +97,5 @@ export const envs = { withAPCore1ClerkV4, withAPCore2ClerkLatest, withAPCore2ClerkV4, + withDynamicKeys, } as const; diff --git a/integration/tests/dynamic-keys.test.ts b/integration/tests/dynamic-keys.test.ts new file mode 100644 index 0000000000..4c08e8656c --- /dev/null +++ b/integration/tests/dynamic-keys.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from '@playwright/test'; + +import type { Application } from '../models/application'; +import { appConfigs } from '../presets'; +import { createTestUtils } from '../testUtils'; + +test.describe('dynamic keys @nextjs', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + + test.beforeAll(async () => { + app = await appConfigs.next.appRouter + .clone() + .addFile( + 'src/middleware.ts', + () => `import { clerkClient, clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' + import { NextResponse } from 'next/server' + + const isProtectedRoute = createRouteMatcher(['/protected']); + const shouldFetchBapi = createRouteMatcher(['/fetch-bapi-from-middleware']); + + export default clerkMiddleware(async (auth, request) => { + if (isProtectedRoute(request)) { + auth().protect(); + } + + if (shouldFetchBapi(request)){ + const count = await clerkClient().users.getCount(); + + if (count){ + return NextResponse.redirect(new URL('/users-count', request.url)) + } + } + }, { + secretKey: process.env.CLERK_DYNAMIC_SECRET_KEY, + signInUrl: '/foobar' + }); + + export const config = { + matcher: ['/((?!.*\\\\..*|_next).*)', '/', '/(api|trpc)(.*)'], + };`, + ) + .addFile( + 'src/app/users-count/page.tsx', + () => `import { clerkClient } from '@clerk/nextjs/server' + + export default async function Page(){ + const count = await clerkClient().users.getCount() + + return

Users count: {count}

+ } + `, + ) + .commit(); + + await app.setup(); + await app.withEnv(appConfigs.envs.withDynamicKeys); + await app.dev(); + }); + + test.afterAll(async () => { + await app.teardown(); + }); + + test.afterEach(async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.signOut(); + await u.page.context().clearCookies(); + }); + + test('redirects to `signInUrl` on `auth().protect()`', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.goToStart(); + + await u.po.expect.toBeSignedOut(); + + await u.page.goToRelative('/protected'); + + await u.page.waitForURL(/foobar/); + }); + + test('resolves auth signature with `secretKey` on `auth().protect()`', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/page-protected'); + await u.page.waitForURL(/foobar/); + }); + + test('calls `clerkClient` with dynamic keys from application runtime', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/users-count'); + await expect(u.page.getByText(/Users count/i)).toBeVisible(); + }); + + test('calls `clerkClient` with dynamic keys from middleware runtime', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/fetch-bapi-from-middleware'); + await u.page.waitForAppUrl('/users-count'); + await expect(u.page.getByText(/Users count/i)).toBeVisible(); + }); +}); diff --git a/integration/tests/handshake.test.ts b/integration/tests/handshake.test.ts index 5057145ef2..7d8cd203e0 100644 --- a/integration/tests/handshake.test.ts +++ b/integration/tests/handshake.test.ts @@ -8,7 +8,7 @@ import { generateConfig, getJwksFromSecretKey } from '../testUtils/handshake'; const PORT = 4199; -test.skip('Client handshake @generic', () => { +test.describe('Client handshake @generic', () => { test.describe.configure({ mode: 'serial' }); let app: Application; diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index fa7ac5c568..7fff948c6b 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -39,6 +39,7 @@ const Headers = { AuthMessage: 'x-clerk-auth-message', ClerkUrl: 'x-clerk-clerk-url', EnableDebug: 'x-clerk-debug', + ClerkRequestData: 'x-clerk-request-data', ClerkRedirectTo: 'x-clerk-redirect-to', CloudFrontForwardedProto: 'cloudfront-forwarded-proto', Authorization: 'authorization', diff --git a/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap b/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap index 023875d97b..a5daa66361 100644 --- a/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap +++ b/packages/fastify/src/__tests__/__snapshots__/constants.test.ts.snap @@ -19,6 +19,7 @@ exports[`constants from environment variables 1`] = ` "AuthToken": "x-clerk-auth-token", "Authorization": "authorization", "ClerkRedirectTo": "x-clerk-redirect-to", + "ClerkRequestData": "x-clerk-request-data", "ClerkUrl": "x-clerk-clerk-url", "CloudFrontForwardedProto": "cloudfront-forwarded-proto", "ContentType": "content-type", diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index f0f66533ec..97302ea3d7 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -8,7 +8,7 @@ import { createGetAuth } from '../../server/createGetAuth'; import { authAuthHeaderMissing } from '../../server/errors'; import type { AuthProtect } from '../../server/protect'; import { createProtect } from '../../server/protect'; -import { getAuthKeyFromRequest } from '../../server/utils'; +import { decryptClerkRequestData, getAuthKeyFromRequest, getHeader } from '../../server/utils'; import { buildRequestLike } from './utils'; type Auth = AuthObject & { protect: AuthProtect; redirectToSignIn: RedirectFun> }; @@ -28,15 +28,16 @@ export const auth = (): Auth => { clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.DevBrowser) || clerkRequest.cookies.get(constants.Cookies.DevBrowser); + const encryptedRequestData = getHeader(request, constants.Headers.ClerkRequestData); + const decryptedRequestData = decryptClerkRequestData(encryptedRequestData); + return createRedirect({ redirectAdapter: redirect, devBrowserToken: devBrowserToken, baseUrl: clerkRequest.clerkUrl.toString(), - // TODO: Support runtime-value configuration of these options - // via setting and reading headers from clerkMiddleware - publishableKey: PUBLISHABLE_KEY, - signInUrl: SIGN_IN_URL, - signUpUrl: SIGN_UP_URL, + publishableKey: decryptedRequestData.publishableKey || PUBLISHABLE_KEY, + signInUrl: decryptedRequestData.signInUrl || SIGN_IN_URL, + signUpUrl: decryptedRequestData.signUpUrl || SIGN_UP_URL, }).redirectToSignIn({ returnBackUrl: opts.returnBackUrl === null ? '' : opts.returnBackUrl || clerkUrl?.toString(), }); diff --git a/packages/nextjs/src/app-router/server/currentUser.ts b/packages/nextjs/src/app-router/server/currentUser.ts index ae3a2090f8..4d7cb2d7be 100644 --- a/packages/nextjs/src/app-router/server/currentUser.ts +++ b/packages/nextjs/src/app-router/server/currentUser.ts @@ -9,5 +9,5 @@ export async function currentUser(): Promise { return null; } - return clerkClient.users.getUser(userId); + return clerkClient().users.getUser(userId); } diff --git a/packages/nextjs/src/app-router/server/utils.ts b/packages/nextjs/src/app-router/server/utils.ts index e8375c319b..5d732c1b9e 100644 --- a/packages/nextjs/src/app-router/server/utils.ts +++ b/packages/nextjs/src/app-router/server/utils.ts @@ -1,6 +1,6 @@ import { NextRequest } from 'next/server'; -const isPrerenderingBailout = (e: unknown) => { +export const isPrerenderingBailout = (e: unknown) => { if (!(e instanceof Error) || !('message' in e)) { return false; } diff --git a/packages/nextjs/src/server/__tests__/clerkClient.test.ts b/packages/nextjs/src/server/__tests__/clerkClient.test.ts index 1ceedefc0f..58b6e883dd 100644 --- a/packages/nextjs/src/server/__tests__/clerkClient.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkClient.test.ts @@ -4,7 +4,7 @@ import { clerkClient } from '../clerkClient'; describe('clerkClient', () => { it('should pass version package to userAgent', async () => { - await clerkClient.users.getUser('user_test'); + await clerkClient().users.getUser('user_test'); expect(global.fetch).toBeCalled(); expect((global.fetch as any).mock.calls[0][1].headers).toMatchObject({ diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts index 8996175599..d793c9493e 100644 --- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts @@ -14,17 +14,20 @@ const authenticateRequestMock = jest.fn().mockResolvedValue({ jest.mock('../clerkClient', () => { return { - clerkClient: { + clerkClient: () => ({ authenticateRequest: authenticateRequestMock, telemetry: { record: jest.fn() }, - }, + }), }; }); // used to assert the mock +import assert from 'assert'; + import { clerkClient } from '../clerkClient'; import { clerkMiddleware } from '../clerkMiddleware'; import { createRouteMatcher } from '../routeMatcher'; +import { decryptClerkRequestData } from '../utils'; /** * Disable console warnings about config matchers @@ -44,6 +47,7 @@ jest.mock('../constants', () => { return { PUBLISHABLE_KEY: 'pk_test_Y2xlcmsuaW5jbHVkZWQua2F0eWRpZC05Mi5sY2wuZGV2JA', SECRET_KEY: 'sk_test_xxxxxxxxxxxxxxxxxx', + ENCRYPTION_KEY: 'encryption-key', }; }); @@ -216,6 +220,25 @@ describe('clerkMiddleware(params)', () => { expect(signInResp?.headers.get('a-custom-header')).toEqual('1'); }); + it('propagates middleware dynamic keys to the next request', async () => { + const options = { + secretKey: 'sk_test_xxxxxxxxxxxxxxxxxx', + publishableKey: 'pk_test_xxxxxxxxxxxxx', + signInUrl: '/foo', + signUpUrl: '/bar', + }; + const resp = await clerkMiddleware(options)(mockRequest({ url: '/sign-in' }), {} as NextFetchEvent); + expect(resp?.status).toEqual(200); + + const requestData = resp?.headers.get('x-middleware-request-x-clerk-request-data'); + assert.ok(requestData); + + const decryptedData = decryptClerkRequestData(requestData); + + expect(resp?.headers.get('x-middleware-request-x-clerk-request-data')).toBeDefined(); + expect(decryptedData).toEqual(options); + }); + describe('auth().redirectToSignIn()', () => { it('redirects to sign-in url when redirectToSignIn is called and the request is a page request', async () => { const req = mockRequest({ @@ -230,7 +253,7 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(307); expect(resp?.headers.get('location')).toContain('sign-in'); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); it('redirects to sign-in url when redirectToSignIn is called with the correct returnBackUrl', async () => { @@ -247,7 +270,7 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(307); expect(resp?.headers.get('location')).toContain('sign-in'); expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toContain('/protected'); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); it('redirects to sign-in url with redirect_url set to the provided returnBackUrl param', async () => { @@ -266,7 +289,7 @@ describe('clerkMiddleware(params)', () => { expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toEqual( 'https://www.clerk.com/hello', ); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); it('redirects to sign-in url without a redirect_url when returnBackUrl is null', async () => { @@ -283,7 +306,7 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(307); expect(resp?.headers.get('location')).toContain('sign-in'); expect(new URL(resp!.headers.get('location')!).searchParams.get('redirect_url')).toBeNull(); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); }); @@ -308,7 +331,7 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(307); expect(resp?.headers.get('location')).toContain('sign-in'); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); it('does not redirect to sign-in url when protect is called, the user is signed in and the request is a page request', async () => { @@ -331,7 +354,7 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(200); expect(resp?.headers.get('location')).toBeFalsy(); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); it('throws a not found error when protect is called, the user is signed out, and is not a page request', async () => { @@ -354,7 +377,7 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(200); expect(resp?.headers.get(constants.Headers.AuthReason)).toContain('protect-rewrite'); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); it('throws a not found error when protect is called with RBAC params the user does not fulfill, and is a page request', async () => { @@ -377,7 +400,7 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(200); expect(resp?.headers.get(constants.Headers.AuthReason)).toContain('protect-rewrite'); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); it('redirects to unauthenticatedUrl when protect is called with the redirectUrl param, the user is signed out, and is a page request', async () => { @@ -401,7 +424,7 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(307); expect(resp?.headers.get('location')).toEqual('https://www.clerk.com/hello'); expect(resp?.headers.get(constants.Headers.ClerkRedirectTo)).toEqual('true'); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); it('redirects to unauthorizedUrl when protect is called with the redirectUrl param, the user does not fulfill the RBAC params, and is a page request', async () => { @@ -431,7 +454,7 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(307); expect(resp?.headers.get('location')).toEqual('https://www.clerk.com/discover'); expect(resp?.headers.get(constants.Headers.ClerkRedirectTo)).toEqual('true'); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); }); @@ -456,7 +479,7 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(307); expect(resp?.headers.get('location')).toContain('sign-in'); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); it('forwards headers from authenticateRequest when auth().protect() is called', async () => { @@ -484,7 +507,7 @@ describe('clerkMiddleware(params)', () => { expect(resp?.headers.get('X-Clerk-Auth')).toEqual('1'); expect(resp?.headers.get('Set-Cookie')).toEqual('session=;'); expect(resp?.headers.get('location')).toContain('sign-in'); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); it('redirects to unauthenticatedUrl when protect is called with the unauthenticatedUrl param, the user is signed out, and is a page request', async () => { @@ -511,7 +534,7 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(307); expect(resp?.headers.get('location')).toContain('https://www.clerk.com/unauthenticatedUrl'); expect(resp?.headers.get(constants.Headers.ClerkRedirectTo)).toEqual('true'); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); it('redirects to unauthorizedUrl when protect is called with the unauthorizedUrl param, the user is signed in but does not have permissions, and is a page request', async () => { @@ -541,7 +564,7 @@ describe('clerkMiddleware(params)', () => { expect(resp?.status).toEqual(307); expect(resp?.headers.get('location')).toContain('https://www.clerk.com/unauthorizedUrl'); expect(resp?.headers.get(constants.Headers.ClerkRedirectTo)).toEqual('true'); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); }); }); @@ -569,7 +592,7 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f expect(resp?.headers.get('location')).toEqual( 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected', ); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); it('appends the Dev Browser JWT to the search when cookie __clerk_db_jwt exists and location is an Account Portal URL', async () => { @@ -593,7 +616,7 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f expect(resp?.headers.get('location')).toEqual( 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected&__clerk_db_jwt=test_jwt', ); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); it('does NOT append the Dev Browser JWT if x-clerk-redirect-to header is not set (user-returned redirect)', async () => { @@ -619,6 +642,6 @@ describe('Dev Browser JWT when redirecting to cross origin for page requests', f expect(resp?.headers.get('location')).toEqual( 'https://accounts.included.katydid-92.lcl.dev/sign-in?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fprotected', ); - expect(clerkClient.authenticateRequest).toBeCalled(); + expect(clerkClient().authenticateRequest).toBeCalled(); }); }); diff --git a/packages/nextjs/src/server/authMiddleware.ts b/packages/nextjs/src/server/authMiddleware.ts index 1b560dcc0c..e9803a0322 100644 --- a/packages/nextjs/src/server/authMiddleware.ts +++ b/packages/nextjs/src/server/authMiddleware.ts @@ -219,7 +219,7 @@ const authMiddleware: AuthMiddleware = (...args: unknown[]) => { logger.debug(`Added ${constants.Headers.EnableDebug} on request`); } - const result = decorateRequest(clerkRequest, finalRes, requestState, secretKey) || NextResponse.next(); + const result = decorateRequest(clerkRequest, finalRes, requestState, { secretKey }) || NextResponse.next(); if (requestState.headers) { requestState.headers.forEach((value, key) => { diff --git a/packages/nextjs/src/server/buildClerkProps.ts b/packages/nextjs/src/server/buildClerkProps.ts index 0248a46123..ffffa401d8 100644 --- a/packages/nextjs/src/server/buildClerkProps.ts +++ b/packages/nextjs/src/server/buildClerkProps.ts @@ -1,6 +1,7 @@ import type { Organization, Session, User } from '@clerk/backend'; import { AuthStatus, + constants, makeAuthObjectSerializable, signedInAuthObject, signedOutAuthObject, @@ -10,7 +11,7 @@ import { decodeJwt } from '@clerk/backend/jwt'; import { API_URL, API_VERSION, SECRET_KEY } from './constants'; import type { RequestLike } from './types'; -import { getAuthKeyFromRequest, injectSSRStateIntoObject } from './utils'; +import { decryptClerkRequestData, getAuthKeyFromRequest, getHeader, injectSSRStateIntoObject } from './utils'; type BuildClerkPropsInitState = { user?: User | null; session?: Session | null; organization?: Organization | null }; @@ -38,8 +39,11 @@ export const buildClerkProps: BuildClerkProps = (req, initState = {}) => { const authMessage = getAuthKeyFromRequest(req, 'AuthMessage'); const authReason = getAuthKeyFromRequest(req, 'AuthReason'); + const encryptedRequestData = getHeader(req, constants.Headers.ClerkRequestData); + const decryptedRequestData = decryptClerkRequestData(encryptedRequestData); + const options = { - secretKey: SECRET_KEY, + secretKey: decryptedRequestData.secretKey || SECRET_KEY, apiUrl: API_URL, apiVersion: API_VERSION, authStatus, diff --git a/packages/nextjs/src/server/clerkClient.ts b/packages/nextjs/src/server/clerkClient.ts index 41b6559b5a..62d7a19cfd 100644 --- a/packages/nextjs/src/server/clerkClient.ts +++ b/packages/nextjs/src/server/clerkClient.ts @@ -1,5 +1,10 @@ +import type { ClerkClient } from '@clerk/backend'; import { createClerkClient } from '@clerk/backend'; +import { constants } from '@clerk/backend/internal'; +import { deprecated } from '@clerk/shared/deprecated'; +import { buildRequestLike, isPrerenderingBailout } from '../app-router/server/utils'; +import { clerkMiddlewareRequestDataStore } from './clerkMiddleware'; import { API_URL, API_VERSION, @@ -12,8 +17,9 @@ import { TELEMETRY_DEBUG, TELEMETRY_DISABLED, } from './constants'; +import { decryptClerkRequestData, getHeader } from './utils'; -const clerkClient = createClerkClient({ +const clerkClientDefaultOptions = { secretKey: SECRET_KEY, publishableKey: PUBLISHABLE_KEY, apiUrl: API_URL, @@ -27,6 +33,54 @@ const clerkClient = createClerkClient({ disabled: TELEMETRY_DISABLED, debug: TELEMETRY_DEBUG, }, -}); +}; + +const createClerkClientWithOptions: typeof createClerkClient = options => + createClerkClient({ ...clerkClientDefaultOptions, ...options }); + +/** + * @deprecated + * This singleton is deprecated and will be removed in a future release. Please use `clerkClient()` as a function instead. + */ +const clerkClientSingleton = createClerkClient(clerkClientDefaultOptions); + +/** + * Constructs a BAPI client that accesses request data within the runtime. + * Necessary if middleware dynamic keys are used. + */ +const clerkClientForRequest = () => { + let requestData; + + try { + const request = buildRequestLike(); + const encryptedRequestData = getHeader(request, constants.Headers.ClerkRequestData); + requestData = decryptClerkRequestData(encryptedRequestData); + } catch (err) { + if (err && isPrerenderingBailout(err)) { + throw err; + } + } + + // Fallbacks between options from middleware runtime and `NextRequest` from application server + const options = clerkMiddlewareRequestDataStore.getStore() ?? requestData; + if (options?.secretKey || options?.publishableKey) { + return createClerkClientWithOptions(options); + } + + return clerkClientSingleton; +}; + +interface ClerkClientExport extends ClerkClient { + (): ClerkClient; +} + +// TODO SDK-1839 - Remove `clerkClient` singleton in the next major version of `@clerk/nextjs` +const clerkClient = new Proxy(Object.assign(clerkClientForRequest, clerkClientSingleton), { + get(target, prop: string, receiver) { + deprecated('clerkClient singleton', 'Use `clerkClient()` as a function instead.'); + + return Reflect.get(target, prop, receiver); + }, +}) as ClerkClientExport; export { clerkClient }; diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts index 7c11f52038..586df62c11 100644 --- a/packages/nextjs/src/server/clerkMiddleware.ts +++ b/packages/nextjs/src/server/clerkMiddleware.ts @@ -7,6 +7,7 @@ import type { } from '@clerk/backend/internal'; import { AuthStatus, constants, createClerkRequest, createRedirect } from '@clerk/backend/internal'; import { eventMethodCalled } from '@clerk/shared/telemetry'; +import { AsyncLocalStorage } from 'async_hooks'; import type { NextMiddleware } from 'next/server'; import { NextResponse } from 'next/server'; @@ -69,6 +70,8 @@ interface ClerkMiddleware { (request: NextMiddlewareRequestParam, event: NextMiddlewareEvtParam): NextMiddlewareReturn; } +export const clerkMiddlewareRequestDataStore = new AsyncLocalStorage>(); + export const clerkMiddleware: ClerkMiddleware = withLogger('clerkMiddleware', logger => (...args: unknown[]): any => { const [request, event] = parseRequestAndEvent(args); const [handler, params] = parseHandlerAndOptions(args); @@ -91,82 +94,87 @@ export const clerkMiddleware: ClerkMiddleware = withLogger('clerkMiddleware', lo signUpUrl, }; - clerkClient.telemetry.record( - eventMethodCalled('clerkMiddleware', { - handler: Boolean(handler), - satellite: Boolean(options.isSatellite), - proxy: Boolean(options.proxyUrl), - }), - ); - - const nextMiddleware: NextMiddleware = async (request, event) => { - const clerkRequest = createClerkRequest(request); - logger.debug('options', options); - logger.debug('url', () => clerkRequest.toJSON()); - - const requestState = await clerkClient.authenticateRequest( - clerkRequest, - createAuthenticateRequestOptions(clerkRequest, options), + return clerkMiddlewareRequestDataStore.run(options, () => { + clerkClient().telemetry.record( + eventMethodCalled('clerkMiddleware', { + handler: Boolean(handler), + satellite: Boolean(options.isSatellite), + proxy: Boolean(options.proxyUrl), + }), ); - logger.debug('requestState', () => ({ - status: requestState.status, - headers: JSON.stringify(Object.fromEntries(requestState.headers)), - reason: requestState.reason, - })); - - const locationHeader = requestState.headers.get(constants.Headers.Location); - if (locationHeader) { - return new Response(null, { status: 307, headers: requestState.headers }); - } else if (requestState.status === AuthStatus.Handshake) { - throw new Error('Clerk: handshake status without redirect'); - } - - const authObject = requestState.toAuth(); - logger.debug('auth', () => ({ auth: authObject, debug: authObject.debug() })); - - const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest); - const protect = createMiddlewareProtect(clerkRequest, authObject, redirectToSignIn); - const authObjWithMethods: ClerkMiddlewareAuthObject = Object.assign(authObject, { protect, redirectToSignIn }); - - let handlerResult: Response = NextResponse.next(); - try { - handlerResult = (await handler?.(() => authObjWithMethods, request, event)) || handlerResult; - } catch (e: any) { - handlerResult = handleControlFlowErrors(e, clerkRequest, requestState); - } + const nextMiddleware: NextMiddleware = async (request, event) => { + const clerkRequest = createClerkRequest(request); + logger.debug('options', options); + logger.debug('url', () => clerkRequest.toJSON()); - // TODO @nikos: we need to make this more generic - // and move the logic in clerk/backend - if (requestState.headers) { - requestState.headers.forEach((value, key) => { - handlerResult.headers.append(key, value); - }); - } + const requestState = await clerkClient().authenticateRequest( + clerkRequest, + createAuthenticateRequestOptions(clerkRequest, options), + ); - if (isRedirect(handlerResult)) { - logger.debug('handlerResult is redirect'); - return serverRedirectWithAuth(clerkRequest, handlerResult, options); - } + logger.debug('requestState', () => ({ + status: requestState.status, + headers: JSON.stringify(Object.fromEntries(requestState.headers)), + reason: requestState.reason, + })); + + const locationHeader = requestState.headers.get(constants.Headers.Location); + if (locationHeader) { + return new Response(null, { status: 307, headers: requestState.headers }); + } else if (requestState.status === AuthStatus.Handshake) { + throw new Error('Clerk: handshake status without redirect'); + } + + const authObject = requestState.toAuth(); + logger.debug('auth', () => ({ auth: authObject, debug: authObject.debug() })); + + const redirectToSignIn = createMiddlewareRedirectToSignIn(clerkRequest); + const protect = createMiddlewareProtect(clerkRequest, authObject, redirectToSignIn); + const authObjWithMethods: ClerkMiddlewareAuthObject = Object.assign(authObject, { protect, redirectToSignIn }); + + let handlerResult: Response = NextResponse.next(); + try { + const userHandlerResult = await clerkMiddlewareRequestDataStore.run(options, async () => + handler?.(() => authObjWithMethods, request, event), + ); + handlerResult = userHandlerResult || handlerResult; + } catch (e: any) { + handlerResult = handleControlFlowErrors(e, clerkRequest, requestState); + } + + // TODO @nikos: we need to make this more generic + // and move the logic in clerk/backend + if (requestState.headers) { + requestState.headers.forEach((value, key) => { + handlerResult.headers.append(key, value); + }); + } + + if (isRedirect(handlerResult)) { + logger.debug('handlerResult is redirect'); + return serverRedirectWithAuth(clerkRequest, handlerResult, options); + } + + if (options.debug) { + setRequestHeadersOnNextResponse(handlerResult, clerkRequest, { [constants.Headers.EnableDebug]: 'true' }); + } + + decorateRequest(clerkRequest, handlerResult, requestState, params); + + return handlerResult; + }; - if (options.debug) { - setRequestHeadersOnNextResponse(handlerResult, clerkRequest, { [constants.Headers.EnableDebug]: 'true' }); + // If we have a request and event, we're being called as a middleware directly + // eg, export default clerkMiddleware; + if (request && event) { + return nextMiddleware(request, event); } - decorateRequest(clerkRequest, handlerResult, requestState, options.secretKey); - - return handlerResult; - }; - - // If we have a request and event, we're being called as a middleware directly - // eg, export default clerkMiddleware; - if (request && event) { - return nextMiddleware(request, event); - } - - // Otherwise, return a middleware that can be called with a request and event - // eg, export default clerkMiddleware(auth => { ... }); - return nextMiddleware; + // Otherwise, return a middleware that can be called with a request and event + // eg, export default clerkMiddleware(auth => { ... }); + return nextMiddleware; + }); }); const parseRequestAndEvent = (args: unknown[]) => { diff --git a/packages/nextjs/src/server/constants.ts b/packages/nextjs/src/server/constants.ts index 38749e2451..36ec73b95e 100644 --- a/packages/nextjs/src/server/constants.ts +++ b/packages/nextjs/src/server/constants.ts @@ -6,6 +6,7 @@ export const CLERK_JS_URL = process.env.NEXT_PUBLIC_CLERK_JS_URL || ''; export const API_VERSION = process.env.CLERK_API_VERSION || 'v1'; export const SECRET_KEY = process.env.CLERK_SECRET_KEY || ''; export const PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY || ''; +export const ENCRYPTION_KEY = process.env.CLERK_ENCRYPTION_KEY || ''; export const API_URL = process.env.CLERK_API_URL || apiUrlFromPublishableKey(PUBLISHABLE_KEY); export const DOMAIN = process.env.NEXT_PUBLIC_CLERK_DOMAIN || ''; export const PROXY_URL = process.env.NEXT_PUBLIC_CLERK_PROXY_URL || ''; diff --git a/packages/nextjs/src/server/createGetAuth.ts b/packages/nextjs/src/server/createGetAuth.ts index 207c2e9cb8..03fe44252f 100644 --- a/packages/nextjs/src/server/createGetAuth.ts +++ b/packages/nextjs/src/server/createGetAuth.ts @@ -6,7 +6,7 @@ import { withLogger } from '../utils/debugLogger'; import { API_URL, API_VERSION, SECRET_KEY } from './constants'; import { getAuthAuthHeaderMissing } from './errors'; import type { RequestLike } from './types'; -import { assertTokenSignature, getAuthKeyFromRequest, getCookie, getHeader } from './utils'; +import { assertTokenSignature, decryptClerkRequestData, getAuthKeyFromRequest, getCookie, getHeader } from './utils'; export const createGetAuth = ({ noAuthStatusMessage, @@ -35,12 +35,15 @@ export const createGetAuth = ({ throw new Error(noAuthStatusMessage); } + const encryptedRequestData = getHeader(req, constants.Headers.ClerkRequestData); + const decryptedRequestData = decryptClerkRequestData(encryptedRequestData); + const options = { authStatus, apiUrl: API_URL, apiVersion: API_VERSION, authMessage, - secretKey: opts?.secretKey || SECRET_KEY, + secretKey: opts?.secretKey || decryptedRequestData.secretKey || SECRET_KEY, authReason, }; diff --git a/packages/nextjs/src/server/errors.ts b/packages/nextjs/src/server/errors.ts index 8e23e9d5dd..eba3589e23 100644 --- a/packages/nextjs/src/server/errors.ts +++ b/packages/nextjs/src/server/errors.ts @@ -97,3 +97,5 @@ For additional information about middleware, please visit https://clerk.com/docs }; export const authSignatureInvalid = `Clerk: Unable to verify request, this usually means the Clerk middleware did not run. Ensure Clerk's middleware is properly integrated and matches the current route. For more information, see: https://clerk.com/docs/nextjs/middleware. (code=auth_signature_invalid)`; + +export const encryptionKeyInvalid = `Clerk: Unable to decrypt request data, this usually means the encryption key is invalid. Ensure the encryption key is properly set. For more information, see: https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys. (code=encryption_key_invalid)`; diff --git a/packages/nextjs/src/server/utils.ts b/packages/nextjs/src/server/utils.ts index 7d6eb6b779..8ec537538b 100644 --- a/packages/nextjs/src/server/utils.ts +++ b/packages/nextjs/src/server/utils.ts @@ -1,15 +1,19 @@ import type { AuthenticateRequestOptions, ClerkRequest, RequestState } from '@clerk/backend/internal'; import { constants } from '@clerk/backend/internal'; +import { logger } from '@clerk/shared'; import { handleValueOrFn } from '@clerk/shared/handleValueOrFn'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { isHttpOrHttps } from '@clerk/shared/proxy'; +import AES from 'crypto-js/aes'; +import encUtf8 from 'crypto-js/enc-utf8'; import hmacSHA1 from 'crypto-js/hmac-sha1'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { constants as nextConstants } from '../constants'; -import { DOMAIN, IS_SATELLITE, PROXY_URL, SECRET_KEY, SIGN_IN_URL } from './constants'; -import { authSignatureInvalid, missingDomainAndProxy, missingSignInUrlInDev } from './errors'; +import { DOMAIN, ENCRYPTION_KEY, IS_SATELLITE, PROXY_URL, SECRET_KEY, SIGN_IN_URL } from './constants'; +import { authSignatureInvalid, encryptionKeyInvalid, missingDomainAndProxy, missingSignInUrlInDev } from './errors'; +import { errorThrower } from './errorThrower'; import type { RequestLike } from './types'; export function setCustomAttributeOnRequest(req: RequestLike, key: string, value: string): void { @@ -113,7 +117,7 @@ export function decorateRequest( req: ClerkRequest, res: Response, requestState: RequestState, - secretKey: string, + requestData?: AuthenticateRequestOptions, ): Response { const { reason, message, status, token } = requestState; // pass-through case, convert to next() @@ -148,13 +152,16 @@ export function decorateRequest( } if (rewriteURL) { + const clerkRequestData = encryptClerkRequestData(requestData); + setRequestHeadersOnNextResponse(res, req, { [constants.Headers.AuthStatus]: status, [constants.Headers.AuthToken]: token || '', - [constants.Headers.AuthSignature]: token ? createTokenSignature(token, secretKey) : '', + [constants.Headers.AuthSignature]: token ? createTokenSignature(token, requestData?.secretKey ?? SECRET_KEY) : '', [constants.Headers.AuthMessage]: message || '', [constants.Headers.AuthReason]: reason || '', [constants.Headers.ClerkUrl]: req.clerkUrl.toString(), + ...(clerkRequestData ? { [constants.Headers.ClerkRequestData]: clerkRequestData } : {}), }); res.headers.set(nextConstants.Headers.NextRewrite, rewriteURL.href); } @@ -234,3 +241,47 @@ export function assertTokenSignature(token: string, key: string, signature?: str throw new Error(authSignatureInvalid); } } + +/** + * Encrypt request data propagated between server requests. + * @internal + **/ +export function encryptClerkRequestData(requestData?: Partial) { + if (!requestData || !Object.values(requestData).length) { + return; + } + + if (requestData.secretKey && !ENCRYPTION_KEY) { + // TODO SDK-1833: change this to an error in the next major version of `@clerk/nextjs` + logger.warnOnce( + 'Clerk: Missing `CLERK_ENCRYPTION_KEY`. Required for propagating `secretKey` middleware option. See docs: https://clerk.com/docs/references/nextjs/clerk-middleware#dynamic-keys', + ); + + return; + } + + return AES.encrypt( + JSON.stringify(requestData), + ENCRYPTION_KEY || assertKey(SECRET_KEY, () => errorThrower.throwMissingSecretKeyError()), + ).toString(); +} + +/** + * Decrypt request data propagated between server requests. + * @internal + */ +export function decryptClerkRequestData( + encryptedRequestData?: string | undefined | null, +): Partial { + if (!encryptedRequestData) { + return {}; + } + + try { + const decryptedBytes = AES.decrypt(encryptedRequestData, ENCRYPTION_KEY || SECRET_KEY); + const encoded = decryptedBytes.toString(encUtf8); + return JSON.parse(encoded); + } catch (err) { + throw new Error(encryptionKeyInvalid); + } +}