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
33 changes: 33 additions & 0 deletions packages/clerk-js/src/core/__tests__/protectSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,39 @@ describe('ProtectSession inline token', () => {
await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v1.payload.mac' });
});

it('ignores a planted value that could never have been a mint', async () => {
localStorage.setItem(
'__clerk_protect_st',
JSON.stringify({ token: 'not-a-token', exp: nowSeconds() + 43_200, rid: 'b'.repeat(26) }),
);

const { session: created, injected } = session([loader()]);
// Shape alone proves nothing — only the server can tell a mint from a well-formed forgery —
// but a corrupt entry must start a fresh run rather than suppress acquisition until it expires.
expect(created?.hasFreshToken()).toBe(false);

created?.start();
serveInline(await injected(), { cid: created?.placeholders().cid });

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

it('reuses a mint whose version this build predates', async () => {
localStorage.setItem(
'__clerk_protect_st',
JSON.stringify({ token: 'v9.cached.mac', exp: nowSeconds() + 43_200, rid: 'b'.repeat(26) }),
);

// The shape check must not pin a version. The server may mint ahead of this build, and
// rejecting that here would re-run the loader on every page load until the SDK caught up.
const { session: created, elements } = session([loader()]);
expect(created?.hasFreshToken()).toBe(true);
created?.start();

await expect(created?.getRequestParams()).resolves.toMatchObject({ __clerk_protect_token: 'v9.cached.mac' });
expect(elements).toHaveLength(0);
});

it('reports nothing at all for a loader that carries no correlation id', async () => {
const { session: created, elements } = session([loader({ attributes: { 'data-pid': '{pid}' } })]);

Expand Down
12 changes: 11 additions & 1 deletion packages/clerk-js/src/core/protectSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ const MAX_TOKEN_TIMEOUT_MS = 10 * 1_000;
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. */
const MAX_TOKEN_LENGTH = 4_096;
/**
* The shape of a mint: `v<n>.<payload>.<mac>`, base64url. Version-agnostic on purpose — the server
* may mint a version this build predates, and only the server can judge a token either way.
*/
const TOKEN_SHAPE = /^v\d+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
/** How long a settled, tokenless acquisition is reused before a fresh run is allowed. */
const REACQUIRE_COOLDOWN_MS = 30 * 1_000;
/** Bounds we hold the server-supplied `retry_in_ms` to. */
Expand Down Expand Up @@ -245,9 +250,14 @@ function readStoredToken(key: string, marginMs: number): StoredToken | null {
/**
* The store is writable by anything running on the origin, so a value that could not have come
* from a mint of ours is discarded rather than trusted to suppress the loaders.
*
* The shape check is hygiene, not a security boundary: only the server 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 corrupt or truncated entry starts a fresh run immediately
* instead of suppressing acquisition until it expires.
*/
function validateToken(token: unknown, exp: unknown, marginMs: number): { token: string; exp: number } | null {
if (typeof token !== 'string' || !token || token.length > MAX_TOKEN_LENGTH) {
if (typeof token !== 'string' || token.length > MAX_TOKEN_LENGTH || !TOKEN_SHAPE.test(token)) {
return null;
}
if (typeof exp !== 'number' || !Number.isFinite(exp)) {
Expand Down
Loading