Skip to content
Open
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-keep-late-di-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Keep a Device Intelligence result that arrives after ClerkJS has stopped waiting for it, and use it for the next sign-in or sign-up instead of discarding it. Users on slow connections who retry are less likely to have sign-ups blocked by protections that require a Device Intelligence result.
238 changes: 238 additions & 0 deletions packages/clerk-js/src/core/__tests__/protectSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,244 @@ describe('ProtectSession deadlines', () => {
});
});

describe('ProtectSession late token', () => {
const STORE_KEY = '__clerk_protect_st';

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

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

const timedOut = async () => {
const h = session([loader({ token_timeout_ms: 5_000 })]);
h.session?.start();
const first = h.session?.getRequestParams();
await vi.advanceTimersByTimeAsync(5_000);
await expect(first).resolves.toMatchObject({ __clerk_protect_status: 'timeout' });
return h;
};

it('keeps a token whose loader finishes after the deadline for the next sign-in', async () => {
const { session: created, elements } = await timedOut();

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

await expect(created?.getRequestParams()).resolves.toMatchObject({
__clerk_protect_token: 'v1.payload.mac',
__clerk_protect_status: 'ok',
});
// Still inside the cooldown, so the token can only have come from the late load.
expect(elements).toHaveLength(1);
});

it('keeps a token whose ready settles after the deadline', async () => {
const { session: created, elements } = session([loader({ token_timeout_ms: 5_000 })]);
created?.start();
const first = created?.getRequestParams();

let release: (value: unknown) => void = () => undefined;
await vi.advanceTimersByTimeAsync(1_000);
serveInline(elements[0], {
cid: created?.placeholders().cid,
ready: new Promise(resolve => {
release = resolve;
}),
});
await vi.advanceTimersByTimeAsync(4_000);
await expect(first).resolves.toMatchObject({ __clerk_protect_status: 'timeout' });

// Well past a second deadline window, so only an unbounded late read can still catch it.
await vi.advanceTimersByTimeAsync(7_000);
release({ token: 'v1.late.mac', exp: nowSeconds() + 43_200 });
await vi.advanceTimersByTimeAsync(0);

await expect(created?.getRequestParams()).resolves.toMatchObject({
__clerk_protect_token: 'v1.late.mac',
__clerk_protect_status: 'ok',
});
});

it('does not replace a stored token that lasts longer', async () => {
const { session: created, elements } = await timedOut();

localStorage.setItem(
STORE_KEY,
storedEntry({ token: 'v1.leader.mac', rid: 'c'.repeat(26), exp: nowSeconds() + 43_260 }),
);
serveInline(elements[0], { cid: created?.placeholders().cid });
await vi.advanceTimersByTimeAsync(0);

await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.leader.mac' });
});

it('replaces a stored token that is still usable but expires sooner', async () => {
const { session: created, elements } = await timedOut();

localStorage.setItem(STORE_KEY, storedEntry({ token: 'v1.old.mac', rid: 'c'.repeat(26), exp: nowSeconds() + 45 }));
serveInline(elements[0], { cid: created?.placeholders().cid });
await vi.advanceTimersByTimeAsync(0);

await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.payload.mac' });
});

it('does not replace a stored token stamped with a newer invalidation floor', async () => {
const floor = nowSeconds() - 60;
const created = session([loader({ token_timeout_ms: 5_000 })], floor);
created.session?.start();
const first = created.session?.getRequestParams();
await vi.advanceTimersByTimeAsync(5_000);
await expect(first).resolves.toMatchObject({ __clerk_protect_status: 'timeout' });

localStorage.setItem(
STORE_KEY,
storedEntry({ token: 'v1.newer.mac', rid: 'c'.repeat(26), exp: nowSeconds() + 3_600, floor: floor + 30 }),
);
serveInline(created.elements[0], { cid: created.session?.placeholders().cid });
await vi.advanceTimersByTimeAsync(0);

await expect(created.session?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.newer.mac' });
});

it('waits for the cross-tab lock before storing a late token', async () => {
let tail: Promise<unknown> = Promise.resolve();
Object.defineProperty(navigator, 'locks', {
value: {
request: vi.fn((_key: string, _options: unknown, callback: () => Promise<unknown>) => {
const result = tail.then(() => callback());
tail = result.catch(() => undefined);
return result;
}),
},
configurable: true,
});

const a = await timedOut();

// Another tab takes the lock and starts minting.
const b = session([loader({ token_timeout_ms: 5_000 })]);
b.session?.start();
await vi.advanceTimersByTimeAsync(0);
expect(b.elements).toHaveLength(1);

serveInline(a.elements[0], { cid: a.session?.placeholders().cid });
await vi.advanceTimersByTimeAsync(0);
expect(localStorage.getItem(STORE_KEY)).toBeNull();

serveInline(b.elements[0], {
cid: b.session?.placeholders().cid,
ready: Promise.resolve({ token: 'v1.b.mac', exp: nowSeconds() + 43_260 }),
});
await vi.advanceTimersByTimeAsync(0);

expect(JSON.parse(localStorage.getItem(STORE_KEY) as string)).toMatchObject({ token: 'v1.b.mac' });
});

it.each([
{ name: 'an empty store', stored: undefined, token: 'v1.payload.mac' },
{ name: 'a longer-lived stored token', stored: { exp: 43_260, floor: 0 }, token: 'v1.leader.mac' },
{ name: 'a newer invalidation floor', stored: { exp: 3_600, floor: 30 }, token: 'v1.leader.mac' },
])('retries a timed-out lock and respects $name', async ({ stored, token }) => {
let calls = 0;
const request = vi.fn((_key: string, options: { signal: AbortSignal }, callback: () => Promise<unknown>) => {
if (++calls === 2) {
return new Promise((_resolve, reject) => {
options.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
});
}
return callback();
});
Object.defineProperty(navigator, 'locks', { value: { request }, configurable: true });

const { session: created, elements } = await timedOut();
await vi.advanceTimersByTimeAsync(2_000);
serveInline(elements[0], { cid: created?.placeholders().cid });
await vi.advanceTimersByTimeAsync(4_999);

expect(request).toHaveBeenCalledTimes(2);
expect(localStorage.getItem(STORE_KEY)).toBeNull();
await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'timeout' });

if (stored) {
localStorage.setItem(
STORE_KEY,
storedEntry({ token, rid: 'c'.repeat(26), exp: nowSeconds() + stored.exp, floor: stored.floor }),
);
}
await vi.advanceTimersByTimeAsync(1_000);

expect(request).toHaveBeenCalledTimes(3);
await expect(created?.getRequestParams()).resolves.toMatchObject({
__clerk_protect_token: token,
__clerk_protect_status: 'ok',
});
expect(elements).toHaveLength(1);
});

it('stops retrying the lock when the late token is no longer usable', async () => {
let calls = 0;
const request = vi.fn((_key: string, options: { signal: AbortSignal }, callback: () => Promise<unknown>) => {
if (++calls > 1) {
return new Promise((_resolve, reject) => {
options.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
});
}
return callback();
});
Object.defineProperty(navigator, 'locks', { value: { request }, configurable: true });

const { session: created, elements } = await timedOut();
serveInline(elements[0], {
cid: created?.placeholders().cid,
ready: Promise.resolve({ token: 'v1.late.mac', exp: nowSeconds() + 12 }),
});
await vi.advanceTimersByTimeAsync(20_000);

expect(request).toHaveBeenCalledTimes(3);
expect(localStorage.getItem(STORE_KEY)).toBeNull();
});

it('revalidates a late token after waiting for the lock', async () => {
let calls = 0;
const request = vi.fn((_key: string, _options: unknown, callback: () => Promise<unknown>) => {
if (++calls === 2) {
return new Promise(resolve => setTimeout(resolve, 4_000)).then(callback);
}
return callback();
});
Object.defineProperty(navigator, 'locks', { value: { request }, configurable: true });

const { session: created, elements } = await timedOut();
serveInline(elements[0], {
cid: created?.placeholders().cid,
ready: Promise.resolve({ token: 'v1.late.mac', exp: nowSeconds() + 7 }),
});
await vi.advanceTimersByTimeAsync(4_000);

expect(request).toHaveBeenCalledTimes(2);
expect(localStorage.getItem(STORE_KEY)).toBeNull();
await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'timeout' });
});

