diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 0c888f6..1724650 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,8 +27,8 @@ 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] -- 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 0d15c7e..ae96583 100644 --- a/src/core/AuthKitCore.spec.ts +++ b/src/core/AuthKitCore.spec.ts @@ -44,6 +44,40 @@ const mockEncryption = { }), }; +const newJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInNpZCI6InNlc3Npb25fbmV3IiwiZXhwIjozMDAwMDAwMDAwfQ.sig'; + +function makeExpiredSession() { + return { + accessToken: 'expired-jwt', + refreshToken: 'rt-1', + user: mockUser, + impersonator: undefined, + }; +} + +function makeCountingClient(opts?: { fail?: () => boolean }) { + let callCount = 0; + 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, 50)); + 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; @@ -258,14 +292,123 @@ describe('AuthKitCore', () => { TokenRefreshError, ); }); + + it('deduplicates concurrent calls with the same refresh token', async () => { + vi.useFakeTimers(); + const { client, getCallCount } = makeCountingClient(); + const testCore = new AuthKitCore( + mockConfig as any, + client as any, + mockEncryption as any, + ); + + const session = makeExpiredSession(); + 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, + }); + const testCore = new AuthKitCore( + mockConfig as any, + client as any, + mockEncryption as any, + ); + + const session = makeExpiredSession(); + 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'); + if (r.status === 'rejected') { + expect(r.reason).toBeInstanceOf(TokenRefreshError); + } + } + vi.useRealTimers(); + }); + + it('retries after a failed concurrent batch', async () => { + vi.useFakeTimers(); + let shouldFail = true; + const { client, getCallCount } = makeCountingClient({ + fail: () => shouldFail, + }); + const testCore = new AuthKitCore( + mockConfig as any, + client as any, + mockEncryption as any, + ); + + const session = makeExpiredSession(); + + const firstBatch = Promise.allSettled([ + testCore.validateAndRefresh(session), + testCore.validateAndRefresh(session), + ]); + await vi.advanceTimersByTimeAsync(50); + await firstBatch; + expect(getCallCount()).toBe(1); + + shouldFail = false; + 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, + client as any, + mockEncryption as any, + ); + + const session = makeExpiredSession(); + 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(); + }); }); 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 6477c3d..ff3f9a0 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,6 +41,7 @@ export class AuthKitCore { private client: WorkOS; private encryption: SessionEncryption; private clientId: string; + private inflightRefreshes = new Map>(); constructor( config: AuthKitConfig, @@ -211,29 +219,37 @@ export class AuthKitCore { refreshToken: string, organizationId?: string, context?: { userId?: string; sessionId?: string }, - ): Promise<{ - accessToken: string; - refreshToken: string; - user: User; - impersonator: Impersonator | undefined; - }> { - try { - const result = - await this.client.userManagement.authenticateWithRefreshToken({ - refreshToken, - clientId: this.clientId, - organizationId, - }); + ): Promise { + const key = `${refreshToken}\0${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 cleanup = () => { + this.inflightRefreshes.delete(key); + }; + 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); + } + })(); + + this.inflightRefreshes.set(key, promise); + promise.then(cleanup, cleanup); + return promise; } /**