diff --git a/.changeset/preserve-oauth-scope-surface.md b/.changeset/preserve-oauth-scope-surface.md new file mode 100644 index 000000000..2056ba413 --- /dev/null +++ b/.changeset/preserve-oauth-scope-surface.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Preserve an integration's declared OAuth scopes when same-origin authorization-server metadata describes a different authorization or token endpoint. diff --git a/packages/core/sdk/src/oauth-scope-union.test.ts b/packages/core/sdk/src/oauth-scope-union.test.ts index 2895878fd..744e6f941 100644 --- a/packages/core/sdk/src/oauth-scope-union.test.ts +++ b/packages/core/sdk/src/oauth-scope-union.test.ts @@ -107,6 +107,8 @@ const serveMetadataServer = (config: { | "error" | null; readonly authServerScopes?: readonly string[]; + readonly authServerAuthorizationPath?: string; + readonly authServerTokenPath?: string; }) => Effect.gen(function* () { const baseUrlRef = { value: "" }; @@ -133,8 +135,8 @@ const serveMetadataServer = (config: { ) { return HttpServerResponse.jsonUnsafe({ issuer: base, - authorization_endpoint: `${base}/authorize`, - token_endpoint: `${base}/token`, + authorization_endpoint: `${base}${config.authServerAuthorizationPath ?? "/authorize"}`, + token_endpoint: `${base}${config.authServerTokenPath ?? "/token"}`, response_types_supported: ["code"], code_challenge_methods_supported: ["S256"], scopes_supported: config.authServerScopes, @@ -325,6 +327,49 @@ describe("oauth.start integration-driven scopes", () => { ), ); + it.effect("keeps declared scopes when same-origin metadata describes another OAuth surface", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveMetadataServer({ + authServerScopes: ["mcp:connect"], + authServerAuthorizationPath: "/oauth/mcp", + }); + const plugins = [ + memoryCredentialsPlugin(), + makeScopePlugin({ scopes: ["file_content:read", "file_comments:write"] }), + ] as const; + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + + 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([ + "file_content:read", + "file_comments:write", + ]); + }), + ), + ); + it.effect( "(d) for MCP, the resource's own scopes_supported wins over a divergent authorization server", () => diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index ea1fd3fa3..5130dcb77 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -54,6 +54,7 @@ import { discoverProtectedResourceMetadata, OAuthDiscoveryError, registerDynamicClient as registerDynamicClientDcr, + type OAuthAuthorizationServerMetadata, } from "./oauth-discovery"; import { assertSupportedOAuthEndpointUrl, @@ -414,6 +415,14 @@ const canonicalUrlString = (value: string): string => { return url.toString(); }; +const oauthMetadataMatchesClient = ( + client: Pick, + metadata: OAuthAuthorizationServerMetadata, +): boolean => + canonicalUrlString(metadata.authorization_endpoint) === + canonicalUrlString(client.authorizationUrl) && + canonicalUrlString(metadata.token_endpoint) === canonicalUrlString(client.tokenUrl); + const isWellKnownOAuthMetadataUrl = (value: string): boolean => { const path = new URL(value.trim()).pathname.toLowerCase(); return ( @@ -494,7 +503,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { Effect.catch(() => Effect.succeed(null)), Effect.provide(httpClientLayer), ); - return intersectScopes(requestedScopes, as?.metadata.scopes_supported); + if (!as || !oauthMetadataMatchesClient(client, as.metadata)) return requestedScopes; + return intersectScopes(requestedScopes, as.metadata.scopes_supported); }).pipe(Effect.catch(() => Effect.succeed(requestedScopes))); // Caps on server-controlled discovery input — a hostile or buggy server must