it.each([
['minted for another page', { cid: 'someone-else' }],
['carrying no token', { ready: Promise.resolve({ status: 'no_token' }) }],
])('ignores a late result %s', async (_name, overrides) => {
const { session: created, elements } = await timedOut();

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

await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_status: 'timeout' });
expect(localStorage.getItem(STORE_KEY)).toBeNull();
});
});

describe('ProtectSession cross-tab single flight', () => {
/**
* A serialising Web Locks stand-in. jsdom has no `navigator.locks`, and the `browser-tabs-lock`
Expand Down
64 changes: 58 additions & 6 deletions packages/clerk-js/src/core/protectSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const TOKEN_SEND_MARGIN_MS = 5 * 1_000;
/** Bounds we hold the server-supplied `retry_in_ms` to. */
const MIN_RETRY_DELAY_MS = 50;
const MAX_RETRY_DELAY_MS = 1_000;
const LATE_TOKEN_RETRY_DELAY_MS = 1_000;

/** Lowercase RFC 4648 base32 — a `cid` has to survive case-insensitive, alphanumeric-only contexts. */
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
Expand Down Expand Up @@ -366,7 +367,7 @@ function settleWithin(ready: PromiseLike<unknown>, deadline: number): Promise<un
* `timeout`: until the server half deploys it is the correct answer for every load, and calling
* that a timeout would make a normal rollout look like an outage.
*/
async function readInlineToken(cid: string, rid: string, deadline: number): Promise<MintedToken | ProtectStatus> {
async function readInlineToken(cid: string, rid: string, deadline?: number): Promise<MintedToken | ProtectStatus> {
const inline = (globalThis as unknown as Record<string, unknown>)[INLINE_TOKEN_GLOBAL];
if (!inline || typeof inline !== 'object') {
return 'no_token';
Expand All @@ -379,7 +380,9 @@ async function readInlineToken(cid: string, rid: string, deadline: number): Prom
return 'no_token';
}

const resolved = await settleWithin(ready, deadline);
// No deadline means a late read, which waits for `ready` however long it takes.
const resolved =
deadline === undefined ? await Promise.resolve(ready).catch(() => null) : await settleWithin(ready, deadline);
if (resolved === DEADLINE) {
return 'timeout';
}
Expand Down Expand Up @@ -688,7 +691,7 @@ export class ProtectSession {
async #mint(): Promise<ProtectStatus> {
const deadline = Date.now() + this.#timeoutMs;

const outcome = await this.#runTokenLoader(deadline);
const outcome = await this.#runTokenLoader(deadline, () => void this.#keepLateToken());
if (outcome === 'error') {
return 'script_error';
}
Expand All @@ -701,6 +704,9 @@ export class ProtectSession {
}

const inline = await readInlineToken(this.#cid as string, this.#rid as string, deadline);
if (inline === 'timeout') {
void this.#keepLateToken();
}
if (typeof inline === 'string') {
return inline;
}
Expand All @@ -709,11 +715,40 @@ export class ProtectSession {
return 'ok';
}

/** A token that lands after the deadline was still minted for this page, so keep it for the next sign-in. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the newly added code comments

The repository explicitly forbids adding code comments unless the user requests them; this new JSDoc and the other newly added inline comments around late reads and saves therefore violate the package-wide contribution rule and should be removed.

AGENTS.md reference: AGENTS.md:L9-L9

Useful? React with 👍 / 👎.

async #keepLateToken(): Promise<void> {
if (this.#tokenUrl) {
return;
}
const late = await readInlineToken(this.#cid as string, this.#rid as string);
if (typeof late === 'string') {
return;
}
while (validateToken(late.token, late.exp, TOKEN_SEND_MARGIN_MS)) {
const saved = await this.#lock.acquireLockAndRun(() => {
if (!validateToken(late.token, late.exp, TOKEN_SEND_MARGIN_MS)) {
return Promise.resolve(true);
}
const stored = readStoredToken(this.#tokenStorageKey, TOKEN_SEND_MARGIN_MS, this.#floor);
// Keep whichever token is better: a newer invalidation floor first, then the later expiry.
if (!stored || (stored.floor <= this.#floor && stored.exp < late.exp)) {
writeStoredToken(this.#tokenStorageKey, late, this.#floor);
this.#consecutiveFailures = 0;
}
return Promise.resolve(true);
});
if (saved === true) {
return;
}
await new Promise(resolve => setTimeout(resolve, LATE_TOKEN_RETRY_DELAY_MS));
}
}

/**
* Injects the token loader and settles on whichever of `load`, `error` or the deadline comes
* first, or immediately when the element has nothing to fetch.
*/
#runTokenLoader(deadline: number): Promise<LoaderOutcome> {
#runTokenLoader(deadline: number, onLateLoad?: () => void): Promise<LoaderOutcome> {
const loader = this.#tokenLoader;
if (!loader) {
return Promise.resolve('error');
Expand All @@ -739,12 +774,29 @@ export class ProtectSession {
}

return new Promise<LoaderOutcome>(resolve => {
const timer = setTimeout(() => resolve('timeout'), Math.max(0, deadline - Date.now()));
let timedOut = false;
const timer = setTimeout(
() => {
timedOut = true;
resolve('timeout');
},
Math.max(0, deadline - Date.now()),
);
const settle = (outcome: LoaderOutcome) => () => {
clearTimeout(timer);
resolve(outcome);
};
element.addEventListener('load', settle('loaded'), { once: true });
element.addEventListener(
'load',
() => {
if (timedOut) {
onLateLoad?.();
return;
}
settle('loaded')();
},
{ once: true },
);
element.addEventListener('error', settle('error'), { once: true });
});
}
Expand Down
Loading