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
5 changes: 5 additions & 0 deletions .changeset/sdk-140-refresh-scheduler.md
Original file line number Diff line number Diff line change
@@ -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.
144 changes: 144 additions & 0 deletions packages/clerk-js/src/core/__tests__/refreshScheduler.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
61 changes: 61 additions & 0 deletions packages/clerk-js/src/core/__tests__/tokenCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TokenResource>((_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<TokenResource>(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<TokenResource>(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);
Expand Down
126 changes: 126 additions & 0 deletions packages/clerk-js/src/core/refreshScheduler.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout>;
refreshTimer?: ReturnType<typeof setTimeout>;
}

// 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<typeof setTimeout> => {
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<string, TimerHandles>();

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 };
};
Loading
Loading