From ab476cccc6820dc36239fa6381a7ef45a271caf4 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 14 Apr 2025 08:37:07 -0700 Subject: [PATCH 1/6] chore(nextjs): Add support for pages router when verifying webhooks --- packages/nextjs/src/server/types.ts | 2 +- packages/nextjs/src/webhooks.ts | 77 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/server/types.ts b/packages/nextjs/src/server/types.ts index b1f15bc79fd..dc901538da4 100644 --- a/packages/nextjs/src/server/types.ts +++ b/packages/nextjs/src/server/types.ts @@ -4,7 +4,7 @@ import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; import type { NextMiddleware, NextRequest } from 'next/server'; // Request contained in GetServerSidePropsContext, has cookies but not query -type GsspRequest = IncomingMessage & { cookies: NextApiRequestCookies }; +export type GsspRequest = IncomingMessage & { cookies: NextApiRequestCookies }; export type RequestLike = NextRequest | NextApiRequest | GsspRequest; diff --git a/packages/nextjs/src/webhooks.ts b/packages/nextjs/src/webhooks.ts index 2a5a0c06740..3a68bbfb9b7 100644 --- a/packages/nextjs/src/webhooks.ts +++ b/packages/nextjs/src/webhooks.ts @@ -1 +1,78 @@ +/* eslint-disable import/export */ +import type { VerifyWebhookOptions } from '@clerk/backend/webhooks'; +import { verifyWebhook as verifyWebhookBase } from '@clerk/backend/webhooks'; +import type { NextApiRequest } from 'next'; + +import { isNextRequest, isRequestWebAPI } from './server/headers-utils'; +import type { GsspRequest, RequestLike } from './server/types'; +// Ordering of exports matter here since +// we're overriding the base verifyWebhook export * from '@clerk/backend/webhooks'; + +const SVIX_ID_HEADER = 'svix-id'; +const SVIX_TIMESTAMP_HEADER = 'svix-timestamp'; +const SVIX_SIGNATURE_HEADER = 'svix-signature'; + +/** + * Verifies the authenticity of a webhook request using Svix. + * + * @param request - The incoming webhook request object + * @param options - Optional configuration object + * @param options.signingSecret - Custom signing secret. If not provided, falls back to CLERK_WEBHOOK_SIGNING_SECRET env variable + * @throws Will throw an error if the webhook signature verification fails + * @returns A promise that resolves to the verified webhook event data + * + * @example + * ```typescript + * import { verifyWebhook } from '@clerk/nextjs/webhooks'; + * + * export async function POST(req: Request) { + * try { + * const evt = await verifyWebhook(req); + * + * // Access the event data + * const { id } = evt.data; + * const eventType = evt.type; + * + * // Handle specific event types + * if (evt.type === 'user.created') { + * console.log('New user created:', evt.data.id); + * // Handle user creation + * } + * + * return new Response('Success', { status: 200 }); + * } catch (err) { + * console.error('Webhook verification failed:', err); + * return new Response('Webhook verification failed', { status: 400 }); + * } + * } + * ``` + */ +export async function verifyWebhook(request: RequestLike, options?: VerifyWebhookOptions) { + if (isNextRequest(request) || isRequestWebAPI(request)) { + return verifyWebhookBase(request, options); + } + + const webRequest = nextApiRequestToWebRequest(request); + return verifyWebhookBase(webRequest, options); +} + +function nextApiRequestToWebRequest(req: NextApiRequest | GsspRequest): Request { + const headers = new Headers(); + headers.set(SVIX_ID_HEADER, req.headers[SVIX_ID_HEADER] as string); + headers.set(SVIX_TIMESTAMP_HEADER, req.headers[SVIX_TIMESTAMP_HEADER] as string); + headers.set(SVIX_SIGNATURE_HEADER, req.headers[SVIX_SIGNATURE_HEADER] as string); + + // Create a dummy URL since NextApiRequest doesn't have a full URL + const protocol = req.headers['x-forwarded-proto'] || 'http'; + const host = req.headers.host || 'clerk-dummy'; + const dummyOriginReqUrl = new URL(req.url || '', `${protocol}://${host}`); + + const body = 'body' in req && req.body ? JSON.stringify(req.body) : undefined; + + return new Request(dummyOriginReqUrl, { + method: req.method || 'GET', + headers, + body, + }); +} From f560f2a2467f484a078521fd9e655cdd211cbe1b Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 14 Apr 2025 09:31:14 -0700 Subject: [PATCH 2/6] chore(nextjs): Add custom verify webhook unit test --- .../nextjs/src/__tests__/webhooks.test.ts | 94 +++++++++++++++++++ packages/nextjs/src/webhooks.ts | 12 ++- 2 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 packages/nextjs/src/__tests__/webhooks.test.ts diff --git a/packages/nextjs/src/__tests__/webhooks.test.ts b/packages/nextjs/src/__tests__/webhooks.test.ts new file mode 100644 index 00000000000..4026f3d74d1 --- /dev/null +++ b/packages/nextjs/src/__tests__/webhooks.test.ts @@ -0,0 +1,94 @@ +import type { NextApiRequest } from 'next'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { RequestLike } from '../server/types'; +import { verifyWebhook } from '../webhooks'; + +const mockSuccessResponse = { + type: 'user.created', + data: { id: 'user_123' }, + object: 'event', +} as any; + +const mockError = new Error('Missing required Svix headers: svix-id, svix-timestamp, svix-signature'); + +vi.mock('@clerk/backend/webhooks', () => ({ + verifyWebhook: vi.fn().mockImplementation((request: Request) => { + const svixId = request.headers.get('svix-id'); + const svixTimestamp = request.headers.get('svix-timestamp'); + const svixSignature = request.headers.get('svix-signature'); + + if (!svixId || !svixTimestamp || !svixSignature) { + throw mockError; + } + + return mockSuccessResponse; + }), +})); + +describe('verifyWebhook', () => { + const mockSecret = 'test_signing_secret'; + const mockBody = JSON.stringify(mockSuccessResponse); + const mockHeaders = { + 'svix-id': 'msg_123', + 'svix-timestamp': '1614265330', + 'svix-signature': 'v1,test_signature', + }; + + beforeEach(() => { + process.env.CLERK_WEBHOOK_SIGNING_SECRET = mockSecret; + }); + + describe('with standard Request', () => { + it('verifies webhook with standard Request object', async () => { + const mockRequest = new Request('https://clerk.com/webhooks', { + method: 'POST', + body: mockBody, + headers: new Headers(mockHeaders), + }) as unknown as RequestLike; + + const result = await verifyWebhook(mockRequest); + expect(result).toEqual(mockSuccessResponse); + }); + }); + + describe('with NextApiRequest', () => { + it('verifies webhook with NextApiRequest object', async () => { + const mockNextApiRequest = { + method: 'POST', + url: '/webhooks', + headers: { + ...mockHeaders, + host: 'clerk.com', + 'x-forwarded-proto': 'https', + }, + body: JSON.parse(mockBody), + query: {}, + cookies: {}, + env: {}, + aborted: false, + } as unknown as NextApiRequest; + + const result = await verifyWebhook(mockNextApiRequest); + expect(result).toEqual(mockSuccessResponse); + }); + + it('throws error when headers are missing', async () => { + const mockNextApiRequest = { + method: 'POST', + url: '/webhooks', + headers: { + host: 'clerk.com', + 'x-forwarded-proto': 'https', + }, + body: JSON.parse(mockBody), + query: {}, + cookies: {}, + env: {}, + aborted: false, + } as unknown as NextApiRequest; + + await expect(verifyWebhook(mockNextApiRequest)).rejects.toThrow(mockError); + }); + }); +}); diff --git a/packages/nextjs/src/webhooks.ts b/packages/nextjs/src/webhooks.ts index 3a68bbfb9b7..1b13a8eba25 100644 --- a/packages/nextjs/src/webhooks.ts +++ b/packages/nextjs/src/webhooks.ts @@ -3,7 +3,7 @@ import type { VerifyWebhookOptions } from '@clerk/backend/webhooks'; import { verifyWebhook as verifyWebhookBase } from '@clerk/backend/webhooks'; import type { NextApiRequest } from 'next'; -import { isNextRequest, isRequestWebAPI } from './server/headers-utils'; +import { getHeader, isNextRequest, isRequestWebAPI } from './server/headers-utils'; import type { GsspRequest, RequestLike } from './server/types'; // Ordering of exports matter here since // we're overriding the base verifyWebhook @@ -59,9 +59,13 @@ export async function verifyWebhook(request: RequestLike, options?: VerifyWebhoo function nextApiRequestToWebRequest(req: NextApiRequest | GsspRequest): Request { const headers = new Headers(); - headers.set(SVIX_ID_HEADER, req.headers[SVIX_ID_HEADER] as string); - headers.set(SVIX_TIMESTAMP_HEADER, req.headers[SVIX_TIMESTAMP_HEADER] as string); - headers.set(SVIX_SIGNATURE_HEADER, req.headers[SVIX_SIGNATURE_HEADER] as string); + const svixId = getHeader(req, SVIX_ID_HEADER) || ''; + const svixTimestamp = getHeader(req, SVIX_TIMESTAMP_HEADER) || ''; + const svixSignature = getHeader(req, SVIX_SIGNATURE_HEADER) || ''; + + headers.set(SVIX_ID_HEADER, svixId); + headers.set(SVIX_TIMESTAMP_HEADER, svixTimestamp); + headers.set(SVIX_SIGNATURE_HEADER, svixSignature); // Create a dummy URL since NextApiRequest doesn't have a full URL const protocol = req.headers['x-forwarded-proto'] || 'http'; From 30c637b9c93934fae0bd0e1a8db44f70c24b1753 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 14 Apr 2025 09:33:17 -0700 Subject: [PATCH 3/6] chore: clean up --- packages/nextjs/src/webhooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nextjs/src/webhooks.ts b/packages/nextjs/src/webhooks.ts index 1b13a8eba25..7ca1dc4af5f 100644 --- a/packages/nextjs/src/webhooks.ts +++ b/packages/nextjs/src/webhooks.ts @@ -67,7 +67,7 @@ function nextApiRequestToWebRequest(req: NextApiRequest | GsspRequest): Request headers.set(SVIX_TIMESTAMP_HEADER, svixTimestamp); headers.set(SVIX_SIGNATURE_HEADER, svixSignature); - // Create a dummy URL since NextApiRequest doesn't have a full URL + // Create a dummy URL to make a Request object const protocol = req.headers['x-forwarded-proto'] || 'http'; const host = req.headers.host || 'clerk-dummy'; const dummyOriginReqUrl = new URL(req.url || '', `${protocol}://${host}`); From 742b87f032a29bc3d2d2ac78726c5b214ad86609 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 14 Apr 2025 09:35:15 -0700 Subject: [PATCH 4/6] chore: revert exported type --- packages/nextjs/src/server/types.ts | 2 +- packages/nextjs/src/webhooks.ts | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/nextjs/src/server/types.ts b/packages/nextjs/src/server/types.ts index dc901538da4..b1f15bc79fd 100644 --- a/packages/nextjs/src/server/types.ts +++ b/packages/nextjs/src/server/types.ts @@ -4,7 +4,7 @@ import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; import type { NextMiddleware, NextRequest } from 'next/server'; // Request contained in GetServerSidePropsContext, has cookies but not query -export type GsspRequest = IncomingMessage & { cookies: NextApiRequestCookies }; +type GsspRequest = IncomingMessage & { cookies: NextApiRequestCookies }; export type RequestLike = NextRequest | NextApiRequest | GsspRequest; diff --git a/packages/nextjs/src/webhooks.ts b/packages/nextjs/src/webhooks.ts index 7ca1dc4af5f..1e0db5ce542 100644 --- a/packages/nextjs/src/webhooks.ts +++ b/packages/nextjs/src/webhooks.ts @@ -1,10 +1,9 @@ /* eslint-disable import/export */ import type { VerifyWebhookOptions } from '@clerk/backend/webhooks'; import { verifyWebhook as verifyWebhookBase } from '@clerk/backend/webhooks'; -import type { NextApiRequest } from 'next'; import { getHeader, isNextRequest, isRequestWebAPI } from './server/headers-utils'; -import type { GsspRequest, RequestLike } from './server/types'; +import type { RequestLike } from './server/types'; // Ordering of exports matter here since // we're overriding the base verifyWebhook export * from '@clerk/backend/webhooks'; @@ -57,7 +56,7 @@ export async function verifyWebhook(request: RequestLike, options?: VerifyWebhoo return verifyWebhookBase(webRequest, options); } -function nextApiRequestToWebRequest(req: NextApiRequest | GsspRequest): Request { +function nextApiRequestToWebRequest(req: RequestLike): Request { const headers = new Headers(); const svixId = getHeader(req, SVIX_ID_HEADER) || ''; const svixTimestamp = getHeader(req, SVIX_TIMESTAMP_HEADER) || ''; @@ -68,8 +67,8 @@ function nextApiRequestToWebRequest(req: NextApiRequest | GsspRequest): Request headers.set(SVIX_SIGNATURE_HEADER, svixSignature); // Create a dummy URL to make a Request object - const protocol = req.headers['x-forwarded-proto'] || 'http'; - const host = req.headers.host || 'clerk-dummy'; + const protocol = getHeader(req, 'x-forwarded-proto') || 'http'; + const host = getHeader(req, 'x-forwarded-host') || 'clerk-dummy'; const dummyOriginReqUrl = new URL(req.url || '', `${protocol}://${host}`); const body = 'body' in req && req.body ? JSON.stringify(req.body) : undefined; From c1d3b46b0f1e2cbb801581a521ead514e70896b4 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 14 Apr 2025 12:21:08 -0700 Subject: [PATCH 5/6] chore: add changeset --- .changeset/free-actors-prove.md | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .changeset/free-actors-prove.md diff --git a/.changeset/free-actors-prove.md b/.changeset/free-actors-prove.md new file mode 100644 index 00000000000..5769e07d582 --- /dev/null +++ b/.changeset/free-actors-prove.md @@ -0,0 +1,37 @@ +--- +'@clerk/nextjs': patch +--- + +Add support for webhook verification with Next.js Pages Router. + +```ts +// Next.js Pages Router +import type { NextApiRequest, NextApiResponse } from 'next' +import { verifyWebhook } from '@clerk/nextjs/webhooks'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + const evt = await verifyWebhook(req); + // Handle webhook event + res.status(200).json({ received: true }); + } catch (err) { + res.status(400).json({ error: 'Webhook verification failed' }); + } +} + +// tRPC +import { verifyWebhook } from '@clerk/nextjs/webhooks'; + +const webhookRouter = router({ + webhook: publicProcedure + .input(/** schema */) + .mutation(async ({ ctx }) => { + const evt = await verifyWebhook(ctx.req); + // Handle webhook event + return { received: true }; + }), +}); +``` From e6a05383ed727fb0c98e23d3dcd555c9772b1909 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 14 Apr 2025 12:39:11 -0700 Subject: [PATCH 6/6] chore: update changeset --- .changeset/free-actors-prove.md | 6 ++++++ packages/nextjs/src/webhooks.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.changeset/free-actors-prove.md b/.changeset/free-actors-prove.md index 5769e07d582..c664845505d 100644 --- a/.changeset/free-actors-prove.md +++ b/.changeset/free-actors-prove.md @@ -9,6 +9,12 @@ Add support for webhook verification with Next.js Pages Router. import type { NextApiRequest, NextApiResponse } from 'next' import { verifyWebhook } from '@clerk/nextjs/webhooks'; +export const config = { + api: { + bodyParser: false, + }, +} + export default async function handler( req: NextApiRequest, res: NextApiResponse diff --git a/packages/nextjs/src/webhooks.ts b/packages/nextjs/src/webhooks.ts index 1e0db5ce542..406d2fe8013 100644 --- a/packages/nextjs/src/webhooks.ts +++ b/packages/nextjs/src/webhooks.ts @@ -74,7 +74,7 @@ function nextApiRequestToWebRequest(req: RequestLike): Request { const body = 'body' in req && req.body ? JSON.stringify(req.body) : undefined; return new Request(dummyOriginReqUrl, { - method: req.method || 'GET', + method: req.method, headers, body, });