Skip to content

Commit 44edcc9

Browse files
feat(js): attach an optional server-configured session token to sign-in (#9299)
1 parent fe8a439 commit 44edcc9

12 files changed

Lines changed: 2225 additions & 96 deletions

File tree

.changeset/lucky-pandas-observe.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/shared': minor
4+
---
5+
6+
Internal improvements to Clerk Protect. No action is required, and instances that do not use Protect are unaffected.

packages/clerk-js/bundlewatch.config.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.js", "maxSize": "549KB" },
4-
{ "path": "./dist/clerk.browser.js", "maxSize": "77KB" },
5-
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "119KB" },
6-
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
7-
{ "path": "./dist/clerk.native.js", "maxSize": "77KB" },
3+
{ "path": "./dist/clerk.js", "maxSize": "552KB" },
4+
{ "path": "./dist/clerk.browser.js", "maxSize": "79KB" },
5+
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "122KB" },
6+
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "320KB" },
7+
{ "path": "./dist/clerk.native.js", "maxSize": "79KB" },
88
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
99
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
1010
{ "path": "./dist/base-account-sdk*.js", "maxSize": "207KB" },
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { ProtectAssertion } from '@clerk/shared/types';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { Clerk } from '../clerk';
5+
6+
/**
7+
* Two independent Protect features feed the single `getProtectParams` hook the FAPI client calls:
8+
* the application-supplied assertion, and the server-configured session token. They are wired in
9+
* the same expression, so collapsing it to either one alone still compiles, still type-checks, and
10+
* silently stops sending the other's params — a degradation with nothing to see. These tests pin
11+
* the hook to the union.
12+
*/
13+
14+
const getRequestParams = vi.fn();
15+
16+
vi.mock('../protect', () => ({
17+
Protect: class {
18+
load = vi.fn();
19+
getRequestParams = getRequestParams;
20+
},
21+
}));
22+
23+
const { capturedOptions } = vi.hoisted(() => ({ capturedOptions: { current: undefined as any } }));
24+
25+
vi.mock('../fapiClient', async importOriginal => {
26+
const actual = await importOriginal<typeof import('../fapiClient')>();
27+
return {
28+
...actual,
29+
createFapiClient: (options: any) => {
30+
capturedOptions.current = options;
31+
return actual.createFapiClient(options);
32+
},
33+
};
34+
});
35+
36+
const productionPublishableKey = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k';
37+
38+
const sessionParams = { __clerk_protect_token: 'v1.payload.mac', __clerk_protect_status: 'ok' };
39+
const assertionParams = { __clerk_protect_assertion: 'token-abc' };
40+
41+
/** The hook a freshly constructed Clerk handed to the FAPI client. */
42+
const hookFor = (assertion?: ProtectAssertion) => {
43+
const clerk = new Clerk(productionPublishableKey);
44+
if (assertion !== undefined) {
45+
clerk.setProtectAssertion(assertion);
46+
}
47+
return capturedOptions.current.getProtectParams as () => Promise<Record<string, string | undefined> | undefined>;
48+
};
49+
50+
describe('Clerk getProtectParams', () => {
51+
beforeEach(() => {
52+
getRequestParams.mockReset();
53+
capturedOptions.current = undefined;
54+
});
55+
56+
it('is wired into the FAPI client', () => {
57+
expect(hookFor()).toBeTypeOf('function');
58+
});
59+
60+
it('unions the assertion and the session token', async () => {
61+
getRequestParams.mockResolvedValue(sessionParams);
62+
63+
await expect(hookFor('token-abc')()).resolves.toEqual({ ...assertionParams, ...sessionParams });
64+
});
65+
66+
it('sends the session token when no assertion is configured', async () => {
67+
getRequestParams.mockResolvedValue(sessionParams);
68+
69+
await expect(hookFor()()).resolves.toEqual(sessionParams);
70+
});
71+
72+
it('sends the assertion when the session contributes nothing', async () => {
73+
getRequestParams.mockResolvedValue(undefined);
74+
75+
await expect(hookFor('token-abc')()).resolves.toEqual(assertionParams);
76+
});
77+
78+
// Returning `{}` would make every sign-in body differ from what it was before the feature existed.
79+
it('resolves to undefined when neither contributes anything', async () => {
80+
getRequestParams.mockResolvedValue(undefined);
81+
82+
await expect(hookFor()()).resolves.toBeUndefined();
83+
});
84+
85+
// Neither feature may take the other down with it.
86+
it('keeps the session token when the assertion resolver throws', async () => {
87+
getRequestParams.mockResolvedValue(sessionParams);
88+
89+
await expect(
90+
hookFor(() => {
91+
throw new Error('boom');
92+
})(),
93+
).resolves.toEqual(sessionParams);
94+
});
95+
96+
it('keeps the assertion when acquiring the session token rejects', async () => {
97+
getRequestParams.mockRejectedValue(new DOMException('storage is blocked', 'SecurityError'));
98+
99+
await expect(hookFor('token-abc')()).resolves.toEqual(assertionParams);
100+
});
101+
});

