diff --git a/.changeset/free-actors-prove.md b/.changeset/free-actors-prove.md new file mode 100644 index 00000000000..c664845505d --- /dev/null +++ b/.changeset/free-actors-prove.md @@ -0,0 +1,43 @@ +--- +'@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 const config = { + api: { + bodyParser: false, + }, +} + +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 }; + }), +}); +``` 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 2a5a0c06740..406d2fe8013 100644 --- a/packages/nextjs/src/webhooks.ts +++ b/packages/nextjs/src/webhooks.ts @@ -1 +1,81 @@ +/* eslint-disable import/export */ +import type { VerifyWebhookOptions } from '@clerk/backend/webhooks'; +import { verifyWebhook as verifyWebhookBase } from '@clerk/backend/webhooks'; + +import { getHeader, isNextRequest, isRequestWebAPI } from './server/headers-utils'; +import type { 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: RequestLike): Request { + const headers = new Headers(); + 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 to make a Request object + 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; + + return new Request(dummyOriginReqUrl, { + method: req.method, + headers, + body, + }); +}