diff --git a/.changeset/sdk-140-refresh-scheduler.md b/.changeset/sdk-140-refresh-scheduler.md new file mode 100644 index 00000000000..237d19ad53d --- /dev/null +++ b/.changeset/sdk-140-refresh-scheduler.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Compute the proactive session-token refresh from the token's absolute expiry. A token restored from the session cookie on page load (issued before the tab opened) now schedules its background refresh ahead of expiry instead of after it, where previously the refresh could be scheduled past expiration and never fire proactively. diff --git a/packages/clerk-js/src/core/__tests__/refreshScheduler.test.ts b/packages/clerk-js/src/core/__tests__/refreshScheduler.test.ts new file mode 100644 index 00000000000..09499817744 --- /dev/null +++ b/packages/clerk-js/src/core/__tests__/refreshScheduler.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type Clock, createRefreshScheduler } from '../refreshScheduler'; + +// leeway = max(BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS=15, POLLER_INTERVAL/1000=5) = 15 +// refresh lead time = 2, so a token fires its refresh at expiresAt - now - 17. +const NOW = 1000; + +describe('createRefreshScheduler', () => { + let now: number; + const clock: Clock = { now: () => now }; + + beforeEach(() => { + now = NOW; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('fires onRefresh at expiresAt - now - 17 (43s for a fresh 60s token)', () => { + const scheduler = createRefreshScheduler(clock); + const onRefresh = vi.fn(); + scheduler.schedule('k', { expiresAt: now + 60, onExpire: vi.fn(), onRefresh }); + + vi.advanceTimersByTime(42 * 1000); + expect(onRefresh).not.toHaveBeenCalled(); + vi.advanceTimersByTime(2 * 1000); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('fires onExpire at expiresAt - now', () => { + const scheduler = createRefreshScheduler(clock); + const onExpire = vi.fn(); + scheduler.schedule('k', { expiresAt: now + 60, onExpire, onRefresh: vi.fn() }); + + vi.advanceTimersByTime(59 * 1000); + expect(onExpire).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1 * 1000); + expect(onExpire).toHaveBeenCalledTimes(1); + }); + + it('recomputes the refresh against the wall clock for a past-issuance token', () => { + // Token minted 30s ago with a 60s TTL: exp is only 30s in the future, so the + // refresh must fire at 30 - 17 = 13s, not at a fixed ttl - 17 = 43s. + const scheduler = createRefreshScheduler(clock); + const onRefresh = vi.fn(); + scheduler.schedule('k', { expiresAt: now + 30, onExpire: vi.fn(), onRefresh }); + + vi.advanceTimersByTime(12 * 1000); + expect(onRefresh).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1 * 1000); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('does not arm a refresh timer when the refresh point is already in the past', () => { + const scheduler = createRefreshScheduler(clock); + const onRefresh = vi.fn(); + const onExpire = vi.fn(); + // 10s TTL: refresh point is 10 - 17 = -7 < 0, so only the expiration timer arms. + scheduler.schedule('k', { expiresAt: now + 10, onExpire, onRefresh }); + + vi.advanceTimersByTime(60 * 1000); + expect(onRefresh).not.toHaveBeenCalled(); + expect(onExpire).toHaveBeenCalledTimes(1); + }); + + it('does not arm an expiration timer when the token is already expired', () => { + const scheduler = createRefreshScheduler(clock); + const onExpire = vi.fn(); + const onRefresh = vi.fn(); + scheduler.schedule('k', { expiresAt: now - 5, onExpire, onRefresh }); + + vi.advanceTimersByTime(60 * 1000); + expect(onExpire).not.toHaveBeenCalled(); + expect(onRefresh).not.toHaveBeenCalled(); + }); + + it('does not arm a refresh timer when onRefresh is omitted', () => { + const scheduler = createRefreshScheduler(clock); + const onExpire = vi.fn(); + scheduler.schedule('k', { expiresAt: now + 60, onExpire }); + + // Only the expiration timer should fire; nothing throws from a missing onRefresh. + vi.advanceTimersByTime(60 * 1000); + expect(onExpire).toHaveBeenCalledTimes(1); + }); + + it('cancel() disarms both timers for a key before they fire', () => { + const scheduler = createRefreshScheduler(clock); + const onExpire = vi.fn(); + const onRefresh = vi.fn(); + scheduler.schedule('k', { expiresAt: now + 60, onExpire, onRefresh }); + + scheduler.cancel('k'); + vi.advanceTimersByTime(120 * 1000); + expect(onExpire).not.toHaveBeenCalled(); + expect(onRefresh).not.toHaveBeenCalled(); + }); + + it('cancel() of an unknown key is a no-op', () => { + const scheduler = createRefreshScheduler(clock); + expect(() => scheduler.cancel('missing')).not.toThrow(); + }); + + it('cancelAll() disarms every key', () => { + const scheduler = createRefreshScheduler(clock); + const a = vi.fn(); + const b = vi.fn(); + scheduler.schedule('a', { expiresAt: now + 60, onExpire: a, onRefresh: a }); + scheduler.schedule('b', { expiresAt: now + 60, onExpire: b, onRefresh: b }); + + scheduler.cancelAll(); + vi.advanceTimersByTime(120 * 1000); + expect(a).not.toHaveBeenCalled(); + expect(b).not.toHaveBeenCalled(); + }); + + it('re-scheduling a key cancels the prior timers (no accumulation)', () => { + const scheduler = createRefreshScheduler(clock); + const first = vi.fn(); + const second = vi.fn(); + scheduler.schedule('k', { expiresAt: now + 60, onExpire: vi.fn(), onRefresh: first }); + scheduler.schedule('k', { expiresAt: now + 60, onExpire: vi.fn(), onRefresh: second }); + + vi.advanceTimersByTime(60 * 1000); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('cancelling one key leaves another key armed', () => { + const scheduler = createRefreshScheduler(clock); + const a = vi.fn(); + const b = vi.fn(); + scheduler.schedule('a', { expiresAt: now + 60, onExpire: vi.fn(), onRefresh: a }); + scheduler.schedule('b', { expiresAt: now + 60, onExpire: vi.fn(), onRefresh: b }); + + scheduler.cancel('a'); + vi.advanceTimersByTime(60 * 1000); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 41be211dc3f..3c941d4b682 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -1329,6 +1329,67 @@ describe('SessionTokenCache', () => { expect(backgroundRefresh).toHaveBeenCalledTimes(1); }); + it('a stale rejected resolver after overwrite does not cancel the replacement timers', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + const newToken = new Token({ id: 'stale-reject', jwt, object: 'token' }); + + const key = { tokenId: 'stale-reject' }; + const staleRefresh = vi.fn(); + const newRefresh = vi.fn(); + + // 1. First set() with a still-pending resolver that will later reject. + let rejectStale: (reason?: unknown) => void = () => {}; + const staleResolver = new Promise((_resolve, reject) => { + rejectStale = reject; + }); + SessionTokenCache.set({ ...key, tokenResolver: staleResolver, onRefresh: staleRefresh }); + + // 2. Overwrite with a resolved token; its refresh timer arms at 43s. + SessionTokenCache.set({ + ...key, + tokenResolver: Promise.resolve(newToken), + onRefresh: newRefresh, + }); + await Promise.resolve(); + + // 3. The stale resolver rejects AFTER the overwrite. Its .catch(deleteKey) must bail on the + // identity guard — it must not cancel the replacement's live timers nor evict its token. + rejectStale(new Error('stale token fetch failed')); + for (let i = 0; i < 5; i++) { + await Promise.resolve(); + } + + expect(SessionTokenCache.get(key)?.entry.tokenId).toBe('stale-reject'); + + vi.advanceTimersByTime(44 * 1000); + expect(staleRefresh).not.toHaveBeenCalled(); + expect(newRefresh).toHaveBeenCalledTimes(1); + }); + + it('schedules a cookie-hydrated (past-iat) token from its absolute expiry, not a full TTL out', async () => { + // Token minted 30s before this tab loaded: 60s TTL, but exp is only 30s away. + // The proactive refresh must fire at exp - now - 17 = 13s (recomputed against the + // wall clock), not at the old relative ttl - 17 = 43s, which would land past expiry + // and never fire proactively. Drives the claims.exp -> absolute expiresAt wiring. + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds - 30, 60); + const token = new Token({ id: 'past-iat-token', jwt, object: 'token' }); + + const onRefresh = vi.fn(); + SessionTokenCache.set({ + tokenId: 'past-iat-token', + tokenResolver: Promise.resolve(token), + onRefresh, + }); + await Promise.resolve(); + + vi.advanceTimersByTime(12 * 1000); + expect(onRefresh).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1 * 1000); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + it('cancels old expiration timer when set() is called again for the same key', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt1 = createJwtWithTtl(nowSeconds, 30); diff --git a/packages/clerk-js/src/core/refreshScheduler.ts b/packages/clerk-js/src/core/refreshScheduler.ts new file mode 100644 index 00000000000..8ece6d57b24 --- /dev/null +++ b/packages/clerk-js/src/core/refreshScheduler.ts @@ -0,0 +1,126 @@ +/** + * Owns the per-token timers for the session token cache: an expiration-cleanup + * timer and a proactive background-refresh timer. Keeping the scheduling out of + * the storage layer lets the cache deal in opaque keys and makes timer behavior + * independently testable through an injected clock. + * + * Timers are still backed by `setTimeout`; the injected clock only supplies + * `now()` so the fire times can be recomputed against the wall clock (a token + * issued before the tab loaded refreshes at its true expiry, not relative to + * when it was cached). + */ + +import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller'; + +/** + * Seconds before token expiration to trigger a proactive background refresh. + * Sized to absorb timer jitter, SafeLock contention (~5s), and network latency. + */ +const BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS = 15; + +/** + * Seconds of buffer before the leeway window so a refresh completes before the + * old token enters leeway. Token fetches typically finish in ~100ms; 2s is ample. + */ +const REFRESH_LEAD_TIME_IN_SECONDS = 2; + +/** + * Source of the current time, in seconds since the UNIX epoch. Injected so timer + * fire points are deterministic in tests; defaults to the wall clock. + */ +export interface Clock { + now(): number; +} + +/** The production wall clock, in seconds since the UNIX epoch. */ +export const systemClock: Clock = { now: () => Date.now() / 1000 }; + +interface ScheduleParams { + /** Absolute expiry of the token, in seconds since the UNIX epoch (JWT `exp`). */ + expiresAt: number; + /** Invoked when the expiration-cleanup timer fires. */ + onExpire: () => void; + /** Invoked when the proactive-refresh timer fires. Omit to skip the refresh timer. */ + onRefresh?: () => void; +} + +export interface RefreshScheduler { + /** + * Arms the expiration and proactive-refresh timers for a key, cancelling any + * prior timers for that key first. Delays are recomputed against the clock, so + * an already-expired or past-issuance token arms only the timers that are still + * in the future. + */ + schedule(key: string, params: ScheduleParams): void; + /** Cancels both timers for a single key. */ + cancel(key: string): void; + /** Cancels every key's timers (for cache `clear()`). */ + cancelAll(): void; +} + +interface TimerHandles { + expirationTimer?: ReturnType; + refreshTimer?: ReturnType; +} + +// Teach ClerkJS not to block the exit of the event loop in Node environments. +// https://nodejs.org/api/timers.html#timeoutunref +const armTimer = (callback: () => void, delayMs: number): ReturnType => { + const id = setTimeout(callback, delayMs); + if (typeof (id as any).unref === 'function') { + (id as any).unref(); + } + return id; +}; + +const clearHandles = (handles: TimerHandles) => { + if (handles.expirationTimer !== undefined) { + clearTimeout(handles.expirationTimer); + } + if (handles.refreshTimer !== undefined) { + clearTimeout(handles.refreshTimer); + } +}; + +/** + * Creates a {@link RefreshScheduler} bound to a {@link Clock} (defaults to the wall clock). + */ +export const createRefreshScheduler = (clock: Clock = systemClock): RefreshScheduler => { + const timers = new Map(); + + const cancel = (key: string) => { + const handles = timers.get(key); + if (!handles) { + return; + } + clearHandles(handles); + timers.delete(key); + }; + + const cancelAll = () => { + timers.forEach(clearHandles); + timers.clear(); + }; + + const schedule = (key: string, { expiresAt, onExpire, onRefresh }: ScheduleParams) => { + cancel(key); + + const now = clock.now(); + const handles: TimerHandles = {}; + + const expirationDelay = (expiresAt - now) * 1000; + if (expirationDelay > 0) { + handles.expirationTimer = armTimer(onExpire, expirationDelay); + } + + const leeway = Math.max(BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, POLLER_INTERVAL_IN_MS / 1000); + const refreshDelay = (expiresAt - now - leeway - REFRESH_LEAD_TIME_IN_SECONDS) * 1000; + if (refreshDelay > 0 && onRefresh) { + handles.refreshTimer = armTimer(() => onRefresh(), refreshDelay); + } + + timers.set(key, handles); + }; + + return { schedule, cancel, cancelAll }; +}; diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 217a89b1b04..afe7813e9fb 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -5,6 +5,7 @@ import { TokenId } from '@/utils/tokenId'; import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller'; import { createKeyResolver, type TokenCacheKeyJSON } from './keyResolver'; +import { type Clock, createRefreshScheduler, systemClock } from './refreshScheduler'; import { Token } from './resources/internal'; import { pickFreshestJwt } from './tokenFreshness'; import { createTokenStore } from './tokenStore'; @@ -40,16 +41,13 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { type Seconds = number; /** - * Internal cache value containing the entry, expiration metadata, and timers. + * Internal cache value containing the entry and expiration metadata. Timers are + * owned by the {@link RefreshScheduler}, keyed by the same cache key. */ interface TokenCacheValue { createdAt: Seconds; entry: TokenCacheEntry; expiresIn?: Seconds; - /** Timer for automatic cache cleanup when token expires */ - timeoutId?: ReturnType; - /** Timer for proactive refresh before token enters leeway period */ - refreshTimeoutId?: ReturnType; } /** @@ -98,17 +96,6 @@ export interface TokenCache { size(): number; } -/** - * Default seconds before token expiration to trigger background refresh. - * This threshold accounts for timer jitter, SafeLock contention (~5s), network latency, - * and tolerance for missed poller ticks. - * - * Users can customize this value: - * - Lower values (min: 5s) delay background refresh until closer to expiration - * - Higher values trigger earlier background refresh but may cause more frequent requests - */ -const BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS = 15; - const BROADCAST = { broadcast: true }; const NO_BROADCAST = { broadcast: false }; @@ -133,9 +120,10 @@ const generateTabId = (): string => { * Automatically manages token expiration and cleanup via scheduled timeouts. * BroadcastChannel support is enabled whenever the environment provides it. */ -const MemoryTokenCache = (prefix?: string): TokenCache => { +const MemoryTokenCache = (prefix?: string, clock: Clock = systemClock): TokenCache => { const store = createTokenStore(); const keyResolver = createKeyResolver(prefix); + const scheduler = createRefreshScheduler(clock); const tabId = generateTabId(); let broadcastChannel: BroadcastChannel | null = null; @@ -160,14 +148,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { ensureBroadcastChannel(); const clear = () => { - store.forEach(value => { - if (value.timeoutId !== undefined) { - clearTimeout(value.timeoutId); - } - if (value.refreshTimeoutId !== undefined) { - clearTimeout(value.refreshTimeoutId); - } - }); + scheduler.cancelAll(); store.clear(); }; @@ -181,19 +162,14 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { return; } - const nowSeconds = Math.floor(Date.now() / 1000); + const nowSeconds = Math.floor(clock.now()); const elapsed = nowSeconds - value.createdAt; const remainingTtl = (value.expiresIn ?? Infinity) - elapsed; // Token expired or dangerously close to expiration - force synchronous refresh // Uses poller interval as threshold since the poller might not get to it in time if (remainingTtl <= POLLER_INTERVAL_IN_MS / 1000) { - if (value.timeoutId !== undefined) { - clearTimeout(value.timeoutId); - } - if (value.refreshTimeoutId !== undefined) { - clearTimeout(value.refreshTimeoutId); - } + scheduler.cancel(key); store.delete(key); return; } @@ -310,26 +286,21 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { tokenId: entry.tokenId, }); - // Clear timers from any existing entry for this key to prevent orphaned + // Cancel timers from any existing entry for this key to prevent orphaned // refresh timers from accumulating across set() calls (e.g., from // #hydrateCache during _updateClient AND #refreshTokenInBackground). - const existing = store.get(key); - clearTimeout(existing?.timeoutId); - clearTimeout(existing?.refreshTimeoutId); + scheduler.cancel(key); - const nowSeconds = Math.floor(Date.now() / 1000); + const nowSeconds = Math.floor(clock.now()); const createdAt = entry.createdAt ?? nowSeconds; const value: TokenCacheValue = { createdAt, entry, expiresIn: undefined }; + // Cancel inside the identity guard: deleteKey is also the resolver chain's + // .catch handler, so a stale rejected resolver must not cancel a newer + // entry's live timers. const deleteKey = () => { - const cachedValue = store.get(key); - if (cachedValue === value) { - if (cachedValue.timeoutId !== undefined) { - clearTimeout(cachedValue.timeoutId); - } - if (cachedValue.refreshTimeoutId !== undefined) { - clearTimeout(cachedValue.refreshTimeoutId); - } + if (store.get(key) === value) { + scheduler.cancel(key); store.delete(key); } }; @@ -361,35 +332,11 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { value.createdAt = issuedAt; value.expiresIn = expiresIn; - const timeoutId = setTimeout(deleteKey, expiresIn * 1000); - value.timeoutId = timeoutId; - - // Teach ClerkJS not to block the exit of the event loop when used in Node environments. - // More info at https://nodejs.org/api/timers.html#timeoutunref - if (typeof (timeoutId as any).unref === 'function') { - (timeoutId as any).unref(); - } - - // Schedule proactive refresh timer to fire before token enters leeway period - // This ensures new tokens are ready before the old one expires - // refreshLeadTime: 2s buffer before leeway starts. Token fetches typically complete in ~100ms, - // so 2s provides ample margin for the refresh to complete before the token enters the leeway period. - const refreshLeadTime = 2; - const minLeeway = POLLER_INTERVAL_IN_MS / 1000; // Minimum is poller interval (5s) - const leeway = Math.max(BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, minLeeway); - const refreshFireTime = expiresIn - leeway - refreshLeadTime; - - if (refreshFireTime > 0 && entry.onRefresh) { - const refreshTimeoutId = setTimeout(() => { - entry.onRefresh?.(); - }, refreshFireTime * 1000); - - value.refreshTimeoutId = refreshTimeoutId; - - if (typeof (refreshTimeoutId as any).unref === 'function') { - (refreshTimeoutId as any).unref(); - } - } + // Arm the expiration-cleanup and proactive-refresh timers. Fire points are + // recomputed against the wall clock from the absolute expiry, so a token + // issued before this tab loaded refreshes at its true expiry rather than + // a full TTL from now. + scheduler.schedule(key, { expiresAt, onExpire: deleteKey, onRefresh: entry.onRefresh }); const channel = broadcastChannel; if (channel && options.broadcast) { diff --git a/packages/clerk-js/src/core/tokenStore.ts b/packages/clerk-js/src/core/tokenStore.ts index 523141a068c..99853c30d3a 100644 --- a/packages/clerk-js/src/core/tokenStore.ts +++ b/packages/clerk-js/src/core/tokenStore.ts @@ -11,10 +11,7 @@ export interface TokenStore { set(key: string, value: V): void; delete(key: string): void; clear(): void; - /** - * Iterates over every stored entry. Used by the cache to release per-entry - * timers before clearing. - */ + /** Iterates over every stored entry. */ forEach(callback: (value: V, key: string) => void): void; size(): number; }