packages/clerk-js/src/core/__tests__/fapiClient.test.ts

Lines changed: 84 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -385,67 +385,84 @@ describe('request', () => {
385385
});
386386

387387
describe('Protect params', () => {
388-
const protectParams = { __clerk_protect_assertion: 'token-abc' };
389-
const clientWithProtect = createFapiClient({
390-
...baseFapiClientOptions,
391-
getProtectParams: () => Promise.resolve(protectParams),
388+
// Two independent features feed this one hook — an application-supplied assertion and the
389+
// server-configured session token — so the fixture carries params from both.
390+
const protectParams = {
391+
__clerk_protect_assertion: 'token-abc',
392+
__clerk_protect_token: 'v1.payload.mac',
393+
__clerk_protect_status: 'ok',
394+
__clerk_protect_cid: `1-${'a'.repeat(26)}-${'b'.repeat(26)}`,
395+
};
396+
const expectedProtectQuery =
397+
'__clerk_protect_assertion=token-abc&__clerk_protect_token=v1.payload.mac&__clerk_protect_status=ok' +
398+
`&__clerk_protect_cid=${protectParams.__clerk_protect_cid}`;
399+
400+
let getProtectParams: Mock;
401+
let clientWithProtect: ReturnType<typeof createFapiClient>;
402+
403+
beforeEach(() => {
404+
getProtectParams = vi.fn().mockResolvedValue(protectParams);
405+
clientWithProtect = createFapiClient({ ...baseFapiClientOptions, getProtectParams });
392406
});
393407

394-
it.each([
395-
['/client/sign_ins'],
396-
['/client/sign_ins/sia_123/attempt_first_factor'],
397-
['/client/sign_ups'],
398-
['/client/sign_ups/sua_123/attempt_verification'],
399-
])('attaches them to POST %s', async path => {
400-
await clientWithProtect.request({ path, method: 'POST', body: { identifier: 'user@example.com' } as any });
408+
const bodyOf = () => (fetch as Mock).mock.calls[0][1].body as string;
401409

402-
expect(fetch).toHaveBeenCalledWith(
403-
expect.any(URL),
404-
expect.objectContaining({
405-
body: 'identifier=user%40example.com&__clerk_protect_assertion=token-abc',
406-
}),
407-
);
410+
it.each([
411+
'/client/sign_ins',
412+
'/client/sign_ups',
413+
'/client/sign_ins/sia_123/attempt_first_factor',
414+
'/client/sign_ups/sua_123/attempt_verification',
415+
])('merges them into the form-encoded body of %s', async path => {
416+
await clientWithProtect.request({ path, method: 'POST', body: { identifier: 'nick@clerk.dev' } as any });
417+
418+
expect(bodyOf()).toBe(`identifier=nick%40clerk.dev&${expectedProtectQuery}`);
419+
// A signed credential must never land in the URL, which is logged all along the path.
420+
expect((fetch as Mock).mock.calls[0][0].toString()).not.toContain('__clerk_protect');
408421
});
409422

410-
it('attaches them when the request has no body of its own', async () => {
411-
await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST' });
423+
it('adds no request headers', async () => {
424+
await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: {} as any });
412425

413-
expect(fetch).toHaveBeenCalledWith(
414-
expect.any(URL),
415-
expect.objectContaining({ body: '__clerk_protect_assertion=token-abc' }),
416-
);
426+
const headers = (fetch as Mock).mock.calls[0][1].headers as Headers;
427+
expect([...headers.keys()]).toEqual(['content-type']);
417428
});
418429

419-
// All lower-case, so the camel-to-snake body key encoder has nothing to rewrite.
420-
it('does not mangle the param name', async () => {
421-
await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST' });
430+
// Also pins the param names against the camel-to-snake body key encoder: they are all
431+
// lower-case, so it has nothing to rewrite.
432+
it('populates the body even when the request had none', async () => {
433+
await clientWithProtect.request({ path: '/client/sign_ups', method: 'POST' });
422434

423-
const [, init] = (fetch as Mock).mock.calls.at(-1)!;
424-
expect(init.body).toBe('__clerk_protect_assertion=token-abc');
435+
expect(bodyOf()).toBe(expectedProtectQuery);
425436
});
426437

427-
it.each([
428-
['a GET', 'GET', '/client/sign_ins'],
429-
['an unrelated path', 'POST', '/client/sessions'],
430-
['a path that merely shares a prefix', 'POST', '/client/sign_ins_other'],
431-
])('does not attach them to %s', async (_label, method, path) => {
432-
await clientWithProtect.request({ path, method: method as any, body: { a: 'b' } as any });
433-
434-
const [, init] = (fetch as Mock).mock.calls.at(-1)!;
435-
expect(init.body ?? '').not.toContain('__clerk_protect_assertion');
438+
it.each(['/client', '/client/sessions', '/environment', '/client/sign_insomething', '/client/sign_ins_other'])(
439+
'leaves %s alone',
440+
async path => {
441+
await clientWithProtect.request({ path, method: 'POST', body: { foo: 'bar' } as any });
442+
443+
expect(bodyOf()).toBe('foo=bar');
444+
expect(getProtectParams).not.toHaveBeenCalled();
445+
},
446+
);
447+
448+
it('leaves GET requests alone', async () => {
449+
await clientWithProtect.request({ path: '/client/sign_ins', method: 'GET' });
450+
451+
expect(getProtectParams).not.toHaveBeenCalled();
436452
});
437453

438454
// Spreading a FormData would discard the caller's payload, so non-plain bodies are left alone.
439-
it('leaves a FormData body untouched', async () => {
455+
it('leaves a FormData body alone', async () => {
440456
const formData = new FormData();
441-
formData.append('identifier', 'user@example.com');
457+
formData.append('identifier', 'nick@clerk.dev');
442458

443459
await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: formData });
444460

445-
expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: formData }));
461+
expect((fetch as Mock).mock.calls[0][1].body).toBe(formData);
462+
expect(getProtectParams).not.toHaveBeenCalled();
446463
});
447464

448-
it('leaves a string body untouched', async () => {
465+
it('leaves a string body alone', async () => {
449466
// text/plain keeps the form-urlencoded encoder out of it.
450467
await clientWithProtect.request({
451468
path: '/client/sign_ins',
@@ -454,38 +471,44 @@ describe('request', () => {
454471
headers: { 'content-type': 'text/plain' },
455472
});
456473

457-
expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: 'raw string body' }));
474+
expect(bodyOf()).toBe('raw string body');
475+
expect(getProtectParams).not.toHaveBeenCalled();
458476
});
459477

460-
// Protect may influence a sign-in but must never fail one.
461-
it('sends the request unchanged when resolving the params rejects', async () => {
462-
const failing = createFapiClient({
463-
...baseFapiClientOptions,
464-
getProtectParams: () => Promise.reject(new Error('boom')),
465-
});
478+
// Merging into any of these would spread away the caller's payload rather than add to it.
479+
it.each([
480+
['a Blob', () => new Blob(['payload'])],
481+
['an array', () => [1, 2, 3]],
482+
['a URLSearchParams', () => new URLSearchParams({ identifier: 'nick@clerk.dev' })],
483+
])('leaves %s body alone', async (_label, makeBody) => {
484+
await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: makeBody() as any });
485+
486+
expect(getProtectParams).not.toHaveBeenCalled();
487+
expect(String((fetch as Mock).mock.calls[0][1].body)).not.toContain('__clerk_protect');
488+
});
466489

467-
await expect(
468-
failing.request({ path: '/client/sign_ins', method: 'POST', body: { identifier: 'a' } as any }),
469-
).resolves.toBeTruthy();
490+
it('sends nothing extra when the instance contributes no params', async () => {
491+
getProtectParams.mockResolvedValue(undefined);
470492

471-
expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: 'identifier=a' }));
472-
});
493+
await clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: { foo: 'bar' } as any });
473494

474-
it('sends the request unchanged when there are no params', async () => {
475-
const none = createFapiClient({
476-
...baseFapiClientOptions,
477-
getProtectParams: () => Promise.resolve(undefined),
478-
});
495+
expect(bodyOf()).toBe('foo=bar');
496+
});
479497

480-
await none.request({ path: '/client/sign_ins', method: 'POST', body: { identifier: 'a' } as any });
498+
it('still sends the request when resolving the params rejects', async () => {
499+
getProtectParams.mockRejectedValue(new DOMException('storage is blocked', 'SecurityError'));
481500

482-
expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: 'identifier=a' }));
501+
// Protect can degrade a sign-in but must never fail one before it is even sent.
502+
await expect(
503+
clientWithProtect.request({ path: '/client/sign_ins', method: 'POST', body: { foo: 'bar' } as any }),
504+
).resolves.toBeDefined();
505+
expect(bodyOf()).toBe('foo=bar');
483506
});
484507

485508
it('is inert when no hook is configured', async () => {
486509
await fapiClient.request({ path: '/client/sign_ins', method: 'POST', body: { identifier: 'a' } as any });
487510

488-
expect(fetch).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ body: 'identifier=a' }));
511+
expect(bodyOf()).toBe('identifier=a');
489512
});
490513
});
491514

0 commit comments

Comments
 (0)