From d14eaa1d0e94f2048835cf5f16da2b2559d363f4 Mon Sep 17 00:00:00 2001 From: Michel <261991219+mpressiv-vault@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:48:40 +0200 Subject: [PATCH] fix(sdk): try the OIDC path-appended well-known during OAuth discovery Authorization server metadata was only probed at the two path-insertion locations, so an issuer mounted under a path that serves its metadata at `{issuer}/.well-known/openid-configuration` was reported as having no metadata at all. OIDC Discovery 1.0 section 4 defines that location and the MCP authorization spec requires clients to try it, so add it as a third candidate after the existing two. Real-world case: TikTok's Ads MCP server at https://business-api.tiktok.com/open_mcp/tt-ads-mcp-flat advertises an issuer under `/open_mcp/tt-ads-mcp-flat/oauth` and serves metadata only at the path-appended location. Discovery failed there while the server was configured correctly, forcing callers to register clients by hand. --- packages/core/sdk/src/oauth-discovery.test.ts | 26 +++++++++++ packages/core/sdk/src/oauth-discovery.ts | 45 +++++++++++++++---- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/packages/core/sdk/src/oauth-discovery.test.ts b/packages/core/sdk/src/oauth-discovery.test.ts index 7e30d19777..4562e91880 100644 --- a/packages/core/sdk/src/oauth-discovery.test.ts +++ b/packages/core/sdk/src/oauth-discovery.test.ts @@ -170,6 +170,32 @@ describe("discoverAuthorizationServerMetadata", () => { ), ); + it.effect("tries the OIDC path-appended well-known when the issuer is mounted on a path", () => + withOAuthFixture( + (request, baseUrl) => { + // Only the OIDC path-appended location is served, the way an + // authorization server mounted under a path commonly does it. + if (request.url === "/mcp/oauth/.well-known/openid-configuration") { + return sendJson({ + issuer: `${baseUrl}/mcp/oauth`, + authorization_endpoint: `${baseUrl}/mcp/oauth/authorize`, + token_endpoint: `${baseUrl}/mcp/oauth/token`, + code_challenge_methods_supported: ["S256"], + response_types_supported: ["code"], + }); + } + return notFound(); + }, + ({ baseUrl }) => + Effect.gen(function* () { + const result = yield* discoverAuthorizationServerMetadata(`${baseUrl}/mcp/oauth`); + expect(result).not.toBeNull(); + expect(result!.metadataUrl).toBe(`${baseUrl}/mcp/oauth/.well-known/openid-configuration`); + expect(result!.metadata.token_endpoint).toBe(`${baseUrl}/mcp/oauth/token`); + }), + ), + ); + it.effect("requires issuer + authorize + token endpoints", () => withOAuthFixture( () => sendJson({ issuer: "http://127.0.0.1" }), diff --git a/packages/core/sdk/src/oauth-discovery.ts b/packages/core/sdk/src/oauth-discovery.ts index 25786012cf..7b567fee2e 100644 --- a/packages/core/sdk/src/oauth-discovery.ts +++ b/packages/core/sdk/src/oauth-discovery.ts @@ -314,17 +314,42 @@ export const discoverProtectedResourceMetadata = ( // HttpClient boundary and timeout behavior. // --------------------------------------------------------------------------- -const wellKnownUrlFor = ( +interface WellKnownCandidate { + readonly algorithm: "oauth2" | "oidc"; + readonly url: string; +} + +const wellKnownCandidatesFor = ( issuerOrigin: string, - algorithm: "oauth2" | "oidc", issuerPath: string, -): string => { +): readonly WellKnownCandidate[] => { + const hasPath = issuerPath !== "" && issuerPath !== "/"; // Mirrors the library's own well-known composition so the URL we // surface matches what was actually fetched. - const suffix = algorithm === "oauth2" ? "oauth-authorization-server" : "openid-configuration"; - return issuerPath && issuerPath !== "/" - ? `${issuerOrigin}/.well-known/${suffix}${issuerPath}` - : `${issuerOrigin}/.well-known/${suffix}`; + const insertPath = (suffix: string) => + hasPath + ? `${issuerOrigin}/.well-known/${suffix}${issuerPath}` + : `${issuerOrigin}/.well-known/${suffix}`; + + const candidates: WellKnownCandidate[] = [ + { algorithm: "oauth2", url: insertPath("oauth-authorization-server") }, + { algorithm: "oidc", url: insertPath("openid-configuration") }, + ]; + + // OIDC Discovery 1.0 ยง4 appends the well-known segment to the issuer + // instead of inserting it after the origin, and the MCP authorization + // spec requires clients to try that form as well. An issuer mounted + // under a path may serve only this variant, in which case stopping + // after the two path-insertion URLs reports "no metadata" for an + // authorization server that is configured correctly. + if (hasPath) { + candidates.push({ + algorithm: "oidc", + url: `${issuerOrigin}${issuerPath}/.well-known/openid-configuration`, + }); + } + + return candidates; }; export const discoverAuthorizationServerMetadata = ( @@ -343,8 +368,10 @@ export const discoverAuthorizationServerMetadata = ( const issuerOrigin = `${issuerUrl.protocol}//${issuerUrl.host}`; const issuerPath = issuerUrl.pathname.replace(/\/+$/, ""); - for (const algorithm of ["oauth2", "oidc"] as const) { - const metadataUrl = wellKnownUrlFor(issuerOrigin, algorithm, issuerPath); + for (const { algorithm, url: metadataUrl } of wellKnownCandidatesFor( + issuerOrigin, + issuerPath, + )) { let request = HttpClientRequest.get(metadataUrl).pipe( HttpClientRequest.setHeader("accept", "application/json"), );