Skip to content

Commit

Permalink
feat(nextjs,backend,integration): Introduce dynamic keys from `clerkM…
Browse files Browse the repository at this point in the history
…iddleware` (#3525)
  • Loading branch information
LauraBeatris authored Jul 1, 2024
1 parent 168607a commit f1847b7
Show file tree
Hide file tree
Showing 21 changed files with 385 additions and 110 deletions.
11 changes: 11 additions & 0 deletions .changeset/young-pigs-live.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
5 changes: 5 additions & 0 deletions integration/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
10 changes: 9 additions & 1 deletion integration/presets/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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,
Expand All @@ -90,4 +97,5 @@ export const envs = {
withAPCore1ClerkV4,
withAPCore2ClerkLatest,
withAPCore2ClerkV4,
withDynamicKeys,
} as const;
101 changes: 101 additions & 0 deletions integration/tests/dynamic-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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 <p>Users count: {count}</p>
}
`,
)
.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();
});
});
2 changes: 1 addition & 1 deletion integration/tests/handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 7 additions & 6 deletions packages/nextjs/src/app-router/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof redirect>> };
Expand All @@ -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(),
});
Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/src/app-router/server/currentUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ export async function currentUser(): Promise<User | null> {
return null;
}

return clerkClient.users.getUser(userId);
return clerkClient().users.getUser(userId);
}
2 changes: 1 addition & 1 deletion packages/nextjs/src/app-router/server/utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/src/server/__tests__/clerkClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading

0 comments on commit f1847b7

Please sign in to comment.