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 c33d5a6..717084e 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 = { @@ -31,6 +35,19 @@ 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('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' }); + }); + describe('with custom environment variables', () => { const originalEnv = process.env; diff --git a/src/workos.ts b/src/workos.ts index 5c745b9..887aea1 100644 --- a/src/workos.ts +++ b/src/workos.ts @@ -18,6 +18,21 @@ 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. + // 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: (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, }; /**