Skip to content

Commit ccecbe5

Browse files
dom-murrayclaude
andcommitted
fix(auth-next-server): deduplicate concurrent token refresh calls to prevent invalid_grant
OAuth refresh tokens are single-use. Without concurrency protection, parallel requests hitting the same expired token each fire a separate refresh, causing the second caller to receive a 400 invalid_grant and set RefreshTokenError on the session. Promise deduplication ensures all concurrent callers share one in-flight refresh per token. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent f79e3dc commit ccecbe5

1 file changed

Lines changed: 34 additions & 12 deletions

File tree

packages/auth-next-server/src/refresh.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -85,20 +85,16 @@ export function extractZkEvmFromIdToken(idToken?: string): ZkEvmData | undefined
8585
return undefined;
8686
}
8787

88-
/**
89-
* Refresh access token using the refresh token.
90-
* This is called server-side in the JWT callback when the access token is expired.
91-
*
92-
* @param refreshToken - The refresh token to use
93-
* @param clientId - The OAuth client ID
94-
* @param authDomain - The authentication domain (default: https://auth.immutable.com)
95-
* @returns The refreshed tokens
96-
* @throws Error if refresh fails
97-
*/
98-
export async function refreshAccessToken(
88+
// Deduplicates concurrent refresh calls for the same refresh token.
89+
// OAuth refresh tokens are single-use: if two requests arrive simultaneously
90+
// with the same expired token, only the first call reaches the provider.
91+
// Subsequent callers await the same promise and receive the same result.
92+
const refreshPromises = new Map<string, Promise<RefreshedTokens>>();
93+
94+
async function doRefreshAccessToken(
9995
refreshToken: string,
10096
clientId: string,
101-
authDomain: string = DEFAULT_AUTH_DOMAIN,
97+
authDomain: string,
10298
): Promise<RefreshedTokens> {
10399
const tokenUrl = `${authDomain}/oauth/token`;
104100

@@ -141,3 +137,29 @@ export async function refreshAccessToken(
141137
accessTokenExpires: decodeJwtExpiry(tokenData.access_token),
142138
};
143139
}
140+
141+
/**
142+
* Refresh access token using the refresh token.
143+
* This is called server-side in the JWT callback when the access token is expired.
144+
*
145+
* @param refreshToken - The refresh token to use
146+
* @param clientId - The OAuth client ID
147+
* @param authDomain - The authentication domain (default: https://auth.immutable.com)
148+
* @returns The refreshed tokens
149+
* @throws Error if refresh fails
150+
*/
151+
export async function refreshAccessToken(
152+
refreshToken: string,
153+
clientId: string,
154+
authDomain: string = DEFAULT_AUTH_DOMAIN,
155+
): Promise<RefreshedTokens> {
156+
const cacheKey = `${clientId}:${refreshToken}`;
157+
const inflight = refreshPromises.get(cacheKey);
158+
if (inflight) return inflight;
159+
160+
const promise = doRefreshAccessToken(refreshToken, clientId, authDomain).finally(() => {
161+
refreshPromises.delete(cacheKey);
162+
});
163+
refreshPromises.set(cacheKey, promise);
164+
return promise;
165+
}

0 commit comments

Comments
 (0)