From f1356ef88bd39cdcb9e0f63e6f2ed79f6ea370cf Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:58:58 +0530 Subject: [PATCH 1/2] fix(oauth): request the scope named by the server's 401 Bearer challenge --- .changeset/oauth-challenge-scope.md | 5 ++ packages/core/sdk/src/oauth-discovery.test.ts | 79 +++++++++++++++++ packages/core/sdk/src/oauth-discovery.ts | 54 ++++++++---- .../core/sdk/src/oauth-scope-union.test.ts | 87 +++++++++++++++++++ packages/core/sdk/src/oauth-service.ts | 19 ++-- 5 files changed, 224 insertions(+), 20 deletions(-) create mode 100644 .changeset/oauth-challenge-scope.md diff --git a/.changeset/oauth-challenge-scope.md b/.changeset/oauth-challenge-scope.md new file mode 100644 index 0000000000..b55a8c9ad7 --- /dev/null +++ b/.changeset/oauth-challenge-scope.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +MCP OAuth now requests the scope named in the server's 401 Bearer challenge (`WWW-Authenticate: Bearer … scope="mcp offline_access"`) when connecting. The challenge's scope takes precedence over the protected resource metadata's `scopes_supported`, and the metadata list remains the fallback when the challenge names no scope. Servers built on better-auth's MCP plugin list only the resource scope in their metadata and require `offline_access` via the challenge — connections to them previously received no refresh token and showed Expired as soon as the first access token lapsed. Fixes #2304. diff --git a/packages/core/sdk/src/oauth-discovery.test.ts b/packages/core/sdk/src/oauth-discovery.test.ts index 4562e91880..b7ded17365 100644 --- a/packages/core/sdk/src/oauth-discovery.test.ts +++ b/packages/core/sdk/src/oauth-discovery.test.ts @@ -112,6 +112,85 @@ describe("discoverProtectedResourceMetadata", () => { expect(result).not.toBeNull(); expect(result!.metadata.authorization_servers?.[0]).toBe(baseUrl); expect(result!.metadataUrl).toBe(`${baseUrl}/.well-known/oauth-protected-resource`); + expect(result!.challengeScope).toBeNull(); + }), + ), + ); + + it.effect("surfaces the Bearer challenge's scope attribute with the metadata it named", () => + withOAuthFixture( + (request, baseUrl) => { + if (request.url === "/mcp") { + return HttpServerResponse.empty({ + status: 401, + headers: { + "www-authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource/mcp", scope="mcp offline_access"`, + }, + }); + } + if (request.url === "/.well-known/oauth-protected-resource/mcp") { + return sendJson({ resource: `${baseUrl}/mcp`, scopes_supported: ["mcp"] }); + } + return notFound(); + }, + ({ baseUrl }) => + Effect.gen(function* () { + const result = yield* discoverProtectedResourceMetadata(`${baseUrl}/mcp`); + expect(result).not.toBeNull(); + expect(result!.challengeScope).toBe("mcp offline_access"); + expect(result!.metadataUrl).toBe(`${baseUrl}/.well-known/oauth-protected-resource/mcp`); + expect(result!.metadata.scopes_supported).toEqual(["mcp"]); + }), + ), + ); + + it.effect("a challenge without a scope attribute leaves challengeScope null", () => + withOAuthFixture( + (request, baseUrl) => { + if (request.url === "/mcp") { + return HttpServerResponse.empty({ + status: 401, + headers: { + "www-authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`, + }, + }); + } + if (request.url === "/.well-known/oauth-protected-resource/mcp") { + return sendJson({ resource: `${baseUrl}/mcp`, scopes_supported: ["mcp"] }); + } + return notFound(); + }, + ({ baseUrl }) => + Effect.gen(function* () { + const result = yield* discoverProtectedResourceMetadata(`${baseUrl}/mcp`); + expect(result).not.toBeNull(); + expect(result!.challengeScope).toBeNull(); + }), + ), + ); + + it.effect("a challenge scope is kept even when no resource_metadata URL is advertised", () => + withOAuthFixture( + (request, baseUrl) => { + if (request.url === "/mcp") { + return HttpServerResponse.empty({ + status: 401, + headers: { + "www-authenticate": `Bearer scope="mcp offline_access"`, + }, + }); + } + if (request.url === "/.well-known/oauth-protected-resource/mcp") { + return sendJson({ resource: `${baseUrl}/mcp`, scopes_supported: ["mcp"] }); + } + return notFound(); + }, + ({ baseUrl }) => + Effect.gen(function* () { + const result = yield* discoverProtectedResourceMetadata(`${baseUrl}/mcp`); + expect(result).not.toBeNull(); + expect(result!.challengeScope).toBe("mcp offline_access"); + expect(result!.metadataUrl).toBe(`${baseUrl}/.well-known/oauth-protected-resource/mcp`); }), ), ); diff --git a/packages/core/sdk/src/oauth-discovery.ts b/packages/core/sdk/src/oauth-discovery.ts index 260bfc6255..021ecb4fbe 100644 --- a/packages/core/sdk/src/oauth-discovery.ts +++ b/packages/core/sdk/src/oauth-discovery.ts @@ -282,9 +282,13 @@ const withResourceQueryParams = ( const discoverResourceMetadataChallenge = ( resourceUrl: string, options: DiscoveryRequestOptions, -): Effect.Effect => +): Effect.Effect< + { readonly metadataUrl: string | null; readonly scope: string | null }, + OAuthDiscoveryError +> => provideHttpClient( Effect.gen(function* () { + const noChallenge = { metadataUrl: null, scope: null }; yield* validateEndpointUrl(resourceUrl, "resource", options.endpointUrlPolicy); let request = HttpClientRequest.get( withResourceQueryParams(resourceUrl, options.resourceQueryParams), @@ -313,38 +317,58 @@ const discoverResourceMetadataChallenge = ( }), ), ); - if (response.status !== 401 && response.status !== 403) return null; + if (response.status !== 401 && response.status !== 403) return noChallenge; const header = response.headers["www-authenticate"]; - if (header === undefined) return null; + if (header === undefined) return noChallenge; const challenges = parseChallenges(header); - if (challenges === null) return null; + if (challenges === null) return noChallenge; + let scope: string | null = null; for (const challenge of challenges) { if (challenge.scheme !== "bearer") continue; + // The challenge's `scope` attribute (RFC 6750 §3) is the resource's own + // statement of what to request — including lifecycle scopes such as + // `offline_access` that RFC 9728 `scopes_supported` rightly omits. The + // MCP authorization spec prefers it over the metadata lists. + if (scope === null) { + const requested = challenge.params.get("scope"); + if (requested !== undefined && requested.trim().length > 0) scope = requested.trim(); + } const metadataUrl = challenge.params.get("resource_metadata"); if (metadataUrl === undefined) continue; - return yield* validateEndpointUrl( - metadataUrl, - "resource_metadata", - options.endpointUrlPolicy, - ); + return { + metadataUrl: yield* validateEndpointUrl( + metadataUrl, + "resource_metadata", + options.endpointUrlPolicy, + ), + scope, + }; } - return null; + return { metadataUrl: null, scope }; }).pipe(Effect.scoped), options, ); -/** Discover RFC 9728 metadata, preferring an explicit Bearer challenge URL. */ +/** Discover RFC 9728 metadata, preferring an explicit Bearer challenge URL. + * When the 401/403 challenge carries a `scope` attribute it is surfaced as + * `challengeScope` alongside the metadata it pointed at. */ export const discoverProtectedResourceMetadata = ( resourceUrl: string, options: DiscoveryRequestOptions = {}, ): Effect.Effect< - { readonly metadataUrl: string; readonly metadata: OAuthProtectedResourceMetadata } | null, + { + readonly metadataUrl: string; + readonly metadata: OAuthProtectedResourceMetadata; + readonly challengeScope: string | null; + } | null, OAuthDiscoveryError > => Effect.gen(function* () { - const advertisedUrl = yield* discoverResourceMetadataChallenge(resourceUrl, options); + const advertised = yield* discoverResourceMetadataChallenge(resourceUrl, options); const metadataUrls = - advertisedUrl === null ? buildResourceMetadataUrls(resourceUrl) : [advertisedUrl]; + advertised.metadataUrl === null + ? buildResourceMetadataUrls(resourceUrl) + : [advertised.metadataUrl]; for (const url of metadataUrls) { // A challenge may name another origin. Never forward resource credentials there. const sameOrigin = new URL(url).origin === new URL(resourceUrl).origin; @@ -389,7 +413,7 @@ export const discoverProtectedResourceMetadata = ( }), ), ); - return { metadataUrl: url, metadata }; + return { metadataUrl: url, metadata, challengeScope: advertised.scope }; } return null; }); diff --git a/packages/core/sdk/src/oauth-scope-union.test.ts b/packages/core/sdk/src/oauth-scope-union.test.ts index 97dbd3dce5..91429efa84 100644 --- a/packages/core/sdk/src/oauth-scope-union.test.ts +++ b/packages/core/sdk/src/oauth-scope-union.test.ts @@ -117,6 +117,15 @@ const makeMcpScopePlugin = (config: { readonly scopes: readonly string[] | null * SILENT on scopes; `[]` advertises an explicit empty set); `"error"` makes the * PRM endpoint return 500; `null`/absent makes it 404. `authServerScopes`, when * set, serves authorization-server metadata advertising those scopes. */ +/** Serve RFC 9728 protected-resource metadata and RFC 8414 authorization-server + * metadata on one origin, with configurable scopes. `prm`: an object serves + * protected-resource metadata (omit `scopesSupported` to leave the resource + * SILENT on scopes; `[]` advertises an explicit empty set); `"error"` makes the + * PRM endpoint return 500; `null`/absent makes it 404. `authServerScopes`, when + * set, serves authorization-server metadata advertising those scopes. + * `challenge`, when set, makes the resource itself (`/mcp`) answer the + * discovery probe with a 401 Bearer challenge naming its PRM, optionally with + * a `scope` attribute (the better-auth-style server of #2304). */ const serveMetadataServer = (config: { readonly prm?: | { @@ -128,11 +137,22 @@ const serveMetadataServer = (config: { readonly authServerScopes?: readonly string[]; readonly authServerAuthorizationPath?: string; readonly authServerTokenPath?: string; + readonly challenge?: { readonly scope?: string }; }) => Effect.gen(function* () { const baseUrlRef = { value: "" }; const respond = (path: string): HttpServerResponse.HttpServerResponse => { const base = baseUrlRef.value; + if (path === "/mcp" && config.challenge !== undefined) { + return HttpServerResponse.empty({ + status: 401, + headers: { + "www-authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"${ + config.challenge.scope === undefined ? "" : `, scope="${config.challenge.scope}"` + }`, + }, + }); + } if (path.startsWith("/.well-known/oauth-protected-resource")) { if (config.prm === "error") return HttpServerResponse.jsonUnsafe({ error: "boom" }, { status: 500 }); @@ -528,6 +548,73 @@ describe("oauth.start integration-driven scopes", () => { ), ); + it.effect( + "(m) for MCP, the 401 challenge's scope attribute wins over the resource's scopes_supported", + () => + Effect.scoped( + Effect.gen(function* () { + // The better-auth-style server of #2304: the 401 challenge requests + // `mcp offline_access` (its PRM correctly per RFC 9728 lists only the + // resource scope — `offline_access` is not a scope on the resource). + // The challenge is the resource's own statement of what to request, + // so it is authoritative and supplies the lifecycle scope whose + // absence made every token expire without a refresh token. + const server = yield* serveMetadataServer({ + challenge: { scope: "mcp offline_access" }, + prm: { scopesSupported: ["mcp"] }, + authServerScopes: ["mcp"], + }); + const executor = yield* setupMcpScopeClient(server); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([ + "mcp", + "offline_access", + ]); + }), + ), + ); + + it.effect( + "(n) for MCP, a 401 challenge without a scope attribute falls back to the resource's scopes_supported", + () => + Effect.scoped( + Effect.gen(function* () { + // Same 401 shape as #1981 (challenge names only resource_metadata). + // No `scope` attribute ⇒ the PRM's list remains, un-broadened. + const server = yield* serveMetadataServer({ + challenge: {}, + prm: { scopesSupported: ["mcp"] }, + authServerScopes: ["should:not:appear"], + }); + const executor = yield* setupMcpScopeClient(server); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["mcp"]); + }), + ), + ); + it.effect( "(e) for MCP, discovers scopes from a cross-origin authorization server named in resource metadata", () => diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 770437d908..2b57434851 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -851,11 +851,17 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { (protectedResource?.metadata.authorization_servers ?? []).slice(0, MAX_DISCOVERY_AUTH_SERVERS); // Discover the scopes to request when the integration declares none — only - // reached for integrations that opt in (MCP-style). The resource's own RFC - // 9728 `scopes_supported` is authoritative when present, even when empty (§2 - // defines the field; §7.2 cautions against requesting more than it lists). - // Only when the resource is SILENT do we read the scopes advertised by the - // authorization servers it NAMES (RFC 8414) — we never probe arbitrary URLs. + // reached for integrations that opt in (MCP-style). Preference order: + // 1. the Bearer challenge's `scope` attribute (RFC 6750 §3) — the resource + // telling us what to request, including lifecycle scopes such as + // `offline_access` that RFC 9728 `scopes_supported` rightly omits and + // that the MCP authorization spec prefers here; + // 2. the resource's RFC 9728 `scopes_supported` — authoritative when + // present, even when empty (§2 defines the field; §7.2 cautions against + // requesting more than it lists); + // 3. only when the resource is SILENT on scopes, the scopes advertised by + // the authorization servers it NAMES (RFC 8414) — we never probe + // arbitrary URLs. const discoverScopesForResource = ( resource: string | null, ): Effect.Effect => @@ -873,6 +879,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { resource, discoveryOptions, ); + const challengeScope = protectedResource?.challengeScope; + if (challengeScope != null) return capScopes(challengeScope.split(/\s+/).filter(Boolean)); + const resourceScopes = protectedResource?.metadata.scopes_supported; if (resourceScopes !== undefined) return capScopes(resourceScopes); From e20123fd0ce8d92f1944ba6fb168a854f45f3a7a Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:09:15 +0530 Subject: [PATCH 2/2] ci: re-run after an upstream emulator outage (emulators.dev Worker threw exception)