diff --git a/.changeset/token-cache-device-clock.md b/.changeset/token-cache-device-clock.md new file mode 100644 index 00000000000..5adf740d8b6 --- /dev/null +++ b/.changeset/token-cache-device-clock.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +SessionTokenCache measures token age on the device clock, so a slow client clock no longer keeps serving a session token after it expired on the server. diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index 27c0a458401..dd77ee30c3a 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -277,7 +277,7 @@ describe('SessionTokenCache', () => { await broadcastListener(olderEvent); const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); expect(resultAfterOlder).toBeDefined(); - expect(resultAfterOlder?.entry.createdAt).toBe(1666648190); + expect(resultAfterOlder?.entry.createdAt).toBe(1666648260); const newerEvent: MessageEvent = { data: { @@ -294,7 +294,7 @@ describe('SessionTokenCache', () => { const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); expect(resultAfterNewer).toBeDefined(); - expect(resultAfterNewer?.entry.createdAt).toBe(1666648250); + expect(resultAfterNewer?.entry.createdAt).toBe(1666648260); }); it('ignores a broadcast staler than the reconciled resolvedToken even when the resolver is staler', async () => { @@ -555,9 +555,8 @@ describe('SessionTokenCache', () => { expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); - // createdAt reflects high's iat (1666648250), not low's (1666648190): the slot's TTL is stamped - // from the published winner. A slot stamped with low's earlier iat would already be evicted at - // this point; one stamped with high's iat is still comfortably within its 120s TTL. + // TTL is the winner's lifetime (exp - iat) measured from cache time on the + // device clock, so 50s later the 120s token is still served. vi.advanceTimersByTime(50 * 1000); expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); }); @@ -714,6 +713,29 @@ describe('SessionTokenCache', () => { expect(result?.entry.tokenId).toBe('soon_expired_token'); }); + it('does not serve a server-expired token when the device clock is behind', async () => { + const serverNow = 1_700_000_000; + const deviceLag = 40; + vi.setSystemTime(new Date((serverNow - deviceLag) * 1000)); + + const iat = serverNow; + const exp = serverNow + 60; + const jwt = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify({ iat, exp }))}.signature`; + const tokenResolver = Promise.resolve({ + getRawString: () => jwt, + jwt: { claims: { exp, iat } }, + } as any); + + SessionTokenCache.set({ tokenId: 'skew_token', tokenResolver }); + await tokenResolver; + + vi.advanceTimersByTime(50 * 1000); + expect(SessionTokenCache.get({ tokenId: 'skew_token' })).toBeDefined(); + + vi.advanceTimersByTime(20 * 1000); + expect(SessionTokenCache.get({ tokenId: 'skew_token' })).toBeUndefined(); + }); + it('returns token when expiresAt is undefined (promise not yet resolved)', () => { // Create a promise that never resolves const pendingTokenResolver = new Promise(() => {}); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 4c67ca3aad6..58f8224b7d5 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -292,7 +292,6 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { setInternal( { - createdAt: iat, tokenId: data.tokenId, tokenResolver: Promise.resolve(token), }, @@ -328,6 +327,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { const nowSeconds = Math.floor(Date.now() / 1000); const createdAt = entry.createdAt ?? nowSeconds; + entry.createdAt = createdAt; const value: TokenCacheValue = { createdAt, entry, expiresIn: undefined }; // Chain the freshest known token across replacements. This never touches @@ -402,13 +402,12 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { const expiresAt = winnerClaims.exp; const issuedAt = winnerClaims.iat; const expiresIn: Seconds = expiresAt - issuedAt; - // Timers run relative to now, while createdAt/expiresIn describe the token's - // real validity window for get(). An aged winner (alive for part of its - // lifetime already) must be evicted and refreshed by its real expiry, not a - // full lifetime from now. - const remainingTtl: Seconds = expiresAt - Math.floor(Date.now() / 1000); + // Measure elapsed time on the device clock from when we cached the + // entry (`live.createdAt`), not from JWT `iat`. Mixing server `iat` + // with `Date.now()` serves expired tokens when the device runs behind. + const nowSeconds = Math.floor(Date.now() / 1000); + const remainingTtl: Seconds = expiresIn - (nowSeconds - live.createdAt); - live.createdAt = issuedAt; live.expiresIn = expiresIn; if (remainingTtl <= 0) {