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-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.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{ "path": "./dist/clerk.js", "maxSize": "556KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "83KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "126KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "322.25KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "322.5KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "82KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
{ "path": "./dist/coinbase*.js", "maxSize": "36KB" },
Expand Down
152 changes: 152 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,158 @@ 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([
['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
54 changes: 48 additions & 6 deletions packages/clerk-js/src/core/protectSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,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 +379,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 +690,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 +703,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 +714,31 @@ 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. */
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;
}
await this.#lock.acquireLockAndRun(() => {
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();
});
}

/**
* 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 +764,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