Skip to content

Commit e5dab64

Browse files
author
pmor
committed
fix: re-register stale dynamic OAuth clients
1 parent 02619af commit e5dab64

5 files changed

Lines changed: 236 additions & 3 deletions

File tree

src/lib/node-oauth-client-provider.test.ts

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2+
import open from 'open'
23
import { NodeOAuthClientProvider } from './node-oauth-client-provider'
34
import * as mcpAuthConfig from './mcp-auth-config'
45
import type { OAuthProviderOptions } from './types'
@@ -22,6 +23,7 @@ describe('NodeOAuthClientProvider - OAuth Scope Handling', () => {
2223
let mockReadJsonFile: any
2324
let mockWriteJsonFile: any
2425
let mockDeleteConfigFile: any
26+
let mockFetch: ReturnType<typeof vi.fn>
2527

2628
const defaultOptions: OAuthProviderOptions = {
2729
serverUrl: 'https://example.com',
@@ -41,6 +43,7 @@ describe('NodeOAuthClientProvider - OAuth Scope Handling', () => {
4143
})
4244

4345
afterEach(() => {
46+
vi.unstubAllGlobals()
4447
vi.clearAllMocks()
4548
})
4649

@@ -96,14 +99,133 @@ describe('NodeOAuthClientProvider - OAuth Scope Handling', () => {
9699
expect(authUrl.searchParams.get('scope')).toBe('github read:user')
97100
})
98101

99-
it('should include default scope in authorization URL when none specified', async () => {
102+
it('should replace an existing authorization URL scope with the default scope when none is specified', async () => {
100103
provider = new NodeOAuthClientProvider(defaultOptions)
101104

102-
const authUrl = new URL('https://auth.example.com/authorize')
105+
const authUrl = new URL('https://auth.example.com/authorize?scope=existing')
103106
await provider.redirectToAuthorization(authUrl)
104107

105108
expect(authUrl.searchParams.get('scope')).toBe('openid email profile')
106109
})
110+
111+
it('invalidates a cached dynamic client when authorization reports it is no longer registered', async () => {
112+
provider = new NodeOAuthClientProvider(defaultOptions)
113+
mockReadJsonFile.mockResolvedValueOnce({
114+
client_id: 'stale-client',
115+
redirect_uris: ['http://localhost:8080/oauth/callback'],
116+
})
117+
await provider.clientInformation()
118+
mockFetch = vi.fn().mockResolvedValue({
119+
status: 400,
120+
json: async () => ({
121+
registration_endpoint: 'https://auth.example.com/register',
122+
error: 'invalid_request',
123+
error_description: "Client ID 'stale-client' is not registered with this server",
124+
}),
125+
})
126+
vi.stubGlobal('fetch', mockFetch)
127+
128+
await expect(
129+
provider.redirectToAuthorization(new URL('https://auth.example.com/authorize?client_id=stale-client')),
130+
).rejects.toMatchObject({
131+
name: 'StaleClientRegistrationError',
132+
message: 'Cached OAuth client registration is no longer valid',
133+
})
134+
135+
expect(mockFetch).toHaveBeenCalledWith(
136+
'https://auth.example.com/authorize?client_id=stale-client&scope=openid+email+profile',
137+
expect.objectContaining({
138+
redirect: 'manual',
139+
headers: { Accept: 'application/json' },
140+
signal: expect.any(AbortSignal),
141+
}),
142+
)
143+
expect(mockDeleteConfigFile).toHaveBeenCalledTimes(3)
144+
expect(mockDeleteConfigFile.mock.calls.map(([, fileName]: [string, string]) => fileName)).toEqual(
145+
expect.arrayContaining(['client_info.json', 'tokens.json', 'code_verifier.txt']),
146+
)
147+
expect(open).not.toHaveBeenCalled()
148+
})
149+
150+
it('retains cached credentials and opens the browser when authorization redirects', async () => {
151+
provider = new NodeOAuthClientProvider(defaultOptions)
152+
mockReadJsonFile.mockResolvedValueOnce({
153+
client_id: 'active-client',
154+
redirect_uris: ['http://localhost:8080/oauth/callback'],
155+
})
156+
await provider.clientInformation()
157+
mockFetch = vi.fn().mockResolvedValue({ status: 302 })
158+
vi.stubGlobal('fetch', mockFetch)
159+
160+
await provider.redirectToAuthorization(new URL('https://auth.example.com/authorize?client_id=active-client'))
161+
162+
expect(mockFetch).toHaveBeenCalledWith(
163+
'https://auth.example.com/authorize?client_id=active-client&scope=openid+email+profile',
164+
expect.objectContaining({
165+
redirect: 'manual',
166+
headers: { Accept: 'application/json' },
167+
signal: expect.any(AbortSignal),
168+
}),
169+
)
170+
expect(mockDeleteConfigFile).not.toHaveBeenCalled()
171+
expect(open).toHaveBeenCalledOnce()
172+
})
173+
174+
it('does not invalidate a cached client when its redirect URI is described as not registered', async () => {
175+
provider = new NodeOAuthClientProvider(defaultOptions)
176+
mockReadJsonFile.mockResolvedValueOnce({
177+
client_id: 'active-client',
178+
redirect_uris: ['http://localhost:8080/oauth/callback'],
179+
})
180+
await provider.clientInformation()
181+
mockFetch = vi.fn().mockResolvedValue({
182+
status: 400,
183+
json: async () => ({
184+
registration_endpoint: 'https://auth.example.com/register',
185+
error: 'invalid_request',
186+
error_description: 'The client redirect URI is not registered',
187+
}),
188+
})
189+
vi.stubGlobal('fetch', mockFetch)
190+
191+
await provider.redirectToAuthorization(new URL('https://auth.example.com/authorize?client_id=active-client'))
192+
193+
expect(mockDeleteConfigFile).not.toHaveBeenCalled()
194+
expect(open).toHaveBeenCalledOnce()
195+
})
196+
197+
it('does not preflight a freshly dynamically registered client', async () => {
198+
provider = new NodeOAuthClientProvider(defaultOptions)
199+
await provider.saveClientInformation({
200+
client_id: 'fresh-client',
201+
redirect_uris: ['http://localhost:8080/oauth/callback'],
202+
})
203+
mockFetch = vi.fn()
204+
vi.stubGlobal('fetch', mockFetch)
205+
206+
await provider.redirectToAuthorization(new URL('https://auth.example.com/authorize?client_id=fresh-client'))
207+
208+
expect(mockFetch).not.toHaveBeenCalled()
209+
expect(open).toHaveBeenCalledOnce()
210+
})
211+
212+
it('does not preflight a static client registration', async () => {
213+
provider = new NodeOAuthClientProvider({
214+
...defaultOptions,
215+
staticOAuthClientInfo: {
216+
client_id: 'static-client',
217+
redirect_uris: ['http://localhost:8080/oauth/callback'],
218+
},
219+
})
220+
mockFetch = vi.fn()
221+
vi.stubGlobal('fetch', mockFetch)
222+
223+
await provider.clientInformation()
224+
await provider.redirectToAuthorization(new URL('https://auth.example.com/authorize?client_id=static-client'))
225+
226+
expect(mockFetch).not.toHaveBeenCalled()
227+
expect(open).toHaveBeenCalledOnce()
228+
})
107229
})
108230

109231
describe('backward compatibility', () => {

src/lib/node-oauth-client-provider.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,22 @@ import { sanitizeUrl } from 'strict-url-sanitise'
1414
import { randomUUID } from 'node:crypto'
1515
import { fetchAuthorizationServerMetadata, type AuthorizationServerMetadata } from './authorization-server-metadata'
1616
import type { ProtectedResourceMetadata } from './protected-resource-metadata'
17+
import { StaleClientRegistrationError } from './stale-client-registration-error'
18+
19+
type ClientRegistrationSource = 'cached-dynamic' | 'fresh-dynamic' | 'static' | undefined
20+
21+
function isStaleClientRegistrationResponse(response: unknown): boolean {
22+
if (!response || typeof response !== 'object') {
23+
return false
24+
}
25+
26+
const { registration_endpoint: registrationEndpoint, error_description: errorDescription } = response as Record<string, unknown>
27+
return (
28+
typeof registrationEndpoint === 'string' &&
29+
typeof errorDescription === 'string' &&
30+
/\bclient(?:\s+id)?\b\s+(?:['"][^'"]+['"]\s+)?is\s+not[\s-]+registered\b/i.test(errorDescription)
31+
)
32+
}
1733

1834
/**
1935
* Implements the OAuthClientProvider interface for Node.js environments.
@@ -31,6 +47,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
3147
private authorizeResource: string | undefined
3248
private _state: string
3349
private _clientInfo: OAuthClientInformationFull | undefined
50+
private clientRegistrationSource: ClientRegistrationSource
3451
private authorizationServerMetadata: AuthorizationServerMetadata | undefined
3552
private protectedResourceMetadata: ProtectedResourceMetadata | undefined
3653
private wwwAuthenticateScope: string | undefined
@@ -51,6 +68,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
5168
this.authorizeResource = options.authorizeResource
5269
this._state = randomUUID()
5370
this._clientInfo = undefined
71+
this.clientRegistrationSource = undefined
5472
this.authorizationServerMetadata = options.authorizationServerMetadata
5573
this.protectedResourceMetadata = options.protectedResourceMetadata
5674
this.wwwAuthenticateScope = options.wwwAuthenticateScope
@@ -159,6 +177,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
159177
if (this.staticOAuthClientInfo) {
160178
debugLog('Returning static client info')
161179
this._clientInfo = this.staticOAuthClientInfo
180+
this.clientRegistrationSource = 'static'
162181
return this.staticOAuthClientInfo
163182
}
164183
const clientInfo = await readJsonFile<OAuthClientInformationFull>(
@@ -169,6 +188,9 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
169188

170189
if (clientInfo) {
171190
this._clientInfo = clientInfo
191+
if (this.clientRegistrationSource !== 'fresh-dynamic') {
192+
this.clientRegistrationSource = 'cached-dynamic'
193+
}
172194
}
173195

174196
debugLog('Client info result:', clientInfo ? 'Found' : 'Not found')
@@ -182,6 +204,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
182204
async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise<void> {
183205
debugLog('Saving client info', { client_id: clientInformation.client_id })
184206
this._clientInfo = clientInformation
207+
this.clientRegistrationSource = 'fresh-dynamic'
185208
await writeJsonFile(this.serverUrlHash, 'client_info.json', clientInformation)
186209
}
187210

@@ -270,6 +293,8 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
270293

271294
debugLog('Redirecting to authorization URL', authorizationUrl.toString())
272295

296+
await this.preflightCachedDynamicClientRegistration(authorizationUrl)
297+
273298
try {
274299
await open(sanitizeUrl(authorizationUrl.toString()))
275300
log('Browser opened automatically.')
@@ -279,6 +304,43 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
279304
}
280305
}
281306

307+
private async preflightCachedDynamicClientRegistration(authorizationUrl: URL): Promise<void> {
308+
if (this.clientRegistrationSource !== 'cached-dynamic') {
309+
return
310+
}
311+
312+
let response: Response
313+
try {
314+
response = await fetch(authorizationUrl.toString(), {
315+
redirect: 'manual',
316+
headers: { Accept: 'application/json' },
317+
signal: AbortSignal.timeout(5_000),
318+
})
319+
} catch (error) {
320+
debugLog('Authorization preflight failed; continuing to browser authorization', error)
321+
return
322+
}
323+
324+
if (response.status !== 400 && response.status !== 401) {
325+
return
326+
}
327+
328+
let errorResponse: unknown
329+
try {
330+
errorResponse = await response.json()
331+
} catch (error) {
332+
debugLog('Authorization preflight returned invalid JSON; continuing to browser authorization', error)
333+
return
334+
}
335+
336+
if (!isStaleClientRegistrationResponse(errorResponse)) {
337+
return
338+
}
339+
340+
await this.invalidateCredentials('all')
341+
throw new StaleClientRegistrationError()
342+
}
343+
282344
/**
283345
* Saves the PKCE code verifier
284346
* @param codeVerifier The code verifier to save
@@ -314,12 +376,14 @@ export class NodeOAuthClientProvider implements OAuthClientProvider {
314376
deleteConfigFile(this.serverUrlHash, 'code_verifier.txt'),
315377
])
316378
this._clientInfo = undefined
379+
this.clientRegistrationSource = undefined
317380
debugLog('All credentials invalidated')
318381
break
319382

320383
case 'client':
321384
await deleteConfigFile(this.serverUrlHash, 'client_info.json')
322385
this._clientInfo = undefined
386+
this.clientRegistrationSource = undefined
323387
debugLog('Client information invalidated')
324388
break
325389

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export class StaleClientRegistrationError extends Error {
2+
constructor() {
3+
super('Cached OAuth client registration is no longer valid')
4+
this.name = 'StaleClientRegistrationError'
5+
}
6+
}

src/lib/utils.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,40 @@
11
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2-
import { parseCommandLineArgs, shouldIncludeTool, mcpProxy, setupOAuthCallbackServerWithLongPoll, getServerUrlHash } from './utils'
2+
import {
3+
connectToRemoteServer,
4+
parseCommandLineArgs,
5+
shouldIncludeTool,
6+
mcpProxy,
7+
setupOAuthCallbackServerWithLongPoll,
8+
getServerUrlHash,
9+
} from './utils'
310
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
11+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
412
import { EventEmitter } from 'events'
513
import express from 'express'
14+
import { StaleClientRegistrationError } from './stale-client-registration-error'
615

716
// All sanitizeUrl tests have been moved to the strict-url-sanitise package
817

18+
describe('connectToRemoteServer', () => {
19+
it('rethrows a stale client registration error after one reconnect attempt', async () => {
20+
const error = new StaleClientRegistrationError()
21+
const startSpy = vi.spyOn(StreamableHTTPClientTransport.prototype, 'start').mockRejectedValue(error)
22+
23+
try {
24+
await expect(
25+
connectToRemoteServer(null, 'https://example.com/mcp', {} as any, {}, async () => ({
26+
waitForAuthCode: async () => 'unused',
27+
skipBrowserAuth: false,
28+
})),
29+
).rejects.toBe(error)
30+
31+
expect(startSpy).toHaveBeenCalledTimes(2)
32+
} finally {
33+
startSpy.mockRestore()
34+
}
35+
})
36+
})
37+
938
describe('Feature: Command Line Arguments Parsing', () => {
1039
it('Scenario: Parse basic server URL', async () => {
1140
// Given command line arguments with only a server URL

src/lib/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type ProtectedResourceMetadata,
1515
} from './protected-resource-metadata'
1616
import { fetchAuthorizationServerMetadata, type AuthorizationServerMetadata } from './authorization-server-metadata'
17+
import { StaleClientRegistrationError } from './stale-client-registration-error'
1718
import express from 'express'
1819
import net from 'net'
1920
import crypto from 'crypto'
@@ -31,6 +32,7 @@ declare global {
3132
// Connection constants
3233
export const REASON_AUTH_NEEDED = 'authentication-needed'
3334
export const REASON_TRANSPORT_FALLBACK = 'falling-back-to-alternate-transport'
35+
export const REASON_STALE_CLIENT_REGISTRATION = 'stale-client-registration'
3436

3537
// Transport strategy types
3638
export type TransportStrategy = 'sse-only' | 'http-only' | 'sse-first' | 'http-first'
@@ -460,6 +462,16 @@ export async function connectToRemoteServer(
460462

461463
return transport
462464
} catch (error: any) {
465+
if (error instanceof StaleClientRegistrationError) {
466+
if (recursionReasons.has(REASON_STALE_CLIENT_REGISTRATION)) {
467+
throw error
468+
}
469+
470+
recursionReasons.add(REASON_STALE_CLIENT_REGISTRATION)
471+
log(`Recursively reconnecting for reason: ${REASON_STALE_CLIENT_REGISTRATION}`)
472+
return connectToRemoteServer(client, serverUrl, authProvider, headers, authInitializer, transportStrategy, recursionReasons)
473+
}
474+
463475
// Check if it's a protocol error and we should attempt fallback
464476
// StreamableHTTPError has a `code` property with the HTTP status code
465477
const isStreamableHTTPError = error instanceof StreamableHTTPError

0 commit comments

Comments
 (0)