Skip to content

Commit 4c87637

Browse files
committed
fix(McpOAuthClientProvider): refresh token race condition between client_id
1 parent 947ae61 commit 4c87637

File tree

3 files changed

+51
-21
lines changed

3 files changed

+51
-21
lines changed

src/services/mcp/McpOAuthClientProvider.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
174174

175175
// Check if we have a cached client_id from previous registration
176176
const cachedData = await this._secretStorage.getOAuthData(this._serverUrl)
177-
if (cachedData?.client_id && cachedData.redirect_uri === this.redirectUrl) {
177+
if (cachedData?.client_id) {
178178
this._clientInfo = {
179179
client_id: cachedData.client_id,
180180
redirect_uris: [this.redirectUrl],
@@ -219,9 +219,17 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
219219
return this._refreshPromise
220220
}
221221

222-
this._refreshPromise = this.refreshAccessToken(data.tokens.refresh_token).finally(() => {
223-
this._refreshPromise = null
224-
})
222+
// Use the client_id stored alongside the tokens — it is the one the
223+
// auth server bound the refresh token to. `this._clientInfo.client_id`
224+
// may differ if a fresh DCR was performed (e.g. after stale token
225+
// cleanup removed the cached data).
226+
const clientIdForRefresh = data.client_id ?? this._clientInfo?.client_id
227+
228+
this._refreshPromise = this.refreshAccessToken(data.tokens.refresh_token, clientIdForRefresh).finally(
229+
() => {
230+
this._refreshPromise = null
231+
},
232+
)
225233

226234
try {
227235
return await this._refreshPromise
@@ -236,13 +244,12 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
236244
return undefined
237245
}
238246

239-
async saveTokens(tokens: OAuthTokens): Promise<void> {
247+
async saveTokens(tokens: OAuthTokens, clientIdOverride?: string): Promise<void> {
240248
const expires_at = tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : Date.now() + 3600 * 1000 // default 1 hour when server omits expires_in
241249
await this._secretStorage.saveOAuthData(this._serverUrl, {
242250
tokens,
243251
expires_at,
244-
client_id: this._clientInfo?.client_id,
245-
redirect_uri: this.redirectUrl,
252+
client_id: clientIdOverride ?? this._clientInfo?.client_id,
246253
})
247254
}
248255

@@ -374,24 +381,30 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
374381

375382
/**
376383
* Refreshes the access token using a refresh token.
384+
*
377385
* @param refreshToken The refresh token to use.
386+
* @param clientIdOverride Optional client_id to use instead of `this._clientInfo.client_id`.
387+
* This is used when the stored tokens were issued to a different client_id than the
388+
* current in-memory registration (e.g. after a port change caused a new DCR).
378389
* @returns The new tokens.
379390
*/
380-
async refreshAccessToken(refreshToken: string): Promise<OAuthTokens> {
391+
async refreshAccessToken(refreshToken: string, clientIdOverride?: string): Promise<OAuthTokens> {
381392
if (!this._authServerMeta?.token_endpoint) {
382393
throw new Error("No token_endpoint in auth server metadata — cannot refresh token")
383394
}
384-
if (!this._clientInfo) {
395+
396+
const clientId = clientIdOverride ?? this._clientInfo?.client_id
397+
if (!clientId) {
385398
throw new Error("No client information — registerClientIfNeeded() must be called first")
386399
}
387400

388401
const params: Record<string, string> = {
389402
grant_type: "refresh_token",
390403
refresh_token: refreshToken,
391-
client_id: this._clientInfo.client_id,
404+
client_id: clientId,
392405
}
393406

394-
if (this._tokenEndpointAuthMethod === "client_secret_post" && this._clientInfo.client_secret) {
407+
if (this._tokenEndpointAuthMethod === "client_secret_post" && this._clientInfo?.client_secret) {
395408
params.client_secret = this._clientInfo.client_secret
396409
}
397410

@@ -405,11 +418,12 @@ export class McpOAuthClientProvider implements OAuthClientProvider {
405418
})
406419

407420
if (!response.ok) {
408-
throw new Error(`Token refresh failed: HTTP ${response.status}`)
421+
const errorBody = await response.text().catch(() => "")
422+
throw new Error(`Token refresh failed: HTTP ${response.status} ${errorBody}`)
409423
}
410424

411425
const tokens = (await response.json()) as OAuthTokens
412-
await this.saveTokens(tokens)
426+
await this.saveTokens(tokens, clientId)
413427
return tokens
414428
}
415429

src/services/mcp/SecretStorageService.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ export interface StoredMcpOAuthData {
77
expires_at: number
88
/** The client_id used to obtain these tokens (for token reuse without re-registration). */
99
client_id?: string
10-
/** The redirect_uri used during client registration (to detect port changes). */
11-
redirect_uri?: string
1210
}
1311

1412
/**

src/services/mcp/__tests__/McpOAuthClientProvider.spec.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,7 @@ describe("McpOAuthClientProvider", () => {
585585
})
586586

587587
describe("registerClientIfNeeded", () => {
588-
it("should reuse cached client_id when redirect_uri matches", async () => {
588+
it("should reuse cached client_id from previous registration", async () => {
589589
setupCallbackServerMock()
590590
const secretStorage = createMockSecretStorage()
591591

@@ -594,7 +594,6 @@ describe("McpOAuthClientProvider", () => {
594594
tokens: { access_token: "cached-token", token_type: "Bearer" },
595595
expires_at: Date.now() + 3600000,
596596
client_id: "cached-client-id",
597-
redirect_uri: "http://localhost:12345/callback",
598597
})
599598

600599
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
@@ -604,7 +603,28 @@ describe("McpOAuthClientProvider", () => {
604603
await provider.close()
605604
})
606605

607-
it("should not reuse cached client_id when redirect_uri does not match", async () => {
606+
it("should reuse cached client_id even when callback server port has changed", async () => {
607+
setupCallbackServerMock()
608+
const secretStorage = createMockSecretStorage()
609+
610+
// Pre-populate storage with cached data — the callback server port (12345)
611+
// may differ from the port used in the original registration, but we still
612+
// reuse the client_id to avoid "refresh token not issued to this client" errors.
613+
await secretStorage.saveOAuthData("https://example.com/mcp", {
614+
tokens: { access_token: "cached-token", token_type: "Bearer" },
615+
expires_at: Date.now() + 3600000,
616+
client_id: "cached-client-id",
617+
})
618+
619+
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)
620+
await provider.registerClientIfNeeded()
621+
622+
// Should still reuse the cached client_id, NOT perform a new DCR
623+
expect((await provider.clientInformation())?.client_id).toBe("cached-client-id")
624+
await provider.close()
625+
})
626+
627+
it("should perform DCR when no cached client_id exists", async () => {
608628
setupCallbackServerMock()
609629
const secretStorage = createMockSecretStorage()
610630

@@ -636,12 +656,10 @@ describe("McpOAuthClientProvider", () => {
636656
}),
637657
})
638658

639-
// Pre-populate storage with cached data with different redirect_uri
659+
// No cached client_id in storage
640660
await secretStorage.saveOAuthData("https://example.com/mcp", {
641661
tokens: { access_token: "cached-token", token_type: "Bearer" },
642662
expires_at: Date.now() + 3600000,
643-
client_id: "cached-client-id",
644-
redirect_uri: "http://localhost:99999/callback", // different port
645663
})
646664

647665
const provider = await McpOAuthClientProvider.create("https://example.com/mcp", secretStorage)

0 commit comments

Comments
 (0)