diff --git a/.changeset/token-cache-expiry-on-token.md b/.changeset/token-cache-expiry-on-token.md new file mode 100644 index 00000000000..1d26cd64772 --- /dev/null +++ b/.changeset/token-cache-expiry-on-token.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix `getToken()` returning an expired session token when the device clock runs behind and a token is cached again, or a staler token arrives after a fresher one. diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index acced559aa1..b3a20218c45 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -51,6 +51,14 @@ const tick = async () => { const makeToken = (raw: string, id = 'session_123') => new Token({ id, jwt: raw, object: 'token' }) as TokenResource; +const deferred = () => { + let resolve!: (token: TokenResource) => void; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +}; + describe('SessionTokenCache', () => { let mockBroadcastChannel: { addEventListener: ReturnType; @@ -429,21 +437,13 @@ describe('SessionTokenCache', () => { describe('same-tab monotonic resolve', () => { const tokenId = 'session_123'; - const deferred = () => { - let resolve!: (token: TokenResource) => void; - const promise = new Promise(r => { - resolve = r; - }); - return { promise, resolve }; - }; - it('keeps the fresher token when a staler set resolves into the slot after it', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); // A fresher token is cached and resolved, then a staler set() replaces the slot and resolves. // The staler resolve reconciles against the previous token (carried onto the new slot's - // baseline at set time) and loses. + // freshest token at set time) and loses. SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) }); @@ -536,8 +536,8 @@ describe('SessionTokenCache', () => { // Two concurrent sets, both pending. The first (fresher) is replaced by the second (staler) // before either resolves. When the fresher one resolves it is now foreign, so it only raises - // the live slot's baseline, leaving resolvedToken undefined. The staler live resolve then - // reconciles against that baseline and the slot publishes the fresher token. + // the live slot's freshest token, leaving resolvedToken undefined. The staler live resolve then + // reconciles against that freshest token and the slot publishes the fresher token. const dHigh = deferred(); const dLow = deferred(); SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise }); @@ -564,7 +564,7 @@ describe('SessionTokenCache', () => { const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); // Inverse ordering of the previous test: the staler set is replaced by the fresher one before - // either resolves. The staler foreign resolve lands first and only advances the baseline; the + // either resolves. The staler foreign resolve lands first and only advances the freshest token; the // fresher live resolve then publishes high directly. const dLow = deferred(); const dHigh = deferred(); @@ -588,7 +588,7 @@ describe('SessionTokenCache', () => { // The live slot (d2) resolves staler first and publishes low. The replaced resolver (d1) // resolves fresher afterward. Because the slot is no longer pending, the fresher foreign token // advances resolvedToken to high and re-derives the slot's timers, rather than only raising the - // baseline. + // freshest token. const dHigh = deferred(); const dLow = deferred(); SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise }); @@ -606,22 +606,22 @@ describe('SessionTokenCache', () => { expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); }); - it('never exposes the baseline while a replacement is pending, then publishes high once it resolves', async () => { + it('never exposes the freshest token while a replacement is pending, then publishes high once it resolves', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); - // The first set resolves to high, so the slot's resolvedToken and baseline are both high. + // The first set resolves to high, so the slot's resolvedToken and freshest token are both high. SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); - // A replacement whose resolver never settles must not expose the carried baseline: the slot + // A replacement whose resolver never settles must not expose the carried freshest token: the slot // reads as pending (resolvedToken undefined) so callers keep awaiting the in-flight fetch. const pending = deferred(); SessionTokenCache.set({ tokenId, tokenResolver: pending.promise }); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); - // When it finally resolves staler, the own resolve reconciles against the baseline and the slot + // When it finally resolves staler, the own resolve reconciles against the freshest token and the slot // publishes high. pending.resolve(makeToken(lowRaw)); await tick(); @@ -824,6 +824,155 @@ describe('SessionTokenCache', () => { vi.advanceTimersByTime(11 * 1000); expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); }); + + it('keeps the fresher token expiry when it is carried into a later slot', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + const stalerRaw = createJwtWithOiat(deviceNow, deviceNow, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + vi.advanceTimersByTime(30 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(stalerRaw)) }); + await tick(); + + vi.advanceTimersByTime(20 * 1000); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + + vi.advanceTimersByTime(6 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('does not extend expiry when the same token is cached again as a new Token', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + vi.advanceTimersByTime(30 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + vi.advanceTimersByTime(20 * 1000); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + + vi.advanceTimersByTime(6 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('does not extend expiry when the same token is received by broadcast after a local cache', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + vi.advanceTimersByTime(30 * 1000); + await broadcastListener({ + data: { organizationId: null, sessionId: tokenId, tokenId, tokenRaw: freshRaw, traceId: 'skew' }, + } as MessageEvent); + await tick(); + + vi.advanceTimersByTime(20 * 1000); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + + vi.advanceTimersByTime(6 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('counts a replaced resolver token lifetime from its own set, not the live slot', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + const stalerRaw = createJwtWithOiat(deviceNow, deviceNow, 60); + const dFresh = deferred(); + const dStaler = deferred(); + + SessionTokenCache.set({ tokenId, tokenResolver: dFresh.promise }); + + vi.advanceTimersByTime(10 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: dStaler.promise }); + + dFresh.resolve(makeToken(freshRaw)); + await tick(); + dStaler.resolve(makeToken(stalerRaw)); + await tick(); + + vi.advanceTimersByTime(40 * 1000); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + + vi.advanceTimersByTime(6 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('gives a fresher token with a different raw string its own lifetime', async () => { + const firstRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + const fresherRaw = createJwtWithOiat(deviceNow + 30 + deviceLag, deviceNow + 30 + deviceLag, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(firstRaw)) }); + await tick(); + + vi.advanceTimersByTime(30 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(fresherRaw)) }); + await tick(); + + vi.advanceTimersByTime(26 * 1000); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(fresherRaw); + + vi.advanceTimersByTime(30 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('fires the proactive refresh from the carried token expiry, not the later slot', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + const stalerRaw = createJwtWithOiat(deviceNow, deviceNow, 60); + const onRefresh = vi.fn(); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)), onRefresh }); + await tick(); + + vi.advanceTimersByTime(30 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(stalerRaw)), onRefresh }); + await tick(); + + vi.advanceTimersByTime(12 * 1000); + expect(onRefresh).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1 * 1000); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('takes the earlier expiry when a replaced resolver delivers the same token after the slot resolved', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + const dFirst = deferred(); + + SessionTokenCache.set({ tokenId, tokenResolver: dFirst.promise }); + + vi.advanceTimersByTime(10 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + dFirst.resolve(makeToken(freshRaw)); + await tick(); + + vi.advanceTimersByTime(40 * 1000); + expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + + vi.advanceTimersByTime(6 * 1000); + expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); + }); + + it('keeps a pending slot when the token carried into it is close to expiry', async () => { + const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); + + SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); + await tick(); + + vi.advanceTimersByTime(50 * 1000); + SessionTokenCache.set({ tokenId, tokenResolver: deferred().promise }); + + vi.advanceTimersByTime(8 * 1000); + const result = SessionTokenCache.get({ tokenId }); + expect(result).toBeDefined(); + expect(result?.entry.resolvedToken).toBeUndefined(); + }); }); describe('broadcast sending', () => { diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index bb04327332f..d5edee93df3 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -34,22 +34,27 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { type Seconds = number; +interface TokenWithExpiry { + expiresAt: Seconds; + token: TokenResource; +} + /** * Internal cache value containing the entry, expiration metadata, and timers. */ interface TokenCacheValue { /** - * Freshest claims-valid token observed for this key, chained across set() calls - * and updated by every resolver settle, including resolvers replaced by a newer - * set() while still pending. Internal bookkeeping only: readers only ever see - * entry.resolvedToken, so a pending entry still reads as pending and callers + * Freshest claims-valid token observed for this key, with its expiry fixed when + * the cache first saw it for as long as the cache holds it, chained across set() + * calls and updated by every resolver settle, including resolvers replaced by a + * newer set() while still pending. Internal bookkeeping only: readers only ever + * see entry.resolvedToken, so a pending entry still reads as pending and callers * await its resolver. Folded into resolvedToken when the live entry resolves, * which is what keeps a staler resolve from overwriting a fresher token. */ - baseline?: TokenResource; + freshestKnown?: TokenWithExpiry; cachedAt: Seconds; entry: TokenCacheEntry; - expiresAt?: Seconds; /** Timer for automatic cache cleanup when token expires */ timeoutId?: ReturnType; /** Timer for proactive refresh before token enters leeway period */ @@ -132,6 +137,16 @@ const generateTabId = (): string => { return Math.random().toString(36).slice(2); }; +const pickFreshest = (existing: TokenWithExpiry | undefined, incoming: TokenWithExpiry): TokenWithExpiry => { + if (!existing) { + return incoming; + } + if (existing.token.getRawString() === incoming.token.getRawString()) { + return existing.expiresAt <= incoming.expiresAt ? existing : incoming; + } + return pickFreshestJwt(existing.token, incoming.token) === existing.token ? existing : incoming; +}; + /** * Creates an in-memory token cache with optional BroadcastChannel synchronization across tabs. * Automatically manages token expiration and cleanup via scheduled timeouts. @@ -185,7 +200,9 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { return; } - const remainingTtl = (value.expiresAt ?? Infinity) - Math.floor(Date.now() / 1000); + const hasResolvedToken = value.entry.resolvedToken !== undefined; + const expiresAt = hasResolvedToken && value.freshestKnown ? value.freshestKnown.expiresAt : Infinity; + const remainingTtl = expiresAt - Math.floor(Date.now() / 1000); // 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 @@ -322,8 +339,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { // Chain the freshest known token across replacements. This never touches // entry.resolvedToken: a pending entry reads as pending and callers await. - const prior = existing?.entry.resolvedToken; - value.baseline = prior ? pickFreshestJwt(existing?.baseline, prior) : existing?.baseline; + value.freshestKnown = existing?.freshestKnown; // Clears both timers and drops the slot, but only if it still holds `target` // (a newer set() may have replaced it while a promise/timer was pending). @@ -352,7 +368,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { if (isOwn && !isValid) { // The live slot's own fetch resolved unusable: drop the slot so the next - // read refetches. Keeping the baseline alive here would hide a broken + // read refetches. Keeping the freshest token alive here would hide a broken // token endpoint behind cache hits. dropIfCurrent(live); return; @@ -361,41 +377,38 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { return; } + // `exp` is server time, so reading it on the device clock is late by the + // device's lag. A full lifetime from cache insert is late by the token's age + // on arrival. The earlier of the two is late only by the smaller error. + const candidate: TokenWithExpiry = { + expiresAt: Math.min(claims.exp, value.cachedAt + claims.exp - claims.iat), + token: newToken, + }; + // Track the freshest token seen for this key, even when this resolver was // replaced by a newer set() while it was pending. - const baseline = pickFreshestJwt(live.baseline, newToken); - live.baseline = baseline; + const previous = live.freshestKnown; + const winner = pickFreshest(previous, candidate); + live.freshestKnown = winner; // resolvedToken is only written once the live slot itself resolves. While // the live slot is pending, readers must keep awaiting its resolver, so a - // replaced resolver may only advance the baseline above. - if (!isOwn && live.entry.resolvedToken === undefined) { + // replaced resolver may only advance the freshest token above. + const hasResolvedToken = live.entry.resolvedToken !== undefined; + if (!isOwn && !hasResolvedToken) { return; } - const winner = pickFreshestJwt(live.entry.resolvedToken, baseline); - if (winner === live.entry.resolvedToken) { + if (hasResolvedToken && winner === previous) { // Nothing advanced; the installed timers already match the winner. return; } - live.entry.resolvedToken = winner; - - const winnerClaims = winner.jwt?.claims; - if (!winnerClaims || typeof winnerClaims.exp !== 'number' || typeof winnerClaims.iat !== 'number') { - dropIfCurrent(live); - return; - } + live.entry.resolvedToken = winner.token; clearTimeout(live.timeoutId); clearTimeout(live.refreshTimeoutId); - // `exp` is server time, so reading it on the device clock is late by the - // device's lag. A full lifetime from cache insert is late by the token's age - // on arrival. The earlier of the two is late only by the smaller error. - const expiresAt: Seconds = Math.min(winnerClaims.exp, live.cachedAt + winnerClaims.exp - winnerClaims.iat); - const remainingTtl: Seconds = expiresAt - Math.floor(Date.now() / 1000); - - live.expiresAt = expiresAt; + const remainingTtl: Seconds = winner.expiresAt - Math.floor(Date.now() / 1000); if (remainingTtl <= 0) { dropIfCurrent(live); @@ -437,8 +450,9 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { // Best-effort side effect: a broadcast failure (e.g. postMessage racing a // channel close) must not reach the outer catch and evict the cached token (SDK-119). try { - const tokenRaw = winner.getRawString(); - if (tokenRaw && winnerClaims.sid) { + const tokenRaw = winner.token.getRawString(); + const winnerClaims = winner.token.jwt?.claims; + if (tokenRaw && winnerClaims?.sid) { const sessionId = winnerClaims.sid; const organizationId = winnerClaims.org_id || (winnerClaims.o as any)?.id; const template = TokenId.extractTemplate(entry.tokenId, sessionId, organizationId);