Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
149 changes: 146 additions & 3 deletions src/core/AuthKitCore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down
60 changes: 38 additions & 22 deletions src/core/AuthKitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -34,6 +41,7 @@ export class AuthKitCore {
private client: WorkOS;
private encryption: SessionEncryption;
private clientId: string;
private inflightRefreshes = new Map<string, Promise<RefreshResult>>();

constructor(
config: AuthKitConfig,
Expand Down Expand Up @@ -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<RefreshResult> {
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;
Comment on lines +230 to +252
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Stale rejected promise permanently cached in inflightRefreshes when authenticateWithRefreshToken throws synchronously

The IIFE at src/core/AuthKitCore.ts:233 starts executing synchronously before this.inflightRefreshes.set(key, promise) on line 255. If authenticateWithRefreshToken throws synchronously (i.e., is not an async function), the try/catch/finally inside the IIFE all execute synchronously — meaning this.inflightRefreshes.delete(key) on line 251 runs as a no-op (key not yet in the map), then set(key, promise) stores the already-rejected promise that will never be cleaned up. All subsequent calls with the same refreshToken:organizationId key will return the cached rejected promise, permanently preventing token refresh for that user until the process restarts.

Node.js verification of the execution order

When the awaited expression throws synchronously, finally runs before set:

Order: [ 'delete', 'set' ]
Map has stale entry: true
Map size: 1

When the awaited expression is async (even if it rejects), set correctly runs before finally:

async case: [ 'after-iife', 'finally' ]
Suggested change
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;
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.finally(() => this.inflightRefreshes.delete(key));
return promise;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

/**
Expand Down
Loading