Skip to content

Commit dbaa95a

Browse files
fix(clerk-js,react,shared,ui): bound loading a verification, not running one (#9527)
1 parent 27dbf26 commit dbaa95a

12 files changed

Lines changed: 429 additions & 25 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/react': patch
4+
'@clerk/shared': patch
5+
'@clerk/ui': patch
6+
---
7+
8+
Fix an issue where a verification that was still progressing normally could be cancelled and reported to the user as having timed out.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type { ProtectLoader } from '@clerk/shared/types';
2+
import { beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import { Protect } from '../protect';
5+
import { __internal_resetProtectStorage } from '../protectSession';
6+
import type { Environment } from '../resources';
7+
8+
const environment = (loaders: unknown[]): Environment => ({ protectConfig: { loaders } }) as unknown as Environment;
9+
10+
/** No `src`, so jsdom does not fetch a real URL and race the assertions here. */
11+
const loader = (overrides: Partial<ProtectLoader> = {}): ProtectLoader => ({
12+
target: 'head',
13+
type: 'script',
14+
...overrides,
15+
});
16+
17+
describe('Protect.challengeLoadTimeoutMs', () => {
18+
beforeEach(() => {
19+
document.head.innerHTML = '';
20+
document.body.innerHTML = '';
21+
__internal_resetProtectStorage();
22+
vi.restoreAllMocks();
23+
});
24+
25+
it('is undefined when no loader asks for one, so the caller falls back', () => {
26+
const protect = new Protect();
27+
protect.load(environment([loader()]));
28+
29+
expect(protect.challengeLoadTimeoutMs).toBeUndefined();
30+
});
31+
32+
it('reports the value from the loader this browser was assigned', () => {
33+
const protect = new Protect();
34+
protect.load(environment([loader({ challenge_load_timeout_ms: 25_000 })]));
35+
36+
expect(protect.challengeLoadTimeoutMs).toBe(25_000);
37+
});
38+
39+
// The whole point of putting it on the loader: while a new loader ramps, two are live for the
40+
// same instance, and only the one this browser actually got may speak for it.
41+
it('ignores a loader the rollout dice excluded', () => {
42+
vi.spyOn(Math, 'random').mockReturnValue(0.9);
43+
44+
const protect = new Protect();
45+
protect.load(
46+
environment([
47+
// A 10% ramp, excluded at random() 0.9.
48+
loader({ rollout: 0.1, challenge_load_timeout_ms: 25_000 }),
49+
// The incumbent, which this browser does get.
50+
loader({ challenge_load_timeout_ms: 45_000 }),
51+
]),
52+
);
53+
54+
expect(protect.challengeLoadTimeoutMs).toBe(45_000);
55+
});
56+
57+
it('reports the ramping loader once the dice include it', () => {
58+
vi.spyOn(Math, 'random').mockReturnValue(0.01);
59+
60+
const protect = new Protect();
61+
protect.load(environment([loader({ rollout: 0.1, challenge_load_timeout_ms: 25_000 }), loader()]));
62+
63+
expect(protect.challengeLoadTimeoutMs).toBe(25_000);
64+
});
65+
66+
it('ignores a non-positive, non-finite, or non-numeric value rather than passing it on', () => {
67+
const protect = new Protect();
68+
protect.load(
69+
environment([
70+
loader({ challenge_load_timeout_ms: 0 }),
71+
loader({ challenge_load_timeout_ms: Number.POSITIVE_INFINITY }),
72+
loader({ challenge_load_timeout_ms: 'soon' as unknown as number }),
73+
]),
74+
);
75+
76+
expect(protect.challengeLoadTimeoutMs).toBeUndefined();
77+
});
78+
79+
it('reports the first finite, positive value from the applied loaders', () => {
80+
const protect = new Protect();
81+
protect.load(
82+
environment([
83+
loader({ challenge_load_timeout_ms: 0 }),
84+
loader({ challenge_load_timeout_ms: 25_000 }),
85+
loader({ challenge_load_timeout_ms: 45_000 }),
86+
]),
87+
);
88+
89+
expect(protect.challengeLoadTimeoutMs).toBe(25_000);
90+
});
91+
});

‎packages/clerk-js/src/core/clerk.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,16 @@ export class Clerk implements ClerkInterface {
327327
return this.#oauthTransport;
328328
}
329329

330+
/**
331+
* The verification-module load timeout asked for by the loader THIS browser was assigned, or
332+
* undefined when it asked for nothing. Exposed because the assignment is a random draw per page
333+
* load and cannot be recomputed from the environment config; callers fall back to the
334+
* instance-wide value on that config, and then to the SDK default.
335+
*/
336+
get __internal_protectChallengeLoadTimeoutMs(): number | undefined {
337+
return this.#protect?.challengeLoadTimeoutMs;
338+
}
339+
330340
public __internal_getCachedResources:
331341
| (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>)
332342
| undefined;

‎packages/clerk-js/src/core/protect.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ import type { Environment } from './resources';
88
export class Protect {
99
#initialized: boolean = false;
1010
#session?: ProtectSession;
11+
#challengeLoadTimeoutMs?: number;
12+
13+
/**
14+
* The verification-module LOAD timeout asked for by the loader this browser was assigned, or
15+
* undefined when none asked for one — in which case the caller falls back to the instance-wide
16+
* value and then to the SDK default.
17+
*
18+
* Resolved from the APPLIED loaders rather than from the config, because rollout is decided by
19+
* a random draw per page load: a loader being ramped can carry its own value without changing
20+
* anything for browsers still on the loader it replaces, and which one a browser got cannot be
21+
* recomputed afterwards.
22+
*/
23+
get challengeLoadTimeoutMs(): number | undefined {
24+
return this.#challengeLoadTimeoutMs;
25+
}
1126

1227
load(env: Environment): void {
1328
const config = env?.protectConfig;
@@ -40,6 +55,16 @@ export class Protect {
4055
// loaders we are actually going to apply.
4156
const loaders = configured.filter(loader => isLoader(loader) && inRollout(loader));
4257

58+
// Load timeout applies to a single event and we only need one, so we pick the first valid one.
59+
// This happens before applying the loaders since we want to use the value even if the loader
60+
// later fails to apply.
61+
this.#challengeLoadTimeoutMs = loaders.find(
62+
loader =>
63+
typeof loader.challenge_load_timeout_ms === 'number' &&
64+
Number.isFinite(loader.challenge_load_timeout_ms) &&
65+
loader.challenge_load_timeout_ms > 0,
66+
)?.challenge_load_timeout_ms;
67+
4368
// Only an instance whose loaders reference the correlation id gets a session, so an instance
4469
// not using it is unaffected and stores nothing in the browser.
4570
const applyLoader: ApplyLoader = (loader, placeholders) => this.applyLoader(loader, placeholders);

‎packages/clerk-js/src/core/resources/ProtectConfig.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export class ProtectConfig extends BaseResource implements ProtectConfigResource
1111
id: string = '';
1212
loaders?: ProtectLoader[];
1313
tokens_invalid_before?: number;
14+
challenge_load_timeout_ms?: number;
1415
rollout?: number;
1516

1617
public constructor(data: ProtectConfigJSON | ProtectConfigJSONSnapshot | null = null) {
@@ -27,6 +28,7 @@ export class ProtectConfig extends BaseResource implements ProtectConfigResource
2728
this.id = this.withDefault(data.id, this.id);
2829
this.loaders = this.withDefault(data.loaders, this.loaders);
2930
this.tokens_invalid_before = this.withDefault(data.tokens_invalid_before, this.tokens_invalid_before);
31+
this.challenge_load_timeout_ms = this.withDefault(data.challenge_load_timeout_ms, this.challenge_load_timeout_ms);
3032

3133
return this;
3234
}
@@ -37,6 +39,7 @@ export class ProtectConfig extends BaseResource implements ProtectConfigResource
3739
id: this.id,
3840
loaders: this.loaders,
3941
tokens_invalid_before: this.tokens_invalid_before,
42+
challenge_load_timeout_ms: this.challenge_load_timeout_ms,
4043
};
4144
}
4245
}

‎packages/react/src/__tests__/isomorphicClerk.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@ describe('isomorphicClerk', () => {
7373
expect(isomorphicClerk.__internal_moduleManager).toBe(mm);
7474
});
7575

76+
it('exposes the inner clerk-js Protect challenge load timeout', () => {
77+
const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' });
78+
79+
expect(isomorphicClerk.__internal_protectChallengeLoadTimeoutMs).toBeUndefined();
80+
81+
const innerClerk: any = {
82+
addListener: vi.fn(),
83+
__internal_protectChallengeLoadTimeoutMs: 25_000,
84+
};
85+
(isomorphicClerk as any).replayInterceptedInvocations(innerClerk);
86+
87+
expect(isomorphicClerk.__internal_protectChallengeLoadTimeoutMs).toBe(25_000);
88+
});
89+
7690
it('updates props asynchronously after clerkjs has loaded', async () => {
7791
const propsHistory: any[] = [];
7892
const dummyClerkJS = {

‎packages/react/src/isomorphicClerk.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
302302
return this.clerkjs?.__internal_moduleManager;
303303
}
304304

305+
public get __internal_protectChallengeLoadTimeoutMs(): number | undefined {
306+
return this.clerkjs?.__internal_protectChallengeLoadTimeoutMs;
307+
}
308+
305309
constructor(options: IsomorphicClerkOptions) {
306310
this.#publishableKey = options?.publishableKey;
307311
this.#proxyUrl = options?.proxyUrl;

‎packages/shared/src/internal/clerk-js/__tests__/protectCheck.test.ts‎

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { beforeEach, describe, expect, it, vi } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

33
import type { ProtectCheckResource } from '@/types';
44

5-
import { executeProtectCheck } from '../protectCheck';
5+
import { DEFAULT_PROTECT_CHECK_LOAD_TIMEOUT_MS, executeProtectCheck } from '../protectCheck';
66

77
const fakeContainer = (): HTMLDivElement => ({}) as HTMLDivElement;
88

@@ -199,4 +199,131 @@ describe('executeProtectCheck', () => {
199199
});
200200
});
201201
});
202+
203+
describe('the load bound covers the handoff and nothing after it', () => {
204+
beforeEach(() => {
205+
// Fake only the timer functions under test. Faking the whole clock also stalls the
206+
// machinery that settles a dynamic import, so the module would never finish loading.
207+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
208+
});
209+
afterEach(() => {
210+
vi.useRealTimers();
211+
});
212+
213+
// A network that accepts the connection and then never answers is the one load failure that
214+
// cannot report itself — every other one rejects the import on its own.
215+
it('rejects as a load failure when the module never arrives', async () => {
216+
vi.doMock('https://protect.example.com/sdk-hangs.js', () => new Promise(() => {}));
217+
218+
const running = executeProtectCheck(
219+
protectCheck({ sdkUrl: 'https://protect.example.com/sdk-hangs.js' }),
220+
fakeContainer(),
221+
);
222+
const assertion = expect(running).rejects.toMatchObject({ code: 'protect_check_script_load_failed' });
223+
224+
await vi.advanceTimersByTimeAsync(DEFAULT_PROTECT_CHECK_LOAD_TIMEOUT_MS + 1);
225+
await assertion;
226+
});
227+
228+
it('honours a per-instance loadTimeoutMs override instead of the default', async () => {
229+
vi.doMock('https://protect.example.com/sdk-hangs-2.js', () => new Promise(() => {}));
230+
231+
// Expressed relative to the default, and LONGER than it, so that outliving the default is
232+
// itself the proof the override replaced it rather than racing alongside it.
233+
const override = DEFAULT_PROTECT_CHECK_LOAD_TIMEOUT_MS * 2;
234+
const running = executeProtectCheck(
235+
protectCheck({ sdkUrl: 'https://protect.example.com/sdk-hangs-2.js' }),
236+
fakeContainer(),
237+
{ loadTimeoutMs: override },
238+
);
239+
let settled = false;
240+
const watch = running.then(
241+
() => (settled = true),
242+
() => (settled = true),
243+
);
244+
245+
await vi.advanceTimersByTimeAsync(DEFAULT_PROTECT_CHECK_LOAD_TIMEOUT_MS + 1);
246+
expect(settled).toBe(false);
247+
248+
await vi.advanceTimersByTimeAsync(override);
249+
await watch;
250+
expect(settled).toBe(true);
251+
});
252+
253+
// An abort mid-load has to settle the operation, not leave it pending for the whole bound
254+
// holding its closures and timer, and it is a cancellation rather than a load failure.
255+
it('settles as an abort when the caller aborts during a stalled load', async () => {
256+
vi.doMock('https://protect.example.com/sdk-hangs-3.js', () => new Promise(() => {}));
257+
258+
const controller = new AbortController();
259+
const running = executeProtectCheck(
260+
protectCheck({ sdkUrl: 'https://protect.example.com/sdk-hangs-3.js' }),
261+
fakeContainer(),
262+
{ signal: controller.signal },
263+
);
264+
const assertion = expect(running).rejects.toMatchObject({ code: 'protect_check_aborted' });
265+
266+
controller.abort();
267+
// No timer advanced: the abort alone must settle it, well before the load bound.
268+
await assertion;
269+
});
270+
271+
// setTimeout stores its delay in a signed 32-bit int, so an oversized value overflows and
272+
// fires immediately — failing every load instantly, the opposite of what was configured.
273+
it('clamps an oversized loadTimeoutMs instead of overflowing the timer', async () => {
274+
vi.doMock('https://protect.example.com/sdk-hangs-4.js', () => new Promise(() => {}));
275+
276+
const running = executeProtectCheck(
277+
protectCheck({ sdkUrl: 'https://protect.example.com/sdk-hangs-4.js' }),
278+
fakeContainer(),
279+
{ loadTimeoutMs: 2_147_483_648 },
280+
);
281+
let settled = false;
282+
const watch = running.then(
283+
() => (settled = true),
284+
() => (settled = true),
285+
);
286+
287+
// An overflowed timer would already have fired by now.
288+
await vi.advanceTimersByTimeAsync(1_000);
289+
expect(settled).toBe(false);
290+
291+
await vi.advanceTimersByTimeAsync(600_000);
292+
await watch;
293+
expect(settled).toBe(true);
294+
});
295+
296+
// The point of the whole change: the challenge owns its own duration. A challenge running far
297+
// longer than any load bound must still resolve — proof-of-transfer moves a server-chosen
298+
// number of bytes, and a host-side wall would abort it as a "timeout".
299+
it('never bounds the challenge once the module has taken control', async () => {
300+
let running = false;
301+
let finish!: (proofToken: string) => void;
302+
vi.doMock('https://protect.example.com/sdk-slow.js', () => ({
303+
default: () => {
304+
running = true;
305+
return new Promise<string>(resolve => (finish = resolve));
306+
},
307+
}));
308+
309+
const loadTimeoutMs = 5_000;
310+
const execution = executeProtectCheck(
311+
protectCheck({ sdkUrl: 'https://protect.example.com/sdk-slow.js' }),
312+
fakeContainer(),
313+
{ loadTimeoutMs },
314+
);
315+
const assertion = expect(execution).resolves.toBe('proof-after-ages');
316+
317+
// Wait on the real event loop until the module has taken control, so what follows is
318+
// unambiguously time spent in the CHALLENGE rather than in the load.
319+
while (!running) {
320+
await new Promise(resolve => setImmediate(resolve));
321+
}
322+
323+
// Burn far more time than the load bound. Nothing may abort the challenge for it.
324+
await vi.advanceTimersByTimeAsync(360 * loadTimeoutMs);
325+
finish('proof-after-ages');
326+
await assertion;
327+
});
328+
});
202329
});

0 commit comments

Comments
 (0)