Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/get-authorization-url.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
}),
Expand Down
2 changes: 2 additions & 0 deletions src/get-authorization-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ async function fetchClaimNonce(baseURL: string): Promise<string | null> {
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,
Expand Down
17 changes: 17 additions & 0 deletions src/workos.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;

Expand Down
15 changes: 15 additions & 0 deletions src/workos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

/**
Expand Down
Loading