diff --git a/packages/coding-agent/src/mcp/oauth-flow.ts b/packages/coding-agent/src/mcp/oauth-flow.ts index 08b82ec12..d0a35e08d 100644 --- a/packages/coding-agent/src/mcp/oauth-flow.ts +++ b/packages/coding-agent/src/mcp/oauth-flow.ts @@ -563,7 +563,7 @@ export class MCPOAuthFlow extends OAuthCallbackFlow { * accept the later authorize request for the same scope set. */ async #tryRegisterClient(redirectUri: string): Promise { - const registrationEndpoint = this.config.registrationUrl; + const registrationEndpoint = this.config.registrationUrl ?? (await this.#resolveRegistrationEndpoint()); if (!registrationEndpoint) return; try { @@ -620,6 +620,56 @@ export class MCPOAuthFlow extends OAuthCallbackFlow { } } + async #resolveRegistrationEndpoint(): Promise { + const authorizationUrl = new URL(this.config.authorizationUrl); + + // origin-root well-known; most servers serve metadata here. + const rootUrl = new URL("/.well-known/oauth-authorization-server", authorizationUrl.origin).toString(); + const endpoint = await this.#tryWellKnownForRegistration(rootUrl); + if (endpoint) return endpoint; + + // path-prefixed well-known for gateways (e.g. https://gateway.example.com/my-service/). + const normalizedPath = authorizationUrl.pathname.replace(/\/$/, ""); + const lastSlash = normalizedPath.lastIndexOf("/"); + // Bare-origin authorization URL — nothing further to try. + if (lastSlash < 0) return null; + + // Single-segment paths are the gateway prefix itself; multi-segment paths + // drop the trailing segment (typically a service endpoint). + const prefixPath = lastSlash === 0 ? normalizedPath : normalizedPath.slice(0, lastSlash); + const prefixedUrl = new URL( + ".well-known/oauth-authorization-server", + `${authorizationUrl.origin}${prefixPath}/`, + ).toString(); + const prefixedEndpoint = await this.#tryWellKnownForRegistration(prefixedUrl); + if (prefixedEndpoint) return prefixedEndpoint; + + // RFC 8414 §3.1 path-ful issuer form: /.well-known/oauth-authorization-server/. + const pathfulUrl = new URL( + `/.well-known/oauth-authorization-server${normalizedPath}`, + authorizationUrl.origin, + ).toString(); + return await this.#tryWellKnownForRegistration(pathfulUrl); + } + + async #tryWellKnownForRegistration(wellKnownUrl: string): Promise { + try { + const response = await this.#fetch(wellKnownUrl, { + method: "GET", + headers: { Accept: "application/json" }, + signal: this.ctrl.signal, + }); + if (!response.ok) return null; + const metadata = (await response.json()) as { registration_endpoint?: string }; + if (metadata.registration_endpoint && metadata.registration_endpoint.trim() !== "") { + return metadata.registration_endpoint; + } + } catch { + // Ignore fetch/parse failures. + } + return null; + } + async #assertClientIdNotRequired(authorizationUrl: string): Promise { try { const response = await this.#fetch(authorizationUrl, { diff --git a/packages/coding-agent/test/oauth-flow.test.ts b/packages/coding-agent/test/oauth-flow.test.ts index 578e18d1a..8824e2a2e 100644 --- a/packages/coding-agent/test/oauth-flow.test.ts +++ b/packages/coding-agent/test/oauth-flow.test.ts @@ -28,6 +28,12 @@ function mockProviderTokenEndpoint(onBody: (body: string) => void): FetchImpl { function mockFigmaRegistration(onRegistration: (payload: Record) => void): FetchImpl { return async (input, init) => { const url = String(input); + if (url === "https://www.figma.com/.well-known/oauth-authorization-server") { + return new Response(JSON.stringify({ registration_endpoint: "https://www.figma.com/oauth/register" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } if (url === "https://www.figma.com/oauth/register") { onRegistration(JSON.parse(String(init?.body)) as Record); return new Response( @@ -62,7 +68,6 @@ describe("mcp oauth flow", () => { { authorizationUrl: "https://www.figma.com/oauth/mcp", tokenUrl: "https://api.figma.com/v1/oauth/token", - registrationUrl: "https://www.figma.com/oauth/register", fetch: mockFigmaRegistration(payload => { registrationPayload = payload; }),