Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/oauth-challenge-scope.md
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 79 additions & 0 deletions packages/core/sdk/src/oauth-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}),
),
);
Expand Down
54 changes: 39 additions & 15 deletions packages/core/sdk/src/oauth-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,13 @@ const withResourceQueryParams = (
const discoverResourceMetadataChallenge = (
resourceUrl: string,
options: DiscoveryRequestOptions,
): Effect.Effect<string | null, OAuthDiscoveryError> =>
): 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),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -389,7 +413,7 @@ export const discoverProtectedResourceMetadata = (
}),
),
);
return { metadataUrl: url, metadata };
return { metadataUrl: url, metadata, challengeScope: advertised.scope };
}
return null;
});
Expand Down
87 changes: 87 additions & 0 deletions packages/core/sdk/src/oauth-scope-union.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?:
| {
Expand All @@ -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 });
Expand Down Expand Up @@ -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",
() =>
Expand Down
19 changes: 14 additions & 5 deletions packages/core/sdk/src/oauth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<readonly string[], OAuthDiscoveryError> =>
Expand All @@ -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);

Expand Down
Loading