From 710d3d62de3892ef02ba5dfa1b2f7858ed581b67 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 3 Nov 2026 17:25:57 -0600 Subject: [PATCH 1/3] fix: don't follow redirects on WorkOS API requests authenticateWithCode and authenticateWithRefreshToken POST a body containing client_secret. With fetch's default redirect: 'follow', a 307/308 from the API origin re-posts that body to the redirect target (the Authorization header is dropped cross-origin, the body is not). Pass a fetchFn that sets redirect: 'manual' so a 3xx surfaces as an SDK error instead. Closes #510 --- src/workos.spec.ts | 14 ++++++++++++++ src/workos.ts | 4 ++++ 2 files changed, 18 insertions(+) diff --git a/src/workos.spec.ts b/src/workos.spec.ts index c33d5a6..f89bf9a 100644 --- a/src/workos.spec.ts +++ b/src/workos.spec.ts @@ -31,6 +31,20 @@ describe('workos', () => { expect(workos).toBeInstanceOf(WorkOS); }); + it('does not follow redirects, so client_secret is never re-posted to a redirect target', async () => { + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(null, { status: 307, headers: { Location: 'https://evil.example/' } })); + + await expect( + workos.userManagement.authenticateWithRefreshToken({ clientId: 'client_123', refreshToken: 'rt_123' }), + ).rejects.toThrow(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][1]).toMatchObject({ method: 'POST', redirect: 'manual' }); + fetchSpy.mockRestore(); + }); + describe('with custom environment variables', () => { const originalEnv = process.env; diff --git a/src/workos.ts b/src/workos.ts index 5c745b9..fdf3672 100644 --- a/src/workos.ts +++ b/src/workos.ts @@ -18,6 +18,10 @@ const options = { name: 'authkit/nextjs', version: VERSION, }, + // The WorkOS API never redirects; following one would re-POST client_secret to the redirect target. + // 'manual' rather than 'error': Cloudflare workerd rejects 'error', and the SDK retries the TypeError it throws. + // The SDK's worker build (Edge, workerd) ignores fetchFn, so those runtimes need the fix in @workos-inc/node. + fetchFn: ((input, init) => globalThis.fetch(input, { ...init, redirect: 'manual' })) satisfies typeof fetch, }; /** From fd4dff14d3c7e3f51f9022f7fbc1548c65409b3d Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 3 Nov 2026 17:58:47 -0600 Subject: [PATCH 2/3] test: restore the fetch spy even when the redirect test fails --- src/workos.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/workos.spec.ts b/src/workos.spec.ts index f89bf9a..a1e5bef 100644 --- a/src/workos.spec.ts +++ b/src/workos.spec.ts @@ -7,6 +7,10 @@ describe('workos', () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('initializes WorkOS with the correct configuration', () => { // Extracting the config to avoid a circular dependency error const workosConfig = { @@ -42,7 +46,6 @@ describe('workos', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy.mock.calls[0][1]).toMatchObject({ method: 'POST', redirect: 'manual' }); - fetchSpy.mockRestore(); }); describe('with custom environment variables', () => { From 516871c014c6b673cec77d9b8a0ee7e8585ff3a6 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 4 Nov 2026 13:33:26 -0600 Subject: [PATCH 3/3] fix: name redirects in the error and stop claim-token redirects The fetchFn wrapper now throws a plain Error naming the status and Location on a 3xx, so a misconfigured host doesn't surface as a bare ParseError on SDK 9 to 12. A plain Error isn't retried by the SDK. fetchClaimNonce uses the global fetch rather than the SDK client, so it still followed redirects and could re-post WORKOS_CLAIM_TOKEN. It now sets redirect: 'manual'; a 3xx falls into the existing !response.ok branch. --- src/get-authorization-url.spec.ts | 1 + src/get-authorization-url.ts | 2 ++ src/workos.spec.ts | 2 +- src/workos.ts | 13 ++++++++++++- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/get-authorization-url.spec.ts b/src/get-authorization-url.spec.ts index eb0697c..e47eef9 100644 --- a/src/get-authorization-url.spec.ts +++ b/src/get-authorization-url.spec.ts @@ -138,6 +138,7 @@ describe('getAuthorizationUrl', () => { 'https://api.workos.com/x/one-shot-environments/claim-nonces', expect.objectContaining({ method: 'POST', + redirect: 'manual', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: 'client_1234567890', claim_token: 'test-claim-token' }), }), diff --git a/src/get-authorization-url.ts b/src/get-authorization-url.ts index ebbc6c5..e41ae6e 100644 --- a/src/get-authorization-url.ts +++ b/src/get-authorization-url.ts @@ -8,6 +8,8 @@ async function fetchClaimNonce(baseURL: string): Promise { try { const response = await fetch(`${baseURL}/x/one-shot-environments/claim-nonces`, { method: 'POST', + // Don't re-post claim_token to a redirect target; a 3xx falls into the !ok branch. + redirect: 'manual', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: WORKOS_CLIENT_ID, diff --git a/src/workos.spec.ts b/src/workos.spec.ts index a1e5bef..717084e 100644 --- a/src/workos.spec.ts +++ b/src/workos.spec.ts @@ -42,7 +42,7 @@ describe('workos', () => { await expect( workos.userManagement.authenticateWithRefreshToken({ clientId: 'client_123', refreshToken: 'rt_123' }), - ).rejects.toThrow(); + ).rejects.toThrow('WorkOS API responded with a redirect (307 to https://evil.example/)'); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy.mock.calls[0][1]).toMatchObject({ method: 'POST', redirect: 'manual' }); diff --git a/src/workos.ts b/src/workos.ts index fdf3672..887aea1 100644 --- a/src/workos.ts +++ b/src/workos.ts @@ -20,8 +20,19 @@ const options = { }, // The WorkOS API never redirects; following one would re-POST client_secret to the redirect target. // 'manual' rather than 'error': Cloudflare workerd rejects 'error', and the SDK retries the TypeError it throws. + // A 3xx is thrown as a plain Error (not a TypeError) so the SDK surfaces it without retrying. // The SDK's worker build (Edge, workerd) ignores fetchFn, so those runtimes need the fix in @workos-inc/node. - fetchFn: ((input, init) => globalThis.fetch(input, { ...init, redirect: 'manual' })) satisfies typeof fetch, + fetchFn: (async (input, init) => { + const res = await globalThis.fetch(input, { ...init, redirect: 'manual' }); + if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) { + const location = res.headers.get('location'); + throw new Error( + `WorkOS API responded with a redirect (${res.status}${location ? ` to ${location}` : ''}). ` + + 'Redirects are not followed; check WORKOS_API_HOSTNAME, WORKOS_API_PORT and WORKOS_API_HTTPS.', + ); + } + return res; + }) satisfies typeof fetch, }; /**