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
214 changes: 157 additions & 57 deletions packages/clerk-js/src/core/__tests__/tokenCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,9 @@ describe('SessionTokenCache', () => {
} as MessageEvent<SessionTokenEvent>;

broadcastListener(newerEvent);
const cachedEntryAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' });
expect(cachedEntryAfterNewer).toBeDefined();
const newerCreatedAt = cachedEntryAfterNewer?.createdAt;
const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' });
expect(resultAfterNewer).toBeDefined();
const newerCreatedAt = resultAfterNewer?.entry.createdAt;

// mockJwt has iat: 1666648250, so create an older one with iat: 1666648190 (60 seconds earlier)
const olderJwt =
Expand All @@ -226,9 +226,9 @@ describe('SessionTokenCache', () => {

broadcastListener(olderEvent);

const cachedEntryAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' });
expect(cachedEntryAfterOlder).toBeDefined();
expect(cachedEntryAfterOlder?.createdAt).toBe(newerCreatedAt);
const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' });
expect(resultAfterOlder).toBeDefined();
expect(resultAfterOlder?.entry.createdAt).toBe(newerCreatedAt);
});

it('successfully updates cache with valid token', () => {
Expand All @@ -245,9 +245,9 @@ describe('SessionTokenCache', () => {

broadcastListener(event);

const cachedEntry = SessionTokenCache.get({ tokenId: 'session_123' });
expect(cachedEntry).toBeDefined();
expect(cachedEntry?.tokenId).toBe('session_123');
const result = SessionTokenCache.get({ tokenId: 'session_123' });
expect(result).toBeDefined();
expect(result?.entry.tokenId).toBe('session_123');
});

it('does not re-broadcast when receiving a broadcast message', async () => {
Expand All @@ -271,8 +271,8 @@ describe('SessionTokenCache', () => {
await Promise.resolve();

// Verify cache was updated
const cachedEntry = SessionTokenCache.get({ tokenId: 'session_123' });
expect(cachedEntry).toBeDefined();
const result = SessionTokenCache.get({ tokenId: 'session_123' });
expect(result).toBeDefined();

// Critical: postMessage should NOT be called when handling a broadcast
expect(mockBroadcastChannel.postMessage).not.toHaveBeenCalled();
Expand Down Expand Up @@ -331,9 +331,10 @@ describe('SessionTokenCache', () => {
// Wait for promise to resolve
await tokenResolver;

const cachedEntry = SessionTokenCache.get({ tokenId: 'future_token' });
expect(cachedEntry).toBeDefined();
expect(cachedEntry?.tokenId).toBe('future_token');
const result = SessionTokenCache.get({ tokenId: 'future_token' });
expect(result).toBeDefined();
expect(result?.entry.tokenId).toBe('future_token');
expect(result?.needsRefresh).toBe(false);
});

it('removes token when it has already expired based on duration', async () => {
Expand All @@ -351,11 +352,11 @@ describe('SessionTokenCache', () => {

await tokenResolver;

const cachedEntry = SessionTokenCache.get({ tokenId: 'expired_token' });
expect(cachedEntry).toBeUndefined();
const result = SessionTokenCache.get({ tokenId: 'expired_token' });
expect(result).toBeUndefined();
});

it('removes token when it expires within the leeway threshold', async () => {
it('returns token with needsRefresh when remaining TTL is less than leeway (SWR)', async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const iat = nowSeconds;
const exp = iat + 20;
Expand All @@ -366,12 +367,16 @@ describe('SessionTokenCache', () => {
jwt: { claims: { exp, iat } },
} as any);

SessionTokenCache.set({ createdAt: nowSeconds - 13, tokenId: 'soon_expired_token', tokenResolver });
// Token has 20s TTL, created 11s ago = 9s remaining (< 10s default leeway)
SessionTokenCache.set({ createdAt: nowSeconds - 11, tokenId: 'soon_expired_token', tokenResolver });

await tokenResolver;

const cachedEntry = SessionTokenCache.get({ tokenId: 'soon_expired_token' });
expect(cachedEntry).toBeUndefined();
// SWR: Token is still valid (9s > 0), so it should be returned with needsRefresh=true
const result = SessionTokenCache.get({ tokenId: 'soon_expired_token' });
expect(result).toBeDefined();
expect(result?.entry.tokenId).toBe('soon_expired_token');
expect(result?.needsRefresh).toBe(true);
});

it('returns token when expiresAt is undefined (promise not yet resolved)', () => {
Expand All @@ -380,9 +385,9 @@ describe('SessionTokenCache', () => {

SessionTokenCache.set({ tokenId: 'pending_token', tokenResolver: pendingTokenResolver });

const cachedEntry = SessionTokenCache.get({ tokenId: 'pending_token' });
expect(cachedEntry).toBeDefined();
expect(cachedEntry?.tokenId).toBe('pending_token');
const result = SessionTokenCache.get({ tokenId: 'pending_token' });
expect(result).toBeDefined();
expect(result?.entry.tokenId).toBe('pending_token');
});
});

Expand Down Expand Up @@ -471,7 +476,7 @@ describe('SessionTokenCache', () => {
SessionTokenCache.set({ ...key, tokenResolver });
await tokenResolver;

expect(SessionTokenCache.get(key)).toBeDefined();
expect(SessionTokenCache.get(key)?.entry).toBeDefined();
expect(SessionTokenCache.size()).toBe(1);

SessionTokenCache.clear();
Expand Down Expand Up @@ -512,56 +517,142 @@ describe('SessionTokenCache', () => {

SessionTokenCache.set({ ...key, tokenResolver });

const cachedWhilePending = SessionTokenCache.get(key);
expect(cachedWhilePending).toBeDefined();
expect(cachedWhilePending?.tokenId).toBe('lifecycle-token');
const resultWhilePending = SessionTokenCache.get(key);
expect(resultWhilePending).toBeDefined();
expect(resultWhilePending?.entry.tokenId).toBe('lifecycle-token');
expect(isResolved).toBe(false);

vi.advanceTimersByTime(100);
await tokenResolver;

const cachedAfterResolved = SessionTokenCache.get(key);
const resultAfterResolved = SessionTokenCache.get(key);
expect(isResolved).toBe(true);
expect(cachedAfterResolved).toBeDefined();
expect(cachedAfterResolved?.tokenId).toBe('lifecycle-token');
expect(resultAfterResolved).toBeDefined();
expect(resultAfterResolved?.entry.tokenId).toBe('lifecycle-token');

vi.advanceTimersByTime(60 * 1000);

const cachedAfterExpiration = SessionTokenCache.get(key);
expect(cachedAfterExpiration).toBeUndefined();
const resultAfterExpiration = SessionTokenCache.get(key);
expect(resultAfterExpiration).toBeUndefined();
});
});

describe('leeway precision', () => {
it('includes 5 second sync leeway on top of default 10 second leeway', async () => {
describe('SWR leeway behavior', () => {
it('returns needsRefresh=false when token has plenty of time remaining', async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const jwt = createJwtWithTtl(nowSeconds, 60);

const token = new Token({
id: 'leeway-token',
id: 'fresh-token',
jwt,
object: 'token',
});

const tokenResolver = Promise.resolve<TokenResource>(token);
const key = { audience: 'leeway-test', tokenId: 'leeway-token' };
const key = { audience: 'fresh-test', tokenId: 'fresh-token' };

SessionTokenCache.set({ ...key, tokenResolver });
await tokenResolver;

expect(SessionTokenCache.get(key)).toMatchObject({ tokenId: 'leeway-token' });
// Token just created, 60s remaining - should return needsRefresh=false
const result = SessionTokenCache.get(key);
expect(result?.entry.tokenId).toBe('fresh-token');
expect(result?.needsRefresh).toBe(false);
});

vi.advanceTimersByTime(44 * 1000);
expect(SessionTokenCache.get(key)).toBeDefined();
it('returns needsRefresh=true when token is within default leeway (SWR)', async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const jwt = createJwtWithTtl(nowSeconds, 60);

vi.advanceTimersByTime(1 * 1000);
expect(SessionTokenCache.get(key)).toBeDefined();
const token = new Token({
id: 'expiring-token',
jwt,
object: 'token',
});

vi.advanceTimersByTime(1 * 1000);
expect(SessionTokenCache.get(key)).toBeUndefined();
const tokenResolver = Promise.resolve<TokenResource>(token);
const key = { audience: 'expiring-test', tokenId: 'expiring-token' };

SessionTokenCache.set({ ...key, tokenResolver });
await tokenResolver;

// At 49s elapsed, 11s remaining - fresh, no refresh needed
vi.advanceTimersByTime(49 * 1000);
let result = SessionTokenCache.get(key);
expect(result?.entry.tokenId).toBe('expiring-token');
expect(result?.needsRefresh).toBe(false);

// At 51s elapsed, 9s remaining (< 10s leeway) - SWR: return token with needsRefresh=true
vi.advanceTimersByTime(2 * 1000);
result = SessionTokenCache.get(key);
expect(result?.entry.tokenId).toBe('expiring-token');
expect(result?.needsRefresh).toBe(true);

// At 60s elapsed, 0s remaining - token actually expired, return undefined
vi.advanceTimersByTime(9 * 1000);
result = SessionTokenCache.get(key);
expect(result).toBeUndefined();
});

it('returns needsRefresh=true only once per token (prevents duplicate refreshes)', async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const jwt = createJwtWithTtl(nowSeconds, 60);

const token = new Token({
id: 'dedupe-token',
jwt,
object: 'token',
});

const tokenResolver = Promise.resolve<TokenResource>(token);
const key = { audience: 'dedupe-test', tokenId: 'dedupe-token' };

SessionTokenCache.set({ ...key, tokenResolver });
await tokenResolver;

// Advance to within leeway
vi.advanceTimersByTime(51 * 1000); // 9s remaining

// First call: needsRefresh=true
let result = SessionTokenCache.get(key);
expect(result?.needsRefresh).toBe(true);

// Second call: needsRefresh=false (already marked for refresh)
result = SessionTokenCache.get(key);
expect(result?.entry.tokenId).toBe('dedupe-token');
expect(result?.needsRefresh).toBe(false);
});

it('enforces minimum 5 second sync leeway even when leeway is set to 0', async () => {
it('honors larger custom leeway values', async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const jwt = createJwtWithTtl(nowSeconds, 60);

const token = new Token({
id: 'custom-leeway-token',
jwt,
object: 'token',
});

const tokenResolver = Promise.resolve<TokenResource>(token);
const key = { audience: 'custom-leeway-test', tokenId: 'custom-leeway-token' };

SessionTokenCache.set({ ...key, tokenResolver });
await tokenResolver;

// At 29s elapsed, 31s remaining - fresh with 30s leeway
vi.advanceTimersByTime(29 * 1000);
let result = SessionTokenCache.get(key, 30);
expect(result?.entry.tokenId).toBe('custom-leeway-token');
expect(result?.needsRefresh).toBe(false);

// At 31s elapsed, 29s remaining (< 30s leeway) - needs refresh
vi.advanceTimersByTime(2 * 1000);
result = SessionTokenCache.get(key, 30);
expect(result?.entry.tokenId).toBe('custom-leeway-token');
expect(result?.needsRefresh).toBe(true);
});

it('enforces minimum 5 second leeway even when leeway is set to 0', async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const jwt = createJwtWithTtl(nowSeconds, 60);

Expand All @@ -577,13 +668,22 @@ describe('SessionTokenCache', () => {
SessionTokenCache.set({ ...key, tokenResolver });
await tokenResolver;

expect(SessionTokenCache.get(key, 0)).toMatchObject({ tokenId: 'zero-leeway-token' });

// At 54s elapsed, 6s remaining - still fresh with min 5s leeway
vi.advanceTimersByTime(54 * 1000);
expect(SessionTokenCache.get(key, 0)).toBeDefined();
let result = SessionTokenCache.get(key, 0);
expect(result?.entry.tokenId).toBe('zero-leeway-token');
expect(result?.needsRefresh).toBe(false);

// At 56s elapsed, 4s remaining (< 5s min leeway) - needs refresh
vi.advanceTimersByTime(2 * 1000);
expect(SessionTokenCache.get(key, 0)).toBeUndefined();
result = SessionTokenCache.get(key, 0);
expect(result?.entry.tokenId).toBe('zero-leeway-token');
expect(result?.needsRefresh).toBe(true);

// At 60s elapsed, 0s remaining - actually expired
vi.advanceTimersByTime(4 * 1000);
result = SessionTokenCache.get(key, 0);
expect(result).toBeUndefined();
});
});

Expand All @@ -604,7 +704,7 @@ describe('SessionTokenCache', () => {
SessionTokenCache.set({ ...key, tokenResolver });
await tokenResolver;

expect(SessionTokenCache.get(key)).toBeDefined();
expect(SessionTokenCache.get(key)?.entry).toBeDefined();

vi.advanceTimersByTime(30 * 1000);

Expand All @@ -627,10 +727,10 @@ describe('SessionTokenCache', () => {
SessionTokenCache.set({ ...key, tokenResolver });
await tokenResolver;

expect(SessionTokenCache.get(key)).toBeDefined();
expect(SessionTokenCache.get(key)?.entry).toBeDefined();

vi.advanceTimersByTime(90 * 1000);
expect(SessionTokenCache.get(key)).toBeDefined();
expect(SessionTokenCache.get(key)?.entry).toBeDefined();

vi.advanceTimersByTime(30 * 1000);
expect(SessionTokenCache.get(key)).toBeUndefined();
Expand All @@ -656,7 +756,7 @@ describe('SessionTokenCache', () => {
SessionTokenCache.set({ tokenId: label, tokenResolver });
await tokenResolver;

expect(SessionTokenCache.get({ tokenId: label })).toBeDefined();
expect(SessionTokenCache.get({ tokenId: label })?.entry).toBeDefined();

vi.advanceTimersByTime(ttl * 1000);
expect(SessionTokenCache.get({ tokenId: label })).toBeUndefined();
Expand Down Expand Up @@ -684,9 +784,9 @@ describe('SessionTokenCache', () => {
SessionTokenCache.set({ ...keyWithAudience, tokenResolver });
await tokenResolver;

const cached = SessionTokenCache.get(keyWithAudience);
expect(cached).toBeDefined();
expect(cached?.audience).toBe('https://api.example.com');
const result = SessionTokenCache.get(keyWithAudience);
expect(result).toBeDefined();
expect(result?.entry.audience).toBe('https://api.example.com');
});

it('treats tokens with different audiences as separate entries', async () => {
Expand All @@ -709,8 +809,8 @@ describe('SessionTokenCache', () => {
await Promise.all([resolver1, resolver2]);

expect(SessionTokenCache.size()).toBe(2);
expect(SessionTokenCache.get(key1)).toBeDefined();
expect(SessionTokenCache.get(key2)).toBeDefined();
expect(SessionTokenCache.get(key1)?.entry).toBeDefined();
expect(SessionTokenCache.get(key2)?.entry).toBeDefined();
});
});

Expand Down
5 changes: 3 additions & 2 deletions packages/clerk-js/src/core/auth/SessionCookiePoller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { createWorkerTimers } from '@clerk/shared/workerTimers';
import { SafeLock } from './safeLock';

const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken';
const INTERVAL_IN_MS = 5 * 1_000;

export const POLLER_INTERVAL_IN_MS = 5 * 1_000;

export class SessionCookiePoller {
private lock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY);
Expand All @@ -20,7 +21,7 @@ export class SessionCookiePoller {
const run = async () => {
this.initiated = true;
await this.lock.acquireLockAndRun(cb);
this.timerId = this.workerTimers.setTimeout(run, INTERVAL_IN_MS);
this.timerId = this.workerTimers.setTimeout(run, POLLER_INTERVAL_IN_MS);
};

void run();
Expand Down
Loading