From 2aa831480c071fe51aa825744ffcd72f7f744b2a Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 13 May 2026 16:43:28 -0500 Subject: [PATCH 1/7] fix: deduplicate concurrent server-side token refresh calls When multiple server-function requests arrive concurrently with the same expired access token (e.g., TanStack Start firing 3 parallel loaders), each independently calls refreshTokens() with the same single-use refresh token. Only the first succeeds; the rest consume rate-limit quota or fail with TokenRefreshError. Add an in-flight Map to AuthKitCore.refreshTokens() keyed by refreshToken:organizationId. Concurrent callers share one promise; the map entry is cleared in finally on both success and failure. This mirrors the client-side refreshPromise dedup in tokenStore but operates per-process on the server. --- src/core/AuthKitCore.spec.ts | 174 +++++++++++++++++++++++++++++++++++ src/core/AuthKitCore.ts | 52 +++++++---- 2 files changed, 210 insertions(+), 16 deletions(-) diff --git a/src/core/AuthKitCore.spec.ts b/src/core/AuthKitCore.spec.ts index 0d15c7e..0d9643a 100644 --- a/src/core/AuthKitCore.spec.ts +++ b/src/core/AuthKitCore.spec.ts @@ -258,6 +258,180 @@ describe('AuthKitCore', () => { TokenRefreshError, ); }); + + it('deduplicates concurrent calls with the same refresh token', async () => { + const newJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInNpZCI6InNlc3Npb25fbmV3IiwiZXhwIjozMDAwMDAwMDAwfQ.sig'; + let callCount = 0; + const delayedClient = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + callCount++; + await new Promise((r) => setTimeout(r, 50)); + return { + accessToken: newJwt, + refreshToken: 'new-rt', + user: mockUser, + impersonator: undefined, + }; + }, + }, + }; + const testCore = new AuthKitCore( + mockConfig as any, + delayedClient as any, + mockEncryption as any, + ); + + const session = { + accessToken: 'expired-jwt', + refreshToken: 'rt-1', + user: mockUser, + impersonator: undefined, + }; + + const results = await Promise.all([ + testCore.validateAndRefresh(session), + testCore.validateAndRefresh(session), + testCore.validateAndRefresh(session), + ]); + + expect(callCount).toBe(1); + for (const r of results) { + expect(r.refreshed).toBe(true); + expect(r.session.accessToken).toBe(newJwt); + } + }); + + it('propagates errors to all concurrent waiters', async () => { + let callCount = 0; + const failingClient = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + callCount++; + await new Promise((r) => setTimeout(r, 50)); + throw new Error('Rate limit exceeded'); + }, + }, + }; + const testCore = new AuthKitCore( + mockConfig as any, + failingClient as any, + mockEncryption as any, + ); + + const session = { + accessToken: 'expired-jwt', + refreshToken: 'rt-1', + user: mockUser, + impersonator: undefined, + }; + + const results = await Promise.allSettled([ + testCore.validateAndRefresh(session), + testCore.validateAndRefresh(session), + testCore.validateAndRefresh(session), + ]); + + expect(callCount).toBe(1); + for (const r of results) { + expect(r.status).toBe('rejected'); + if (r.status === 'rejected') { + expect(r.reason).toBeInstanceOf(TokenRefreshError); + } + } + }); + + it('retries after a failed concurrent batch', async () => { + const newJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInNpZCI6InNlc3Npb25fbmV3IiwiZXhwIjozMDAwMDAwMDAwfQ.sig'; + let callCount = 0; + let shouldFail = true; + const retryClient = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + callCount++; + await new Promise((r) => setTimeout(r, 20)); + if (shouldFail) throw new Error('Temporary failure'); + return { + accessToken: newJwt, + refreshToken: 'new-rt', + user: mockUser, + impersonator: undefined, + }; + }, + }, + }; + const testCore = new AuthKitCore( + mockConfig as any, + retryClient as any, + mockEncryption as any, + ); + + const session = { + accessToken: 'expired-jwt', + refreshToken: 'rt-1', + user: mockUser, + impersonator: undefined, + }; + + // First batch: all fail, but only 1 API call + await Promise.allSettled([ + testCore.validateAndRefresh(session), + testCore.validateAndRefresh(session), + ]); + expect(callCount).toBe(1); + + // Second call: should retry fresh (map was cleared in finally) + shouldFail = false; + const result = await testCore.validateAndRefresh(session); + expect(callCount).toBe(2); + expect(result.refreshed).toBe(true); + expect(result.session.accessToken).toBe(newJwt); + }); + + it('deduplicates separately per organizationId', async () => { + const newJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInNpZCI6InNlc3Npb25fbmV3IiwiZXhwIjozMDAwMDAwMDAwfQ.sig'; + let callCount = 0; + const orgClient = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + callCount++; + await new Promise((r) => setTimeout(r, 50)); + return { + accessToken: newJwt, + refreshToken: 'new-rt', + user: mockUser, + impersonator: undefined, + }; + }, + }, + }; + const testCore = new AuthKitCore( + mockConfig as any, + orgClient as any, + mockEncryption as any, + ); + + const session = { + accessToken: 'expired-jwt', + refreshToken: 'rt-1', + user: mockUser, + impersonator: undefined, + }; + + await Promise.all([ + testCore.validateAndRefresh(session, { organizationId: 'org_a' }), + testCore.validateAndRefresh(session, { organizationId: 'org_b' }), + ]); + + expect(callCount).toBe(2); + }); }); describe('validateAndRefresh()', () => { diff --git a/src/core/AuthKitCore.ts b/src/core/AuthKitCore.ts index 6477c3d..4973c71 100644 --- a/src/core/AuthKitCore.ts +++ b/src/core/AuthKitCore.ts @@ -34,6 +34,15 @@ export class AuthKitCore { private client: WorkOS; private encryption: SessionEncryption; private clientId: string; + private inflightRefreshes = new Map< + string, + Promise<{ + accessToken: string; + refreshToken: string; + user: User; + impersonator: Impersonator | undefined; + }> + >(); constructor( config: AuthKitConfig, @@ -217,23 +226,34 @@ export class AuthKitCore { user: User; impersonator: Impersonator | undefined; }> { - try { - const result = - await this.client.userManagement.authenticateWithRefreshToken({ - refreshToken, - clientId: this.clientId, - organizationId, - }); + const key = `${refreshToken}:${organizationId ?? ''}`; + const existing = this.inflightRefreshes.get(key); + if (existing) return existing; - return { - accessToken: result.accessToken, - refreshToken: result.refreshToken, - user: result.user, - impersonator: result.impersonator, - }; - } catch (error) { - throw new TokenRefreshError('Failed to refresh tokens', error, context); - } + const promise = (async () => { + try { + const result = + await this.client.userManagement.authenticateWithRefreshToken({ + refreshToken, + clientId: this.clientId, + organizationId, + }); + + return { + accessToken: result.accessToken, + refreshToken: result.refreshToken, + user: result.user, + impersonator: result.impersonator, + }; + } catch (error) { + throw new TokenRefreshError('Failed to refresh tokens', error, context); + } finally { + this.inflightRefreshes.delete(key); + } + })(); + + this.inflightRefreshes.set(key, promise); + return promise; } /** From c53a1627ce92cbc44b001c374460dc4a66719f66 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 13 May 2026 17:04:14 -0500 Subject: [PATCH 2/7] refactor: extract RefreshResult type and test helpers - Extract RefreshResult type alias to deduplicate the Map field and method return type (prevents drift) - Hoist newJwt constant and makeExpiredSession() to file scope - Add makeCountingClient() helper to eliminate 4 near-identical mock client declarations in dedup tests - Remove shadowing newJwt redeclaration in validateAndRefresh block --- src/core/AuthKitCore.spec.ts | 166 +++++++++++++---------------------- src/core/AuthKitCore.ts | 24 ++--- 2 files changed, 68 insertions(+), 122 deletions(-) diff --git a/src/core/AuthKitCore.spec.ts b/src/core/AuthKitCore.spec.ts index 0d9643a..28875e5 100644 --- a/src/core/AuthKitCore.spec.ts +++ b/src/core/AuthKitCore.spec.ts @@ -44,6 +44,43 @@ const mockEncryption = { }), }; +const newJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInNpZCI6InNlc3Npb25fbmV3IiwiZXhwIjozMDAwMDAwMDAwfQ.sig'; + +function makeExpiredSession() { + return { + accessToken: 'expired-jwt', + refreshToken: 'rt-1', + user: mockUser, + impersonator: undefined, + }; +} + +function makeCountingClient(opts?: { + delayMs?: number; + fail?: () => boolean; +}) { + let callCount = 0; + const { delayMs = 50, fail } = opts ?? {}; + const client = { + userManagement: { + getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', + authenticateWithRefreshToken: async () => { + callCount++; + await new Promise((r) => setTimeout(r, delayMs)); + if (fail?.()) throw new Error('Refresh failed'); + return { + accessToken: newJwt, + refreshToken: 'new-rt', + user: mockUser, + impersonator: undefined, + }; + }, + }, + }; + return { client, getCallCount: () => callCount }; +} + describe('AuthKitCore', () => { let core: AuthKitCore; @@ -260,44 +297,21 @@ describe('AuthKitCore', () => { }); it('deduplicates concurrent calls with the same refresh token', async () => { - const newJwt = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInNpZCI6InNlc3Npb25fbmV3IiwiZXhwIjozMDAwMDAwMDAwfQ.sig'; - let callCount = 0; - const delayedClient = { - userManagement: { - getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', - authenticateWithRefreshToken: async () => { - callCount++; - await new Promise((r) => setTimeout(r, 50)); - return { - accessToken: newJwt, - refreshToken: 'new-rt', - user: mockUser, - impersonator: undefined, - }; - }, - }, - }; + const { client, getCallCount } = makeCountingClient(); const testCore = new AuthKitCore( mockConfig as any, - delayedClient as any, + client as any, mockEncryption as any, ); - const session = { - accessToken: 'expired-jwt', - refreshToken: 'rt-1', - user: mockUser, - impersonator: undefined, - }; - + const session = makeExpiredSession(); const results = await Promise.all([ testCore.validateAndRefresh(session), testCore.validateAndRefresh(session), testCore.validateAndRefresh(session), ]); - expect(callCount).toBe(1); + expect(getCallCount()).toBe(1); for (const r of results) { expect(r.refreshed).toBe(true); expect(r.session.accessToken).toBe(newJwt); @@ -305,37 +319,23 @@ describe('AuthKitCore', () => { }); it('propagates errors to all concurrent waiters', async () => { - let callCount = 0; - const failingClient = { - userManagement: { - getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', - authenticateWithRefreshToken: async () => { - callCount++; - await new Promise((r) => setTimeout(r, 50)); - throw new Error('Rate limit exceeded'); - }, - }, - }; + const { client, getCallCount } = makeCountingClient({ + fail: () => true, + }); const testCore = new AuthKitCore( mockConfig as any, - failingClient as any, + client as any, mockEncryption as any, ); - const session = { - accessToken: 'expired-jwt', - refreshToken: 'rt-1', - user: mockUser, - impersonator: undefined, - }; - + const session = makeExpiredSession(); const results = await Promise.allSettled([ testCore.validateAndRefresh(session), testCore.validateAndRefresh(session), testCore.validateAndRefresh(session), ]); - expect(callCount).toBe(1); + expect(getCallCount()).toBe(1); for (const r of results) { expect(r.status).toBe('rejected'); if (r.status === 'rejected') { @@ -345,101 +345,53 @@ describe('AuthKitCore', () => { }); it('retries after a failed concurrent batch', async () => { - const newJwt = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInNpZCI6InNlc3Npb25fbmV3IiwiZXhwIjozMDAwMDAwMDAwfQ.sig'; - let callCount = 0; let shouldFail = true; - const retryClient = { - userManagement: { - getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', - authenticateWithRefreshToken: async () => { - callCount++; - await new Promise((r) => setTimeout(r, 20)); - if (shouldFail) throw new Error('Temporary failure'); - return { - accessToken: newJwt, - refreshToken: 'new-rt', - user: mockUser, - impersonator: undefined, - }; - }, - }, - }; + const { client, getCallCount } = makeCountingClient({ + delayMs: 20, + fail: () => shouldFail, + }); const testCore = new AuthKitCore( mockConfig as any, - retryClient as any, + client as any, mockEncryption as any, ); - const session = { - accessToken: 'expired-jwt', - refreshToken: 'rt-1', - user: mockUser, - impersonator: undefined, - }; + const session = makeExpiredSession(); - // First batch: all fail, but only 1 API call await Promise.allSettled([ testCore.validateAndRefresh(session), testCore.validateAndRefresh(session), ]); - expect(callCount).toBe(1); + expect(getCallCount()).toBe(1); - // Second call: should retry fresh (map was cleared in finally) shouldFail = false; const result = await testCore.validateAndRefresh(session); - expect(callCount).toBe(2); + expect(getCallCount()).toBe(2); expect(result.refreshed).toBe(true); expect(result.session.accessToken).toBe(newJwt); }); it('deduplicates separately per organizationId', async () => { - const newJwt = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInNpZCI6InNlc3Npb25fbmV3IiwiZXhwIjozMDAwMDAwMDAwfQ.sig'; - let callCount = 0; - const orgClient = { - userManagement: { - getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', - authenticateWithRefreshToken: async () => { - callCount++; - await new Promise((r) => setTimeout(r, 50)); - return { - accessToken: newJwt, - refreshToken: 'new-rt', - user: mockUser, - impersonator: undefined, - }; - }, - }, - }; + const { client, getCallCount } = makeCountingClient(); const testCore = new AuthKitCore( mockConfig as any, - orgClient as any, + client as any, mockEncryption as any, ); - const session = { - accessToken: 'expired-jwt', - refreshToken: 'rt-1', - user: mockUser, - impersonator: undefined, - }; - + const session = makeExpiredSession(); await Promise.all([ testCore.validateAndRefresh(session, { organizationId: 'org_a' }), testCore.validateAndRefresh(session, { organizationId: 'org_b' }), ]); - expect(callCount).toBe(2); + expect(getCallCount()).toBe(2); }); }); describe('validateAndRefresh()', () => { - // Decodable JWT with sid + exp + org_id. Signature is garbage → verifyToken false. const oldJwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInNpZCI6InNlc3Npb25fOTk5IiwiZXhwIjoxMDAwMDAwMDAwLCJvcmdfaWQiOiJvcmdfYWJjIn0.sig'; - const newJwt = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInNpZCI6InNlc3Npb25fbmV3IiwiZXhwIjozMDAwMDAwMDAwfQ.sig'; function makeRefreshClient(capture?: { opts?: any }) { return { diff --git a/src/core/AuthKitCore.ts b/src/core/AuthKitCore.ts index 4973c71..97a4c56 100644 --- a/src/core/AuthKitCore.ts +++ b/src/core/AuthKitCore.ts @@ -16,6 +16,13 @@ import type { SessionEncryption, } from './session/types.js'; +type RefreshResult = { + accessToken: string; + refreshToken: string; + user: User; + impersonator: Impersonator | undefined; +}; + /** * AuthKitCore provides pure business logic for authentication operations. * @@ -34,15 +41,7 @@ export class AuthKitCore { private client: WorkOS; private encryption: SessionEncryption; private clientId: string; - private inflightRefreshes = new Map< - string, - Promise<{ - accessToken: string; - refreshToken: string; - user: User; - impersonator: Impersonator | undefined; - }> - >(); + private inflightRefreshes = new Map>(); constructor( config: AuthKitConfig, @@ -220,12 +219,7 @@ export class AuthKitCore { refreshToken: string, organizationId?: string, context?: { userId?: string; sessionId?: string }, - ): Promise<{ - accessToken: string; - refreshToken: string; - user: User; - impersonator: Impersonator | undefined; - }> { + ): Promise { const key = `${refreshToken}:${organizationId ?? ''}`; const existing = this.inflightRefreshes.get(key); if (existing) return existing; From e3013b75641b2d7f1734c93a92a2de25dd053c5c Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 13 May 2026 17:07:31 -0500 Subject: [PATCH 3/7] fix: address review feedback on dedup implementation - Use null byte delimiter in cache key to prevent theoretical collision if a refresh token contains ':' - Move map cleanup from IIFE finally to external .then(cleanup, cleanup) to guarantee delete runs after set, even if the SDK method throws synchronously --- src/core/AuthKitCore.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/AuthKitCore.ts b/src/core/AuthKitCore.ts index 97a4c56..1bd44f9 100644 --- a/src/core/AuthKitCore.ts +++ b/src/core/AuthKitCore.ts @@ -220,10 +220,11 @@ export class AuthKitCore { organizationId?: string, context?: { userId?: string; sessionId?: string }, ): Promise { - const key = `${refreshToken}:${organizationId ?? ''}`; + const key = `${refreshToken}\0${organizationId ?? ''}`; const existing = this.inflightRefreshes.get(key); if (existing) return existing; + const cleanup = () => { this.inflightRefreshes.delete(key); }; const promise = (async () => { try { const result = @@ -241,12 +242,11 @@ export class AuthKitCore { }; } catch (error) { throw new TokenRefreshError('Failed to refresh tokens', error, context); - } finally { - this.inflightRefreshes.delete(key); } })(); this.inflightRefreshes.set(key, promise); + promise.then(cleanup, cleanup); return promise; } From 0b55cde7ae3616d4bf939fe383b90300e54ad532 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 13 May 2026 17:08:12 -0500 Subject: [PATCH 4/7] chore: formatting --- src/core/AuthKitCore.spec.ts | 7 ++----- src/core/AuthKitCore.ts | 4 +++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/core/AuthKitCore.spec.ts b/src/core/AuthKitCore.spec.ts index 28875e5..ef0b018 100644 --- a/src/core/AuthKitCore.spec.ts +++ b/src/core/AuthKitCore.spec.ts @@ -56,10 +56,7 @@ function makeExpiredSession() { }; } -function makeCountingClient(opts?: { - delayMs?: number; - fail?: () => boolean; -}) { +function makeCountingClient(opts?: { delayMs?: number; fail?: () => boolean }) { let callCount = 0; const { delayMs = 50, fail } = opts ?? {}; const client = { @@ -67,7 +64,7 @@ function makeCountingClient(opts?: { getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', authenticateWithRefreshToken: async () => { callCount++; - await new Promise((r) => setTimeout(r, delayMs)); + await new Promise(r => setTimeout(r, delayMs)); if (fail?.()) throw new Error('Refresh failed'); return { accessToken: newJwt, diff --git a/src/core/AuthKitCore.ts b/src/core/AuthKitCore.ts index 1bd44f9..ff3f9a0 100644 --- a/src/core/AuthKitCore.ts +++ b/src/core/AuthKitCore.ts @@ -224,7 +224,9 @@ export class AuthKitCore { const existing = this.inflightRefreshes.get(key); if (existing) return existing; - const cleanup = () => { this.inflightRefreshes.delete(key); }; + const cleanup = () => { + this.inflightRefreshes.delete(key); + }; const promise = (async () => { try { const result = From e6439f292e2868caae55aa71825660acd6eb17a2 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 14 May 2026 08:49:28 -0500 Subject: [PATCH 5/7] chore: fix issue template --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 0c888f6..d0101b7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,7 +27,7 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g. iOS] - Browser [e.g. chrome, safari] -- authkit-nextjs version [e.g. 0.12.0] +- authkit-tanstack-start version [e.g. 0.12.0] - Next.js version [e.g. 14.2.5] **Additional context** From d3af5ed247cca32a86c91d87e403f2b76c4e9805 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 14 May 2026 13:55:29 -0500 Subject: [PATCH 6/7] Update .github/ISSUE_TEMPLATE/bug_report.md Co-authored-by: Garen Torikian --- .github/ISSUE_TEMPLATE/bug_report.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d0101b7..9c4289a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,7 +27,8 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g. iOS] - Browser [e.g. chrome, safari] -- authkit-tanstack-start version [e.g. 0.12.0] +- Framework adapter (authkit-nextjs / authkit-tanstack-start) and version +- Framework version (Next.js / TanStack Start / etc.) - Next.js version [e.g. 14.2.5] **Additional context** From f52adb47f0d246f78e53599efe4d2e84f10d3d03 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 14 May 2026 13:58:43 -0500 Subject: [PATCH 7/7] fix: address review feedback from Garen - Make bug report template framework-agnostic (remove hardcoded Next.js/TanStack references) - Use vi.useFakeTimers() + vi.advanceTimersByTimeAsync() in dedup tests instead of real setTimeout delays --- .github/ISSUE_TEMPLATE/bug_report.md | 5 ++-- src/core/AuthKitCore.spec.ts | 38 +++++++++++++++++++++------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9c4289a..1724650 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,9 +27,8 @@ If applicable, add screenshots to help explain your problem. - OS: [e.g. iOS] - Browser [e.g. chrome, safari] -- Framework adapter (authkit-nextjs / authkit-tanstack-start) and version -- Framework version (Next.js / TanStack Start / etc.) -- Next.js version [e.g. 14.2.5] +- Framework adapter and version [e.g. authkit-nextjs 1.0.0] +- Framework and version [e.g. Next.js 14.2.5, TanStack Start 1.0.0] **Additional context** Add any other context about the problem here. diff --git a/src/core/AuthKitCore.spec.ts b/src/core/AuthKitCore.spec.ts index ef0b018..ae96583 100644 --- a/src/core/AuthKitCore.spec.ts +++ b/src/core/AuthKitCore.spec.ts @@ -56,15 +56,15 @@ function makeExpiredSession() { }; } -function makeCountingClient(opts?: { delayMs?: number; fail?: () => boolean }) { +function makeCountingClient(opts?: { fail?: () => boolean }) { let callCount = 0; - const { delayMs = 50, fail } = opts ?? {}; + const { fail } = opts ?? {}; const client = { userManagement: { getJwksUrl: () => 'https://api.workos.com/sso/jwks/test-client-id', authenticateWithRefreshToken: async () => { callCount++; - await new Promise(r => setTimeout(r, delayMs)); + await new Promise(r => setTimeout(r, 50)); if (fail?.()) throw new Error('Refresh failed'); return { accessToken: newJwt, @@ -294,6 +294,7 @@ describe('AuthKitCore', () => { }); it('deduplicates concurrent calls with the same refresh token', async () => { + vi.useFakeTimers(); const { client, getCallCount } = makeCountingClient(); const testCore = new AuthKitCore( mockConfig as any, @@ -302,20 +303,25 @@ describe('AuthKitCore', () => { ); const session = makeExpiredSession(); - const results = await Promise.all([ + const pending = Promise.all([ testCore.validateAndRefresh(session), testCore.validateAndRefresh(session), testCore.validateAndRefresh(session), ]); + await vi.advanceTimersByTimeAsync(50); + const results = await pending; + expect(getCallCount()).toBe(1); for (const r of results) { expect(r.refreshed).toBe(true); expect(r.session.accessToken).toBe(newJwt); } + vi.useRealTimers(); }); it('propagates errors to all concurrent waiters', async () => { + vi.useFakeTimers(); const { client, getCallCount } = makeCountingClient({ fail: () => true, }); @@ -326,12 +332,15 @@ describe('AuthKitCore', () => { ); const session = makeExpiredSession(); - const results = await Promise.allSettled([ + const pending = Promise.allSettled([ testCore.validateAndRefresh(session), testCore.validateAndRefresh(session), testCore.validateAndRefresh(session), ]); + await vi.advanceTimersByTimeAsync(50); + const results = await pending; + expect(getCallCount()).toBe(1); for (const r of results) { expect(r.status).toBe('rejected'); @@ -339,12 +348,13 @@ describe('AuthKitCore', () => { expect(r.reason).toBeInstanceOf(TokenRefreshError); } } + vi.useRealTimers(); }); it('retries after a failed concurrent batch', async () => { + vi.useFakeTimers(); let shouldFail = true; const { client, getCallCount } = makeCountingClient({ - delayMs: 20, fail: () => shouldFail, }); const testCore = new AuthKitCore( @@ -355,20 +365,26 @@ describe('AuthKitCore', () => { const session = makeExpiredSession(); - await Promise.allSettled([ + const firstBatch = Promise.allSettled([ testCore.validateAndRefresh(session), testCore.validateAndRefresh(session), ]); + await vi.advanceTimersByTimeAsync(50); + await firstBatch; expect(getCallCount()).toBe(1); shouldFail = false; - const result = await testCore.validateAndRefresh(session); + const retryPending = testCore.validateAndRefresh(session); + await vi.advanceTimersByTimeAsync(50); + const result = await retryPending; expect(getCallCount()).toBe(2); expect(result.refreshed).toBe(true); expect(result.session.accessToken).toBe(newJwt); + vi.useRealTimers(); }); it('deduplicates separately per organizationId', async () => { + vi.useFakeTimers(); const { client, getCallCount } = makeCountingClient(); const testCore = new AuthKitCore( mockConfig as any, @@ -377,12 +393,16 @@ describe('AuthKitCore', () => { ); const session = makeExpiredSession(); - await Promise.all([ + const pending = Promise.all([ testCore.validateAndRefresh(session, { organizationId: 'org_a' }), testCore.validateAndRefresh(session, { organizationId: 'org_b' }), ]); + await vi.advanceTimersByTimeAsync(50); + await pending; + expect(getCallCount()).toBe(2); + vi.useRealTimers(); }); });