From 4e5e5acdc2647bc2ad621d69469cfe4b320dedaa Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sun, 18 Oct 2026 18:16:52 -0500 Subject: [PATCH 1/3] fix(client): stop the token refresh loop when the browser clock runs ahead getRefreshDelay now always applies the 15 s floor instead of returning 0 when a token is inside its expiry buffer, so a token shorter than the buffer refreshes every 15 s rather than in a zero-delay loop. Expiry is now judged on WorkOS's clock: each token minted by refreshAccessTokenAction records the browser's offset from its `iat` (measured from the request start, so latency errs toward refreshing early). Tokens from getAccessTokenAction never set the offset. A token timer that finds its token still fresh (the wall clock jumped, e.g. NTP corrected a fast clock) refreshes to re-measure instead of stalling. Fixes #128 Thanks to @MatiasVerdier for the report and the suggested patch. --- src/client/tokenStore.spec.ts | 276 +++++++++++++++++++++++++++++++++- src/client/tokenStore.ts | 34 +++-- 2 files changed, 296 insertions(+), 14 deletions(-) diff --git a/src/client/tokenStore.spec.ts b/src/client/tokenStore.spec.ts index 2533c27..3c985f1 100644 --- a/src/client/tokenStore.spec.ts +++ b/src/client/tokenStore.spec.ts @@ -263,21 +263,28 @@ describe('TokenStore', () => { }); describe('getRefreshDelay edge cases', () => { - it('returns 0 when token is within expiry buffer', async () => { + it('applies the 15 s floor when token is within expiry buffer', async () => { const currentTimeInSeconds = Math.floor(Date.now() / 1000); const nearExpiryPayload = { sub: 'user_123', sid: 'session_123', - iat: currentTimeInSeconds - 100, - exp: currentTimeInSeconds + 30, // Within 60-second buffer + iat: currentTimeInSeconds, + exp: currentTimeInSeconds + 25, // Lifetime shorter than the 30-second buffer }; const nearExpiryToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(nearExpiryPayload))}.mock-signature`; vi.mocked(refreshAccessTokenAction).mockResolvedValue(nearExpiryToken); await store.refreshToken(); - // The refresh should be scheduled immediately (delay ~0) expect(store.getSnapshot().token).toBe(nearExpiryToken); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); + + // The scheduled refresh waits for MIN_REFRESH_DELAY_SECONDS, not 0. + await vi.advanceTimersByTimeAsync(14_999); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(2); }); it('caps refresh delay at maximum value', async () => { @@ -410,4 +417,265 @@ describe('TokenStore', () => { localStore.reset(); }); }); + + // https://github.com/workos/authkit-tanstack-start/issues/128 + // Browser clock = the faked Date.now(). WorkOS clock = Date.now() - skew. + describe('refresh loop when the browser clock is skewed (#128)', () => { + const START = Date.UTC(2025, 9, 18, 12, 0, 0); + const LATENCY_MS = 150; + const TEN_MINUTES_MS = 10 * 60_000; + + const makeToken = (payload: Record) => + `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.sig`; + const workosNowSeconds = (skew: number) => Math.floor((Date.now() - skew * 1000) / 1000); + + function workosToken(skew: number, { lifetime = 300, ageSeconds = 0 } = {}) { + const iat = workosNowSeconds(skew) - ageSeconds; + return makeToken({ sub: 'user_123', sid: 'session_123', iat, exp: iat + lifetime }); + } + + // The server action: a ~150 ms round trip that mints a fresh token on WorkOS's clock. + function mintOnWorkosClock(skew: number, { lifetime = 300, mintAfterMs = LATENCY_MS, respondAfterMs = 0 } = {}) { + vi.mocked(refreshAccessTokenAction).mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, mintAfterMs)); + const token = workosToken(skew, { lifetime }); + if (respondAfterMs > 0) { + await new Promise((resolve) => setTimeout(resolve, respondAfterMs)); + } + return token; + }); + } + + // How long the store thinks a token minted right now on WorkOS's clock has left. + // 300 means the store's clock offset matches `skew`. + const perceivedLifetime = (skew: number) => store.parseToken(workosToken(skew))?.timeUntilExpiry; + + beforeEach(() => { + vi.setSystemTime(START); + store.subscribe(() => {}); + }); + + afterEach(() => { + store.reset(); + }); + + describe.each([0, 269, 271, 400, 1620, -600])('skew %i s', (skew) => { + it('makes exactly one refresh call in the first 5 s', async () => { + mintOnWorkosClock(skew); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(5_000); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); + }); + + it('refreshes once per 270 s over 10 minutes', async () => { + mintOnWorkosClock(skew); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(TEN_MINUTES_MS); + // t = 0, ~270 s, ~540 s + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(3); + }); + + it("judges expiry on WorkOS's clock after a refresh", async () => { + mintOnWorkosClock(skew); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + expect(perceivedLifetime(skew)).toBe(300); + expect(store.parseToken(store.getSnapshot().token)?.isExpiring).toBe(false); + }); + }); + + it('measures from the request start, so a slow response cannot push refreshes later', async () => { + // Minted 100 ms after the request, delivered 2.4 s later. + mintOnWorkosClock(1620, { mintAfterMs: 100, respondAfterMs: 2_400 }); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(2_500); + // Measuring on arrival would count the 2.4 s as skew and report 302. + expect(perceivedLifetime(1620)).toBe(300); + }); + + it('refreshes a token shorter than the buffer every 15 s instead of looping', async () => { + mintOnWorkosClock(0, { lifetime: 20 }); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(5_000); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(55_000); + // t = 0, ~15 s, ~30 s, ~45 s + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(4); + }); + + it('startup on a correct clock makes no extra refresh calls', async () => { + mintOnWorkosClock(0); + vi.mocked(getAccessTokenAction).mockResolvedValue(workosToken(0)); + void store.getAccessTokenSilently(); + await vi.advanceTimersByTimeAsync(5_000); + expect(refreshAccessTokenAction).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(TEN_MINUTES_MS - 5_000); + // Scheduled refreshes at ~270 s and ~540 s only. + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(2); + }); + + it.each([271, 1620])('startup on a clock %i s ahead costs one refresh, then the normal cadence', async (skew) => { + mintOnWorkosClock(skew); + vi.mocked(getAccessTokenAction).mockResolvedValue(workosToken(skew)); + void store.getAccessTokenSilently(); + await vi.advanceTimersByTimeAsync(5_000); + expect(getAccessTokenAction).toHaveBeenCalledTimes(1); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(TEN_MINUTES_MS - 5_000); + // t = 0, ~270 s, ~540 s + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(3); + }); + + it('does not measure skew from tokens served by getAccessTokenAction', async () => { + mintOnWorkosClock(0); + // Correct clock; the session's current token was minted 120 s ago. + vi.mocked(getAccessTokenAction).mockResolvedValue(workosToken(0, { ageSeconds: 120 })); + await store.getAccessTokenSilently(); + expect(perceivedLifetime(0)).toBe(300); + + // 180 s left, so the refresh is due at 150 s (not 270 s, as it would be if its age counted as skew). + await vi.advanceTimersByTimeAsync(149_000); + expect(refreshAccessTokenAction).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(2_000); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); + }); + + it.each<[string, () => string]>([ + ['no iat', () => makeToken({ sub: 'user_123', sid: 'session_123', exp: workosNowSeconds(1620) + 300 })], + [ + 'a string iat', + () => makeToken({ sub: 'user_123', sid: 'session_123', iat: 'soon', exp: workosNowSeconds(1620) + 300 }), + ], + [ + 'a null iat', + () => makeToken({ sub: 'user_123', sid: 'session_123', iat: null, exp: workosNowSeconds(1620) + 300 }), + ], + ['an undecodable payload', () => 'header.not-base64-json.sig'], + ['an opaque token', () => 'opaque-token'], + ])('a minted token with %s leaves the offset unchanged', async (_label, mint) => { + mintOnWorkosClock(1620); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + expect(perceivedLifetime(1620)).toBe(300); + + vi.mocked(refreshAccessTokenAction).mockImplementation(async () => mint()); + await store.refreshToken(); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(2); + expect(perceivedLifetime(1620)).toBe(300); + }); + + it('with no iat to measure, a skewed clock falls back to the 15 s floor instead of looping', async () => { + vi.mocked(refreshAccessTokenAction).mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, LATENCY_MS)); + return makeToken({ sub: 'user_123', sid: 'session_123', exp: workosNowSeconds(1620) + 300 }); + }); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(60_000); + // t = 0, ~15 s, ~30 s, ~45 s + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(4); + expect(perceivedLifetime(0)).toBe(300); + }); + + it("refreshes right away on getAccessToken() when the token is already expired on WorkOS's clock", async () => { + // A minted token that expires the moment it is issued (e.g. at the end of its session). + mintOnWorkosClock(1620, { lifetime: 0 }); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + expect(store.parseToken(store.getSnapshot().token)?.timeUntilExpiry).toBe(0); + + // The scheduled timer waits for the 15 s floor; an explicit caller does not. + mintOnWorkosClock(1620); + const pending = store.getAccessToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + await pending; + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(2); + expect(store.parseToken(store.getSnapshot().token)?.timeUntilExpiry).toBe(300); + }); + + it('a late timer (hidden-tab throttling) that finds the token expired refreshes once, then resumes the cadence', async () => { + mintOnWorkosClock(1620); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + + // Throttling holds the 270 s timer for an extra minute: the clock moves, the timer doesn't fire. + vi.setSystemTime(Date.now() + 60_000); + expect(store.parseToken(store.getSnapshot().token)?.timeUntilExpiry).toBe(240); + await vi.advanceTimersByTimeAsync(270_000); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(5_000); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(270_000); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(3); + }); + + it('dedupes concurrent refreshes into one call', async () => { + mintOnWorkosClock(1620); + const results = Promise.all([store.refreshToken(), store.getAccessToken(), store.getAccessTokenSilently()]); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + const [a, b, c] = await results; + expect(a).toBe(b); + expect(b).toBe(c); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); + expect(perceivedLifetime(1620)).toBe(300); + }); + + it("keeps the offset across clearToken() and judges the next session's startup token with it", async () => { + mintOnWorkosClock(1620); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + + store.clearToken(); + expect(perceivedLifetime(1620)).toBe(300); + + // Next sign-in: the session's token is 100 s old, with 200 s left on WorkOS's clock. + vi.mocked(getAccessTokenAction).mockResolvedValue(workosToken(1620, { ageSeconds: 100 })); + await store.getAccessTokenSilently(); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); + expect(perceivedLifetime(1620)).toBe(300); + }); + + it('re-measures when the OS corrects the clock mid-session instead of stalling on a stale offset', async () => { + mintOnWorkosClock(1620); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + + // NTP fixes the 27-minute skew: the browser clock now matches WorkOS's. + vi.setSystemTime(Date.now() - 1620 * 1000); + mintOnWorkosClock(0); + + // The 270 s timer fires while the stale offset still makes the token look fresh. + await vi.advanceTimersByTimeAsync(270_000); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + expect(perceivedLifetime(0)).toBe(300); + + // Back to the normal cadence on the corrected clock. + await vi.advanceTimersByTimeAsync(270_000); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(3); + }); + + it('keeps the last offset when a refresh fails', async () => { + mintOnWorkosClock(1620); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + + vi.mocked(refreshAccessTokenAction).mockRejectedValue(new Error('Network error')); + await expect(store.refreshToken()).rejects.toThrow('Network error'); + expect(perceivedLifetime(1620)).toBe(300); + }); + + it('reset() forgets the offset', async () => { + mintOnWorkosClock(1620); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + expect(perceivedLifetime(1620)).toBe(300); + + store.reset(); + expect(perceivedLifetime(0)).toBe(300); + }); + }); }); diff --git a/src/client/tokenStore.ts b/src/client/tokenStore.ts index 8298915..64967c3 100644 --- a/src/client/tokenStore.ts +++ b/src/client/tokenStore.ts @@ -41,6 +41,8 @@ export class TokenStore { private listeners = new Set<() => void>(); private refreshPromise: Promise | null = null; private refreshTimeout: ReturnType | undefined; + // Browser clock minus WorkOS clock (seconds): refresh request start minus the minted token's `iat`. + private clockOffsetSeconds = 0; subscribe = (listener: () => void) => { this.listeners.add(listener); @@ -75,7 +77,11 @@ export class TokenStore { const delay = tokenData === undefined ? RETRY_DELAY_SECONDS * 1000 : this.getRefreshDelay(tokenData); this.refreshTimeout = setTimeout(() => { - void this.getAccessTokenSilently().catch(() => {}); + // A token timer that finds its token still fresh means the wall clock jumped: refresh to re-measure. + const current = this.parseToken(this.state.token); + const refresh = + tokenData && current && !current.isExpiring ? this.refreshTokenSilently() : this.getAccessTokenSilently(); + void refresh.catch(() => {}); }, delay); } @@ -87,11 +93,6 @@ export class TokenStore { totalTokenLifetime: number; }) { const bufferSeconds = getExpiryBuffer(totalTokenLifetime); - - if (timeUntilExpiry <= bufferSeconds) { - return 0; - } - const idealDelay = (timeUntilExpiry - bufferSeconds) * 1000; return Math.min(Math.max(idealDelay, MIN_REFRESH_DELAY_SECONDS * 1000), MAX_REFRESH_DELAY_SECONDS * 1000); @@ -102,7 +103,7 @@ export class TokenStore { try { const { payload } = decodeJwt(token); - const now = Math.floor(Date.now() / 1000); + const now = Math.floor(Date.now() / 1000) - this.clockOffsetSeconds; if (typeof payload.exp !== 'number') { return null; @@ -170,6 +171,18 @@ export class TokenStore { return this._refreshToken(false); } + private async fetchRefreshedToken(): Promise { + const requestedAt = Math.floor(Date.now() / 1000); + const token = await refreshAccessTokenAction(); + const iat = this.parseToken(token)?.payload.iat; + + if (typeof iat === 'number' && Number.isFinite(iat)) { + this.clockOffsetSeconds = requestedAt - iat; + } + + return token; + } + private async refreshTokenSilently(): Promise { return this._refreshToken(true); } @@ -192,7 +205,7 @@ export class TokenStore { let token: string | undefined; if (!silent) { - token = await refreshAccessTokenAction(); + token = await this.fetchRefreshedToken(); } else { if (!previousToken) { token = await getAccessTokenAction(); @@ -207,13 +220,13 @@ export class TokenStore { } if (!token || (tokenData && tokenData.isExpiring)) { - const refreshedToken = await refreshAccessTokenAction(); + const refreshedToken = await this.fetchRefreshedToken(); if (refreshedToken) { token = refreshedToken; } } } else { - token = await refreshAccessTokenAction(); + token = await this.fetchRefreshedToken(); } } @@ -251,6 +264,7 @@ export class TokenStore { reset() { this.state = { token: undefined, loading: false, error: null }; this.refreshPromise = null; + this.clockOffsetSeconds = 0; if (this.refreshTimeout) { clearTimeout(this.refreshTimeout); this.refreshTimeout = undefined; From 0e721b5d2fafe6910306a1b9f3c7ab9970074080 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sun, 18 Oct 2026 21:08:45 -0500 Subject: [PATCH 2/3] fix(client): drop the clock offset when no timer can re-check it clearToken() now resets the offset, so a clock corrected between sessions can't make the next session's token look fresh past its real expiry. The offset is also dropped when the store comes back after an idle gap with no listeners longer than the 15 s refresh floor (a wall-clock jump during the gap counts), while quick unmount/remount churn keeps it. Refs #128 --- src/client/tokenStore.spec.ts | 69 ++++++++++++++++++++++++++++++++--- src/client/tokenStore.ts | 17 +++++++-- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/client/tokenStore.spec.ts b/src/client/tokenStore.spec.ts index 3c985f1..2198031 100644 --- a/src/client/tokenStore.spec.ts +++ b/src/client/tokenStore.spec.ts @@ -450,9 +450,11 @@ describe('TokenStore', () => { // 300 means the store's clock offset matches `skew`. const perceivedLifetime = (skew: number) => store.parseToken(workosToken(skew))?.timeUntilExpiry; + let unsubscribe: () => void; + beforeEach(() => { vi.setSystemTime(START); - store.subscribe(() => {}); + unsubscribe = store.subscribe(() => {}); }); afterEach(() => { @@ -623,21 +625,78 @@ describe('TokenStore', () => { expect(perceivedLifetime(1620)).toBe(300); }); - it("keeps the offset across clearToken() and judges the next session's startup token with it", async () => { + it('clearToken() forgets the offset, so a clock corrected between sessions schedules from the real expiry', async () => { mintOnWorkosClock(1620); void store.refreshToken(); await vi.advanceTimersByTimeAsync(LATENCY_MS); + store.clearToken(); + + // Between sessions, NTP fixes the 27-minute skew. + vi.setSystemTime(Date.now() - 1620 * 1000); + mintOnWorkosClock(0); + + // Next sign-in: a fresh 300 s token from the session. + vi.mocked(getAccessTokenAction).mockResolvedValue(workosToken(0)); + await store.getAccessTokenSilently(); + expect(store.parseToken(store.getSnapshot().token)?.timeUntilExpiry).toBe(300); + // Refresh due at 270 s (real expiry - buffer), not 1,890 s. + await vi.advanceTimersByTimeAsync(269_000); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1_000); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(2); + }); + + it('after clearToken() on a still-skewed clock, the next session pays one startup refresh and re-measures', async () => { + mintOnWorkosClock(1620); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); store.clearToken(); + + vi.mocked(getAccessTokenAction).mockResolvedValue(workosToken(1620)); + void store.getAccessTokenSilently(); + await vi.advanceTimersByTimeAsync(5_000); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(2); expect(perceivedLifetime(1620)).toBe(300); + }); + + it('keeps the offset across a quick unmount and remount', async () => { + mintOnWorkosClock(1620); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + + unsubscribe(); + await vi.advanceTimersByTimeAsync(1_000); + store.subscribe(() => {}); - // Next sign-in: the session's token is 100 s old, with 200 s left on WorkOS's clock. - vi.mocked(getAccessTokenAction).mockResolvedValue(workosToken(1620, { ageSeconds: 100 })); + expect(perceivedLifetime(1620)).toBe(300); await store.getAccessTokenSilently(); expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); - expect(perceivedLifetime(1620)).toBe(300); }); + it.each([5, 400])( + 'forgets the offset when the clock is corrected during a %i s idle gap with no listeners', + async (idleSeconds) => { + mintOnWorkosClock(1620); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + + // Every listener unmounts, cancelling the timer that would have re-measured the offset. + unsubscribe(); + await vi.advanceTimersByTimeAsync(idleSeconds * 1000); + vi.setSystemTime(Date.now() - 1620 * 1000); + mintOnWorkosClock(0); + store.subscribe(() => {}); + + // The stored token is judged by its real expiry, not 1,620 s later. + expect(store.parseToken(store.getSnapshot().token)?.timeUntilExpiry).toBe(300 - idleSeconds); + const pending = store.getAccessToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + await pending; + expect(perceivedLifetime(0)).toBe(300); + }, + ); + it('re-measures when the OS corrects the clock mid-session instead of stalling on a stale offset', async () => { mintOnWorkosClock(1620); void store.refreshToken(); diff --git a/src/client/tokenStore.ts b/src/client/tokenStore.ts index 64967c3..659e06e 100644 --- a/src/client/tokenStore.ts +++ b/src/client/tokenStore.ts @@ -43,14 +43,23 @@ export class TokenStore { private refreshTimeout: ReturnType | undefined; // Browser clock minus WorkOS clock (seconds): refresh request start minus the minted token's `iat`. private clockOffsetSeconds = 0; + private idleSince: number | undefined; subscribe = (listener: () => void) => { + // Idle longer than the refresh floor (|gap| also counts clock jumps) leaves the offset unchecked: drop it. + if (this.idleSince !== undefined && Math.abs(Date.now() - this.idleSince) > MIN_REFRESH_DELAY_SECONDS * 1000) { + this.clockOffsetSeconds = 0; + } + this.idleSince = undefined; this.listeners.add(listener); return () => { this.listeners.delete(listener); - if (this.listeners.size === 0 && this.refreshTimeout) { - clearTimeout(this.refreshTimeout); - this.refreshTimeout = undefined; + if (this.listeners.size === 0) { + this.idleSince = Date.now(); + if (this.refreshTimeout) { + clearTimeout(this.refreshTimeout); + this.refreshTimeout = undefined; + } } }; }; @@ -132,6 +141,7 @@ export class TokenStore { } clearToken() { + this.clockOffsetSeconds = 0; this.setState({ token: undefined, error: null, loading: false }); if (this.refreshTimeout) { clearTimeout(this.refreshTimeout); @@ -265,6 +275,7 @@ export class TokenStore { this.state = { token: undefined, loading: false, error: null }; this.refreshPromise = null; this.clockOffsetSeconds = 0; + this.idleSince = undefined; if (this.refreshTimeout) { clearTimeout(this.refreshTimeout); this.refreshTimeout = undefined; From 49a1a4b49e3bfc5ce4dcc2e2760d675b921c10c6 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Sun, 18 Oct 2026 21:18:43 -0500 Subject: [PATCH 3/3] fix(client): refresh a token retained through an idle gap before serving it Dropping the clock offset after an idle gap made a token kept through the gap look fresh again on a clock that is still behind WorkOS (600 s behind, 400 s gap: expired 100 s ago, judged valid for 500 s more). When the offset is dropped, the retained token is now marked unverified and both getters refresh it before handing it out. Gaps under the 15 s floor keep the offset and make no extra refresh. Refs #128 --- src/client/tokenStore.spec.ts | 29 +++++++++++++++++++++++++++++ src/client/tokenStore.ts | 17 +++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/client/tokenStore.spec.ts b/src/client/tokenStore.spec.ts index 2198031..5db0a7b 100644 --- a/src/client/tokenStore.spec.ts +++ b/src/client/tokenStore.spec.ts @@ -693,10 +693,39 @@ describe('TokenStore', () => { const pending = store.getAccessToken(); await vi.advanceTimersByTimeAsync(LATENCY_MS); await pending; + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(2); expect(perceivedLifetime(0)).toBe(300); }, ); + it.each(['getAccessToken', 'getAccessTokenSilently'] as const)( + '%s refreshes a token retained through an idle gap on a clock 600 s behind instead of handing it out', + async (getter) => { + mintOnWorkosClock(-600); + void store.refreshToken(); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + const retained = store.getSnapshot().token; + + unsubscribe(); + await vi.advanceTimersByTimeAsync(400_000); + store.subscribe(() => {}); + + // Expired 100 s ago on WorkOS's clock, but valid for 500 s more once the offset is dropped. + expect(store.parseToken(retained)?.timeUntilExpiry).toBe(500); + const pending = store[getter](); + await vi.advanceTimersByTimeAsync(LATENCY_MS); + const served = await pending; + + expect(served).not.toBe(retained); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(2); + expect(store.parseToken(served)?.timeUntilExpiry).toBe(300); + + // Measured fresh: later calls hand out the new token without refreshing again. + await expect(store.getAccessToken()).resolves.toBe(served); + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(2); + }, + ); + it('re-measures when the OS corrects the clock mid-session instead of stalling on a stale offset', async () => { mintOnWorkosClock(1620); void store.refreshToken(); diff --git a/src/client/tokenStore.ts b/src/client/tokenStore.ts index 659e06e..fc98d23 100644 --- a/src/client/tokenStore.ts +++ b/src/client/tokenStore.ts @@ -44,11 +44,14 @@ export class TokenStore { // Browser clock minus WorkOS clock (seconds): refresh request start minus the minted token's `iat`. private clockOffsetSeconds = 0; private idleSince: number | undefined; + // A token retained through an idle gap; the getters refresh it before handing it out. + private unverifiedToken: string | undefined; subscribe = (listener: () => void) => { // Idle longer than the refresh floor (|gap| also counts clock jumps) leaves the offset unchecked: drop it. if (this.idleSince !== undefined && Math.abs(Date.now() - this.idleSince) > MIN_REFRESH_DELAY_SECONDS * 1000) { this.clockOffsetSeconds = 0; + this.unverifiedToken = this.state.token; } this.idleSince = undefined; this.listeners.add(listener); @@ -149,7 +152,15 @@ export class TokenStore { } } + private isUnverified(): boolean { + return this.state.token !== undefined && this.state.token === this.unverifiedToken; + } + async getAccessToken(): Promise { + if (this.isUnverified()) { + return this.refreshTokenSilently(); + } + const tokenData = this.parseToken(this.state.token); if (tokenData && !tokenData.isExpiring) { @@ -164,6 +175,10 @@ export class TokenStore { } async getAccessTokenSilently(): Promise { + if (this.isUnverified()) { + return this.refreshTokenSilently(); + } + const tokenData = this.parseToken(this.state.token); if (tokenData && !tokenData.isExpiring) { @@ -184,6 +199,7 @@ export class TokenStore { private async fetchRefreshedToken(): Promise { const requestedAt = Math.floor(Date.now() / 1000); const token = await refreshAccessTokenAction(); + this.unverifiedToken = undefined; const iat = this.parseToken(token)?.payload.iat; if (typeof iat === 'number' && Number.isFinite(iat)) { @@ -276,6 +292,7 @@ export class TokenStore { this.refreshPromise = null; this.clockOffsetSeconds = 0; this.idleSince = undefined; + this.unverifiedToken = undefined; if (this.refreshTimeout) { clearTimeout(this.refreshTimeout); this.refreshTimeout = undefined;