From c42cb1d836c1488f8071ce6b1cd943d3a6bf2c6a Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 1 Jul 2025 16:32:30 +0300 Subject: [PATCH 1/3] test(clerk-js): Add dynamic TTL calculation tests for JWT expiration handling --- .../src/core/__tests__/tokenCache.spec.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.spec.ts b/packages/clerk-js/src/core/__tests__/tokenCache.spec.ts index cca0d8e84a1..aecef4f1d93 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.spec.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.spec.ts @@ -16,6 +16,30 @@ vi.mock('../resources/Base', () => { const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NzU4NzY3OTAsImRhdGEiOiJmb29iYXIiLCJpYXQiOjE2NzU4NzY3MzB9.Z1BC47lImYvaAtluJlY-kBo0qOoAk42Xb-gNrB2SxJg'; +// Helper function to create JWT with custom exp and iat values using the same structure as the working JWT +function createJwtWithTtl(ttlSeconds: number): string { + // Use the existing JWT as template + const baseJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NzU4NzY3OTAsImRhdGEiOiJmb29iYXIiLCJpYXQiOjE2NzU4NzY3MzB9.Z1BC47lImYvaAtluJlY-kBo0qOoAk42Xb-gNrB2SxJg'; + const [headerB64, , signature] = baseJwt.split('.'); + + // Use the same iat as the original working JWT to maintain consistency with test environment + // Original JWT: iat: 1675876730, exp: 1675876790 (60 second TTL) + const baseIat = 1675876730; + const payload = { + exp: baseIat + ttlSeconds, + data: 'foobar', // Keep same data as original + iat: baseIat, + }; + + // Encode the new payload using base64url encoding (like JWT standard) + const payloadString = JSON.stringify(payload); + // Use proper base64url encoding: standard base64 but replace + with -, / with _, and remove padding = + const newPayloadB64 = btoa(payloadString).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + + return `${headerB64}.${newPayloadB64}.${signature}`; +} + describe('MemoryTokenCache', () => { beforeAll(() => { vi.useFakeTimers(); @@ -163,4 +187,79 @@ describe('MemoryTokenCache', () => { expect(cache.get(key, 0)).toBeUndefined(); }); }); + + describe('dynamic TTL calculation', () => { + it('calculates expiresIn from JWT exp and iat claims and sets timeout based on calculated TTL', async () => { + const cache = SessionTokenCache; + + // Mock Date.now to return a fixed timestamp initially + const initialTime = 1675876730000; // Same as our JWT's iat in milliseconds + vi.spyOn(Date, 'now').mockImplementation(() => initialTime); + + // Test with a 30-second TTL + const shortTtlJwt = createJwtWithTtl(30); + const shortTtlToken = new Token({ + object: 'token', + id: 'short-ttl', + jwt: shortTtlJwt, + }); + + const shortTtlKey = { tokenId: 'short-ttl', audience: 'test' }; + const shortTtlResolver = Promise.resolve(shortTtlToken); + cache.set({ ...shortTtlKey, tokenResolver: shortTtlResolver }); + await shortTtlResolver; + + const cachedEntry = cache.get(shortTtlKey); + expect(cachedEntry).toMatchObject(shortTtlKey); + + // Advance both the timer and the mocked current time + const advanceBy = 31 * 1000; + vi.advanceTimersByTime(advanceBy); + vi.spyOn(Date, 'now').mockImplementation(() => initialTime + advanceBy); + + const cachedEntry2 = cache.get(shortTtlKey); + expect(cachedEntry2).toBeUndefined(); + }); + + it('handles tokens with TTL greater than 60 seconds correctly', async () => { + const cache = SessionTokenCache; + + // Mock Date.now to return a fixed timestamp initially + const initialTime = 1675876730000; // Same as our JWT's iat in milliseconds + vi.spyOn(Date, 'now').mockImplementation(() => initialTime); + + // Test with a 120-second TTL + const longTtlJwt = createJwtWithTtl(120); + const longTtlToken = new Token({ + object: 'token', + id: 'long-ttl', + jwt: longTtlJwt, + }); + + const longTtlKey = { tokenId: 'long-ttl', audience: 'test' }; + const longTtlResolver = Promise.resolve(longTtlToken); + cache.set({ ...longTtlKey, tokenResolver: longTtlResolver }); + await longTtlResolver; + + // Check token is cached initially + const cachedEntry = cache.get(longTtlKey); + expect(cachedEntry).toMatchObject(longTtlKey); + + // Advance 90 seconds - token should still be cached + const firstAdvance = 90 * 1000; + vi.advanceTimersByTime(firstAdvance); + vi.spyOn(Date, 'now').mockImplementation(() => initialTime + firstAdvance); + + const cachedEntryAfter90s = cache.get(longTtlKey); + expect(cachedEntryAfter90s).toMatchObject(longTtlKey); + + // Advance to 121 seconds - token should be removed + const secondAdvance = 31 * 1000; + vi.advanceTimersByTime(secondAdvance); + vi.spyOn(Date, 'now').mockImplementation(() => initialTime + firstAdvance + secondAdvance); + + const cachedEntryAfter121s = cache.get(longTtlKey); + expect(cachedEntryAfter121s).toBeUndefined(); + }); + }); }); From e964fcd1bd31c44b2374f903377e1581b4b1516c Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 1 Jul 2025 16:33:33 +0300 Subject: [PATCH 2/3] Create ten-kiwis-cry.md --- .changeset/ten-kiwis-cry.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changeset/ten-kiwis-cry.md diff --git a/.changeset/ten-kiwis-cry.md b/.changeset/ten-kiwis-cry.md new file mode 100644 index 00000000000..ec380ec43f2 --- /dev/null +++ b/.changeset/ten-kiwis-cry.md @@ -0,0 +1,3 @@ +--- +--- + From 98b07359e97e3e857bec8f8526256737f7b79a37 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Mon, 21 Jul 2025 13:09:59 +0300 Subject: [PATCH 3/3] resolve PR comments --- .../src/core/__tests__/tokenCache.spec.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.spec.ts b/packages/clerk-js/src/core/__tests__/tokenCache.spec.ts index aecef4f1d93..476e7622a22 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.spec.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.spec.ts @@ -1,5 +1,5 @@ import type { TokenResource } from '@clerk/types'; -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { Token } from '../resources/internal'; import { SessionTokenCache } from '../tokenCache'; @@ -189,12 +189,18 @@ describe('MemoryTokenCache', () => { }); describe('dynamic TTL calculation', () => { + let dateNowSpy: ReturnType; + + afterEach(() => { + dateNowSpy.mockRestore(); + }); + it('calculates expiresIn from JWT exp and iat claims and sets timeout based on calculated TTL', async () => { const cache = SessionTokenCache; // Mock Date.now to return a fixed timestamp initially const initialTime = 1675876730000; // Same as our JWT's iat in milliseconds - vi.spyOn(Date, 'now').mockImplementation(() => initialTime); + dateNowSpy = vi.spyOn(Date, 'now').mockImplementation(() => initialTime); // Test with a 30-second TTL const shortTtlJwt = createJwtWithTtl(30); @@ -215,7 +221,7 @@ describe('MemoryTokenCache', () => { // Advance both the timer and the mocked current time const advanceBy = 31 * 1000; vi.advanceTimersByTime(advanceBy); - vi.spyOn(Date, 'now').mockImplementation(() => initialTime + advanceBy); + dateNowSpy.mockImplementation(() => initialTime + advanceBy); const cachedEntry2 = cache.get(shortTtlKey); expect(cachedEntry2).toBeUndefined(); @@ -226,7 +232,7 @@ describe('MemoryTokenCache', () => { // Mock Date.now to return a fixed timestamp initially const initialTime = 1675876730000; // Same as our JWT's iat in milliseconds - vi.spyOn(Date, 'now').mockImplementation(() => initialTime); + dateNowSpy = vi.spyOn(Date, 'now').mockImplementation(() => initialTime); // Test with a 120-second TTL const longTtlJwt = createJwtWithTtl(120); @@ -248,7 +254,7 @@ describe('MemoryTokenCache', () => { // Advance 90 seconds - token should still be cached const firstAdvance = 90 * 1000; vi.advanceTimersByTime(firstAdvance); - vi.spyOn(Date, 'now').mockImplementation(() => initialTime + firstAdvance); + dateNowSpy.mockImplementation(() => initialTime + firstAdvance); const cachedEntryAfter90s = cache.get(longTtlKey); expect(cachedEntryAfter90s).toMatchObject(longTtlKey); @@ -256,7 +262,7 @@ describe('MemoryTokenCache', () => { // Advance to 121 seconds - token should be removed const secondAdvance = 31 * 1000; vi.advanceTimersByTime(secondAdvance); - vi.spyOn(Date, 'now').mockImplementation(() => initialTime + firstAdvance + secondAdvance); + dateNowSpy.mockImplementation(() => initialTime + firstAdvance + secondAdvance); const cachedEntryAfter121s = cache.get(longTtlKey); expect(cachedEntryAfter121s).toBeUndefined();