From c7733f76e6a59c8c0faf8217a7160446e3cd1bc6 Mon Sep 17 00:00:00 2001 From: GCdM <59828466+GCdM@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:28:49 +0100 Subject: [PATCH 1/2] add failing test to illustrate issue --- src/client/tokenStore.spec.ts | 36 +++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/client/tokenStore.spec.ts b/src/client/tokenStore.spec.ts index b8f22c9..d75d516 100644 --- a/src/client/tokenStore.spec.ts +++ b/src/client/tokenStore.spec.ts @@ -330,4 +330,40 @@ describe('TokenStore', () => { expect(snapshot.error?.message).toBe('string error'); }); }); + + describe('refresh schedule buffer', () => { + it('scheduled refresh fires a real refresh while the token is in the isExpiring state', async () => { + const now = Math.floor(Date.now() / 1000); + const initialPayload = { + sub: 'user_123', + sid: 'session_123', + iat: now, + exp: now + 300, + }; + const initialToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(initialPayload))}.mock-signature`; + + const refreshedPayload = { + sub: 'user_123', + sid: 'session_123', + iat: now, + exp: now + 3600, + }; + const refreshedToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(refreshedPayload))}.mock-signature`; + + document.cookie = `workos-access-token=${encodeURIComponent(initialToken)}`; + vi.mocked(refreshAccessTokenAction).mockResolvedValue(refreshedToken); + + const localStore = new TokenStore(); + expect(refreshAccessTokenAction).not.toHaveBeenCalled(); + + // Advance past the originally scheduled fire time. If the schedule buffer + // and the isExpiring buffer disagree, the timer's callback will see + // isExpiring=false and silently no-op, leaving call count at 0. + await vi.advanceTimersByTimeAsync(300_000); + + expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); + + localStore.reset(); + }); + }); }); From ca9f2fd41a83737e3a0bffb456715b32dc59de7e Mon Sep 17 00:00:00 2001 From: GCdM <59828466+GCdM@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:31:29 +0100 Subject: [PATCH 2/2] implement short token expiry buffer --- src/client/tokenStore.ts | 43 +++++++++++++++++++----------- src/client/useAccessToken.spec.tsx | 1 + 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/client/tokenStore.ts b/src/client/tokenStore.ts index cc38002..d2a02f1 100644 --- a/src/client/tokenStore.ts +++ b/src/client/tokenStore.ts @@ -8,11 +8,19 @@ interface TokenState { } const TOKEN_EXPIRY_BUFFER_SECONDS = 60; +const SHORT_TOKEN_LIFETIME_SECONDS = 300; +const SHORT_TOKEN_EXPIRY_BUFFER_SECONDS = 30; const MIN_REFRESH_DELAY_SECONDS = 15; const MAX_REFRESH_DELAY_SECONDS = 24 * 60 * 60; const RETRY_DELAY_SECONDS = 300; const jwtCookieName = 'workos-access-token'; +function getExpiryBuffer(totalTokenLifetime: number): number { + return totalTokenLifetime <= SHORT_TOKEN_LIFETIME_SECONDS + ? SHORT_TOKEN_EXPIRY_BUFFER_SECONDS + : TOKEN_EXPIRY_BUFFER_SECONDS; +} + export class TokenStore { private state: TokenState; private serverSnapshot: TokenState; @@ -35,7 +43,7 @@ export class TokenStore { this.fastCookieConsumed = true; const tokenData = this.parseToken(initialToken); if (tokenData) { - this.scheduleRefresh(tokenData.timeUntilExpiry); + this.scheduleRefresh(tokenData); } } } @@ -69,26 +77,33 @@ export class TokenStore { this.notify(); } - private scheduleRefresh(timeUntilExpiry?: number) { + private scheduleRefresh(tokenData?: { timeUntilExpiry: number; totalTokenLifetime: number }) { if (this.refreshTimeout) { clearTimeout(this.refreshTimeout); this.refreshTimeout = undefined; } - const delay = - typeof timeUntilExpiry === 'undefined' ? RETRY_DELAY_SECONDS * 1000 : this.getRefreshDelay(timeUntilExpiry); + const delay = tokenData === undefined ? RETRY_DELAY_SECONDS * 1000 : this.getRefreshDelay(tokenData); this.refreshTimeout = setTimeout(() => { void this.getAccessTokenSilently().catch(() => {}); }, delay); } - private getRefreshDelay(timeUntilExpiry: number) { - if (timeUntilExpiry <= TOKEN_EXPIRY_BUFFER_SECONDS) { + private getRefreshDelay({ + timeUntilExpiry, + totalTokenLifetime, + }: { + timeUntilExpiry: number; + totalTokenLifetime: number; + }) { + const bufferSeconds = getExpiryBuffer(totalTokenLifetime); + + if (timeUntilExpiry <= bufferSeconds) { return 0; } - const idealDelay = (timeUntilExpiry - TOKEN_EXPIRY_BUFFER_SECONDS) * 1000; + const idealDelay = (timeUntilExpiry - bufferSeconds) * 1000; return Math.min(Math.max(idealDelay, MIN_REFRESH_DELAY_SECONDS * 1000), MAX_REFRESH_DELAY_SECONDS * 1000); } @@ -178,21 +193,17 @@ export class TokenStore { } const timeUntilExpiry = payload.exp - now; - - let bufferSeconds = TOKEN_EXPIRY_BUFFER_SECONDS; const totalTokenLifetime = payload.exp - (payload.iat || now); + const bufferSeconds = getExpiryBuffer(totalTokenLifetime); - if (totalTokenLifetime <= 300) { - bufferSeconds = 30; - } - - const isExpiring = payload.exp < now + bufferSeconds; + const isExpiring = payload.exp <= now + bufferSeconds; return { payload, expiresAt: payload.exp, isExpiring, timeUntilExpiry, + totalTokenLifetime, }; } catch { return null; @@ -240,7 +251,7 @@ export class TokenStore { const tokenData = this.parseToken(fastToken); if (tokenData) { - this.scheduleRefresh(tokenData.timeUntilExpiry); + this.scheduleRefresh(tokenData); } return fastToken; @@ -320,7 +331,7 @@ export class TokenStore { const tokenData = this.parseToken(token); if (tokenData) { - this.scheduleRefresh(tokenData.timeUntilExpiry); + this.scheduleRefresh(tokenData); } return token; diff --git a/src/client/useAccessToken.spec.tsx b/src/client/useAccessToken.spec.tsx index 2637fd5..95f3558 100644 --- a/src/client/useAccessToken.spec.tsx +++ b/src/client/useAccessToken.spec.tsx @@ -111,6 +111,7 @@ describe('useAccessToken', () => { expiresAt: 9999999999, isExpiring: false, timeUntilExpiry: 3600, + totalTokenLifetime: 3600, }); vi.mocked(tokenStore.getAccessTokenSilently).mockResolvedValue('test-token');