Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(clerk-js): Handle empty 204 responses from FAPI #5410

Merged
merged 10 commits into from
Mar 26, 2025
5 changes: 5 additions & 0 deletions .changeset/ninety-bobcats-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Handle the empty body on 204 responses from FAPI
17 changes: 17 additions & 0 deletions packages/clerk-js/src/core/__tests__/fapiClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,23 @@ describe('request', () => {
expect(Array.isArray(resp.payload)).toEqual(true);
});

it('handles the empty body on 204 response, returning null', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce(
Promise.resolve<RecursivePartial<Response>>({
status: 204,
json: () => {
throw new Error('json should not be called on 204 response');
},
}),
);

const resp = await fapiClient.request({
path: '/foo',
});

expect(resp.payload).toEqual(null);
});

describe('for production instances', () => {
it.todo('does not append the __clerk_db_jwt cookie value to the query string');
it.todo('does not set the __clerk_db_jwt cookie from the response Clerk-Cookie header');
Expand Down
3 changes: 2 additions & 1 deletion packages/clerk-js/src/core/fapiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,8 @@ export function createFapiClient(options: FapiClientOptions): FapiClient {
clerkNetworkError(urlStr, e);
}

const json: FapiResponseJSON<T> = await response.json();
// 204 No Content responses do not have a body so we should not try to parse it
const json: FapiResponseJSON<T> | null = response.status !== 204 ? await response.json() : null;
const fapiResponse: FapiResponse<T> = Object.assign(response, { payload: json });
await runAfterResponseCallbacks(requestInit, fapiResponse);
return fapiResponse;
Expand Down
83 changes: 58 additions & 25 deletions packages/clerk-js/src/core/resources/__tests__/Client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,66 @@ import { createSession, createSignIn, createSignUp, createUser } from '../../tes
import { BaseResource, Client } from '../internal';

describe('Client Singleton', () => {
it('sends captcha token', async () => {
const user = createUser({ first_name: 'John', last_name: 'Doe', id: 'user_1' });
const session = createSession({ id: 'session_1' }, user);
const clientObjectJSON: ClientJSON = {
object: 'client',
id: 'test_id',
status: 'active',
last_active_session_id: 'test_session_id',
sign_in: createSignIn({ id: 'test_sign_in_id' }, user),
sign_up: createSignUp({ id: 'test_sign_up_id' }), // This is only for testing purposes, this will never happen
sessions: [session],
created_at: jest.now() - 1000,
updated_at: jest.now(),
} as any;
describe('sendCaptchaToken', () => {
it('sends captcha token', async () => {
const user = createUser({ first_name: 'John', last_name: 'Doe', id: 'user_1' });
const session = createSession({ id: 'session_1' }, user);
const clientObjectJSON: ClientJSON = {
object: 'client',
id: 'test_id',
status: 'active',
last_active_session_id: 'test_session_id',
sign_in: createSignIn({ id: 'test_sign_in_id' }, user),
sign_up: createSignUp({ id: 'test_sign_up_id' }), // This is only for testing purposes, this will never happen
sessions: [session],
created_at: jest.now() - 1000,
updated_at: jest.now(),
} as any;

// @ts-expect-error This is a private method that we are mocking
BaseResource._baseFetch = jest.fn();
// @ts-expect-error This is a private method that we are mocking
BaseResource._baseFetch = jest.fn();

const client = Client.getOrCreateInstance().fromJSON(clientObjectJSON);
await client.sendCaptchaToken({ captcha_token: 'test_captcha_token' });
// @ts-expect-error This is a private method that we are mocking
expect(BaseResource._baseFetch).toHaveBeenCalledWith({
method: 'POST',
path: `/client/verify`,
body: {
captcha_token: 'test_captcha_token',
},
const client = Client.getOrCreateInstance().fromJSON(clientObjectJSON);
await client.sendCaptchaToken({ captcha_token: 'test_captcha_token' });
// @ts-expect-error This is a private method that we are mocking
expect(BaseResource._baseFetch).toHaveBeenCalledWith({
method: 'POST',
path: `/client/verify`,
body: {
captcha_token: 'test_captcha_token',
},
});
});

it('ignores null response payload', async () => {
const user = createUser({ first_name: 'John', last_name: 'Doe', id: 'user_1' });
const session = createSession({ id: 'session_1' }, user);
const clientObjectJSON: ClientJSON = {
object: 'client',
id: 'test_id',
status: 'active',
last_active_session_id: 'test_session_id',
sign_in: createSignIn({ id: 'test_sign_in_id' }, user),
sign_up: createSignUp({ id: 'test_sign_up_id' }), // This is only for testing purposes, this will never happen
sessions: [session],
created_at: jest.now() - 1000,
updated_at: jest.now(),
} as any;

// @ts-expect-error This is a private method that we are mocking
BaseResource._baseFetch = jest.fn().mockResolvedValueOnce(Promise.resolve(null));

const client = Client.getOrCreateInstance().fromJSON(clientObjectJSON);
await client.sendCaptchaToken({ captcha_token: 'test_captcha_token' });
// @ts-expect-error This is a private method that we are mocking
expect(BaseResource._baseFetch).toHaveBeenCalledWith({
method: 'POST',
path: `/client/verify`,
body: {
captcha_token: 'test_captcha_token',
},
});
expect(client.id).toBe('test_id');
});
});

Expand Down