Skip to content

Commit 09bfef2

Browse files
committed
fix(clerk-js): discard a stored session token that was never a mint
`validateToken` documented that a value which could not have come from a mint of ours is discarded, but only checked type, length and expiry — so a corrupt or truncated store entry counted as fresh and suppressed acquisition until it expired, up to the lifetime ceiling. This is hygiene, not a security boundary, and is deliberately not framed as one: only the backend can tell a real token from a well-formed forgery, and anything that can write the store can send the same values to the API directly. What it buys is that a broken entry starts a fresh run immediately. The shape is matched version-agnostically. Pinning it to the current version would mean an SDK rejecting a token the backend had minted ahead of it, and re-running the loader on every page load until the SDK caught up — a test guards against that tightening.
1 parent ed13b06 commit 09bfef2

2 files changed

Lines changed: 44 additions & 1 deletion

File tree

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,39 @@ describe('ProtectSession inline token', () => {
383383
await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.payload.mac' });
384384
});
385385

386+
it('ignores a planted value that could never have been a mint', async () => {
387+
localStorage.setItem(
388+
'__clerk_protect_st',
389+
JSON.stringify({ token: 'not-a-token', exp: nowSeconds() + 43_200, rid: 'b'.repeat(26) }),
390+
);
391+
392+
const { session: created, injected } = session([loader()]);
393+
// Shape alone proves nothing — only the server can tell a mint from a well-formed forgery —
394+
// but a corrupt entry must start a fresh run rather than suppress acquisition until it expires.
395+
expect(created?.hasFreshToken()).toBe(false);
396+
397+
created?.start();
398+
serveInline(await injected(), { cid: created?.placeholders().cid });
399+
400+
await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.payload.mac' });
401+
});
402+
403+
it('reuses a mint whose version this build predates', async () => {
404+
localStorage.setItem(
405+
'__clerk_protect_st',
406+
JSON.stringify({ token: 'v9.cached.mac', exp: nowSeconds() + 43_200, rid: 'b'.repeat(26) }),
407+
);
408+
409+
// The shape check must not pin a version. The server may mint ahead of this build, and
410+
// rejecting that here would re-run the loader on every page load until the SDK caught up.
411+
const { session: created, elements } = session([loader()]);
412+
expect(created?.hasFreshToken()).toBe(true);
413+
created?.start();
414+
415+
await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v9.cached.mac' });
416+
expect(elements).toHaveLength(0);
417+
});
418+
386419
it('reports nothing at all for a loader that carries no correlation id', async () => {
387420
const { session: created, elements } = session([loader({ attributes: { 'data-pid': '{pid}' } })]);
388421

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ const MAX_TOKEN_TIMEOUT_MS = 10 * 1_000;
3838
const MAX_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1_000;
3939
/** Longest token we will hand back, so a planted store entry cannot bloat a sign-in body. */
4040
const MAX_TOKEN_LENGTH = 4_096;
41+
/**
42+
* The shape of a mint: `v<n>.<payload>.<mac>`, base64url. Version-agnostic on purpose — the server
43+
* may mint a version this build predates, and only the server can judge a token either way.
44+
*/
45+
const TOKEN_SHAPE = /^v\d+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
4146
/** How long a settled, tokenless acquisition is reused before a fresh run is allowed. */
4247
const REACQUIRE_COOLDOWN_MS = 30 * 1_000;
4348
/** Bounds we hold the server-supplied `retry_in_ms` to. */
@@ -245,9 +250,14 @@ function readStoredToken(key: string, marginMs: number): StoredToken | null {
245250
/**
246251
* The store is writable by anything running on the origin, so a value that could not have come
247252
* from a mint of ours is discarded rather than trusted to suppress the loaders.
253+
*
254+
* The shape check is hygiene, not a security boundary: only the server can tell a real token from a
255+
* well-formed forgery, and anything that can write the store can send the same values to the API
256+
* directly. What it buys is that a corrupt or truncated entry starts a fresh run immediately
257+
* instead of suppressing acquisition until it expires.
248258
*/
249259
function validateToken(token: unknown, exp: unknown, marginMs: number): { token: string; exp: number } | null {
250-
if (typeof token !== 'string' || !token || token.length > MAX_TOKEN_LENGTH) {
260+
if (typeof token !== 'string' || token.length > MAX_TOKEN_LENGTH || !TOKEN_SHAPE.test(token)) {
251261
return null;
252262
}
253263
if (typeof exp !== 'number' || !Number.isFinite(exp)) {

0 commit comments

Comments
 (0)