Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/protect-token-timeout-10s.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Wait up to 10 seconds, instead of 5, for the Device Intelligence check to finish before sending a sign-in or sign-up without its result. This applies when an instance hasn't configured its own limit. Users on slow connections are less likely to have sign-ups blocked by protections that require a Device Intelligence result.
120 changes: 114 additions & 6 deletions packages/clerk-js/src/core/__tests__/protectSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,7 @@ describe('ProtectSession deadlines', () => {

const startedAt = Date.now();
await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'timeout' });
// Misread, this would fall back to the 5s default and be two orders of magnitude out.
// Misread, this would fall back to the 10s default and be two orders of magnitude out.
expect(Date.now() - startedAt).toBeLessThan(1_000);
});

Expand Down Expand Up @@ -733,11 +733,119 @@ describe('ProtectSession deadlines', () => {
// `tokenTimeoutMs` is server config with no upper bound of its own, so the ceiling is ours.
expect(clampTimeout(120_000)).toBe(10_000);
expect(clampTimeout(1_500)).toBe(1_500);
expect(clampTimeout(undefined)).toBe(5_000);
expect(clampTimeout(0)).toBe(5_000);
expect(clampTimeout(-1)).toBe(5_000);
expect(clampTimeout('soon')).toBe(5_000);
expect(clampTimeout(Number.POSITIVE_INFINITY)).toBe(5_000);
expect(clampTimeout(undefined)).toBe(10_000);
expect(clampTimeout(0)).toBe(10_000);
expect(clampTimeout(-1)).toBe(10_000);
expect(clampTimeout('soon')).toBe(10_000);
expect(clampTimeout(Number.POSITIVE_INFINITY)).toBe(10_000);
});
});

describe('ProtectSession default deadline', () => {
const defaultLoader = () => loader({ token_timeout_ms: undefined });

const track = (params: Promise<unknown> | undefined) => {
const state = { settled: false };
void params?.then(() => {
state.settled = true;
});
return state;
};

beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
delete (navigator as unknown as Record<string, unknown>).locks;
});

it('accepts a token that arrives after 7s', async () => {
const { session: created, elements } = session([defaultLoader()]);
created?.start();
const params = created?.getRequestParams();

await vi.advanceTimersByTimeAsync(7_000);
serveInline(elements[0], { cid: created?.placeholders().cid });

await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'ok' });
});

it('gives up at 10s when the loader never loads', async () => {
const { session: created } = session([defaultLoader()]);
created?.start();
const params = created?.getRequestParams();
const state = track(params);

await vi.advanceTimersByTimeAsync(9_999);
expect(state.settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'timeout' });
});

it('holds a sign-in made mid-acquisition only for what is left of it', async () => {
const { session: created } = session([defaultLoader()]);
created?.start();
await vi.advanceTimersByTimeAsync(6_000);

const params = created?.getRequestParams();
const state = track(params);

await vi.advanceTimersByTimeAsync(3_999);
expect(state.settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'timeout' });
});

it('never holds one sign-in past the deadline when the lock is granted late', async () => {
Object.defineProperty(navigator, 'locks', {
value: {
request: vi.fn((_key: string, _options: unknown, callback: () => Promise<unknown>) =>
new Promise(resolve => setTimeout(resolve, 4_900)).then(() => callback()),
),
},
configurable: true,
});

const { session: created } = session([defaultLoader()]);
created?.start();
const params = created?.getRequestParams();
const state = track(params);

await vi.advanceTimersByTimeAsync(9_999);
expect(state.settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(params).resolves.toMatchObject({ __clerk_protect_status: 'timeout' });
});

it('keeps waiting for a leader tab after SafeLock gives up, until its own deadline', async () => {
Object.defineProperty(navigator, 'locks', {
value: {
// Never granted; rejects on SafeLock's abort the way the Web Locks API does.
request: vi.fn(
(_key: string, options: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
options.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
}),
),
},
configurable: true,
});

const { session: created, elements } = session([defaultLoader()]);
created?.start();
const params = created?.getRequestParams();

await vi.advanceTimersByTimeAsync(7_000);
localStorage.setItem('__clerk_protect_st', storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26) }));
await vi.advanceTimersByTimeAsync(100);

await expect(params).resolves.toMatchObject({
__clerk_protect_token: 'v1.leader.mac',
__clerk_protect_status: 'ok',
});
expect(elements).toHaveLength(0);
});
});

Expand Down
20 changes: 17 additions & 3 deletions packages/clerk-js/src/core/protectSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ const INLINE_TOKEN_GLOBAL = '__clerk_specter';
/** A token this close to expiry is treated as absent, so a fresh run starts before it lapses. */
const TOKEN_REFRESH_MARGIN_MS = 60 * 1_000;
/** Acquisition deadline when the instance does not configure one. */
const DEFAULT_TOKEN_TIMEOUT_MS = 5 * 1_000;
const DEFAULT_TOKEN_TIMEOUT_MS = 10 * 1_000;
/** Ceiling on the instance-configured deadline, so no server value can stall a sign-in. */
const MAX_TOKEN_TIMEOUT_MS = 10 * 1_000;
/** How often a tab that could not take the lock re-reads the store while another tab mints. */
const STORE_POLL_INTERVAL_MS = 100;
/** A stored token claiming more remaining life than this was not minted by us. */
const MAX_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1_000;
/** Longest token we will hand back, so a planted store entry cannot bloat a sign-in body. */
Expand Down Expand Up @@ -666,6 +668,7 @@ export class ProtectSession {
return 'ok';
}

const deadline = Date.now() + this.#timeoutMs;
const result = await this.#lock.acquireLockAndRun(async () => {
// Double-checked inside the lock. Without this re-read every tab runs the loader in turn,
// which is the exact failure this design exists to prevent.
Expand All @@ -680,8 +683,19 @@ export class ProtectSession {
}

// The lock was never held — `SafeLock` gives up waiting after ~5s so a wedged leader delays
// nobody. Whatever a leader did manage to write is still ours to use.
return this.#freshStoredToken() ? 'ok' : 'timeout';
// nobody. Whatever a leader manages to write before our own deadline is still ours to use.
return (await this.#awaitStoredToken(deadline)) ? 'ok' : 'timeout';
}

async #awaitStoredToken(deadline: number): Promise<boolean> {
while (!this.#freshStoredToken()) {
const remaining = deadline - Date.now();
if (remaining <= 0) {
return false;
}
await new Promise(resolve => setTimeout(resolve, Math.min(STORE_POLL_INTERVAL_MS, remaining)));
}
return true;
}

/** Runs the loader and takes the token it was served with. Never rejects. */
Expand Down
Loading