From b21dca3e2d8c777a34c48b84be698894dc5dd0e9 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 29 May 2026 21:15:08 -0500 Subject: [PATCH] fix(backend): stop authenticateRequest from consuming the request body --- .changeset/clerkrequest-omit-body.md | 5 ++ .../src/tokens/__tests__/clerkRequest.test.ts | 46 +++++++++++++++++-- packages/backend/src/tokens/clerkRequest.ts | 16 ++++--- 3 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 .changeset/clerkrequest-omit-body.md diff --git a/.changeset/clerkrequest-omit-body.md b/.changeset/clerkrequest-omit-body.md new file mode 100644 index 00000000000..9f8876f9396 --- /dev/null +++ b/.changeset/clerkrequest-omit-body.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': patch +--- + +Stop `authenticateRequest` from consuming the incoming request body, which previously left downstream handlers unable to read it (for example a Hono POST route calling `c.req.json()`). diff --git a/packages/backend/src/tokens/__tests__/clerkRequest.test.ts b/packages/backend/src/tokens/__tests__/clerkRequest.test.ts index 35a5625afe0..1b75b88cd44 100644 --- a/packages/backend/src/tokens/__tests__/clerkRequest.test.ts +++ b/packages/backend/src/tokens/__tests__/clerkRequest.test.ts @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest'; import { createClerkRequest } from '../clerkRequest'; +// Some test runtimes (e.g. Cloudflare/miniflare) gate `new ReadableStream()` +// behind a feature flag and throw when it is constructed directly. +const supportsStreamConstruction = (() => { + try { + new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + return true; + } catch { + return false; + } +})(); + describe('createClerkRequest', () => { describe('instantiating a request', () => { it('retains the headers', () => { @@ -17,11 +32,34 @@ describe('createClerkRequest', () => { expect(req.method).toBe(oldReq.method); }); - it('retains the body', async () => { - const data = { a: '1' }; - const oldReq = new Request('http://localhost:3000', { method: 'POST', body: JSON.stringify(data) }); + // The hazard only exists on undici-style runtimes (Node, edge) where the + // request body is a single-use stream. Cloudflare/miniflare buffers bodies + // (so the body survives anyway) and cannot construct a streaming body, so + // this regression is skipped there. + it.skipIf(!supportsStreamConstruction)('does not consume the original request body (issue #8305)', async () => { + // Clerk only needs the method, headers, cookies, and URL. Forwarding the + // body made the clone share the original's single-use stream, so reading + // either side left the other "unusable" for downstream handlers (e.g. a + // Hono POST route calling `c.req.json()`). + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(JSON.stringify({ a: '1' }))); + controller.close(); + }, + }); + const oldReq = new Request('http://localhost:3000', { + method: 'POST', + body: stream, + // `duplex` is required when streaming a body; not yet in all lib typings. + duplex: 'half', + } as RequestInit); + const req = createClerkRequest(oldReq); - expect((await req.json())['a']).toBe(data.a); + + // The clone carries no body, so it can never lock the original's stream... + expect(req.body).toBeNull(); + // ...and the original stream stays readable for downstream consumers. + expect(((await oldReq.json()) as { a: string }).a).toBe('1'); }); it('retains the url', () => { diff --git a/packages/backend/src/tokens/clerkRequest.ts b/packages/backend/src/tokens/clerkRequest.ts index 7dc0380bb51..8b02266c643 100644 --- a/packages/backend/src/tokens/clerkRequest.ts +++ b/packages/backend/src/tokens/clerkRequest.ts @@ -26,18 +26,22 @@ class ClerkRequest extends Request { // https://github.com/nodejs/undici/issues/2155 // https://github.com/nodejs/undici/blob/7153a1c78d51840bbe16576ce353e481c3934701/lib/fetch/request.js#L854 const url = typeof input !== 'string' && 'url' in input ? input.url : String(input); - // When cloning a Request by passing it as init, hide its `signal`. Undici's - // Request constructor in Node 24 performs a strict instanceof check on the - // signal and rejects ones from a different realm (e.g. NextRequest). Using a - // Proxy keeps property access lazy so environments that don't implement - // optional getters (e.g. Cloudflare Workers' Request lacks `cache`) still work. + // When cloning a Request by passing it as init, hide its `signal` and `body`. + // Undici's Request constructor in Node 24 performs a strict instanceof check on + // the signal and rejects ones from a different realm (e.g. NextRequest). The + // `body` is hidden because forwarding it makes the clone share the original's + // single-use ReadableStream; once either side is read the other throws + // "Body is unusable" downstream (issue #8305). Auth only reads the method, + // headers, cookies, and URL, so the clone never needs a body. Using a Proxy + // keeps property access lazy so environments that don't implement optional + // getters (e.g. Cloudflare Workers' Request lacks `cache`) still work. let cloneInit: RequestInit | undefined; if (init) { cloneInit = init; } else if (typeof input !== 'string') { cloneInit = new Proxy(input as Request, { get(target, prop) { - if (prop === 'signal') { + if (prop === 'signal' || prop === 'body') { return undefined; } return Reflect.get(target, prop, target);