| @@ -107,6 +107,8 @@ const serveMetadataServer = (config: { · 106 unmodified lines |
| 107 | | "error" | 107 | | "error" |
| 108 | | null; | 108 | | null; |
| 109 | readonly authServerScopes?: readonly string[]; | 109 | readonly authServerScopes?: readonly string[]; |
| | 110 | readonly authServerAuthorizationPath?: string; |
| | 111 | readonly authServerTokenPath?: string; |
| 110 | }) => | 112 | }) => |
| 111 | Effect.gen(function* () { | 113 | Effect.gen(function* () { |
| 112 | const baseUrlRef = { value: "" }; | 114 | const baseUrlRef = { value: "" }; |
| @@ -133,8 +135,8 @@ const serveMetadataServer = (config: { · 20 unmodified lines |
| 133 | ) { | 135 | ) { |
| 134 | return HttpServerResponse.jsonUnsafe({ | 136 | return HttpServerResponse.jsonUnsafe({ |
| 135 | issuer: base, | 137 | issuer: base, |
| 136 | authorization_endpoint: `${base}/authorize`, | 138 | authorization_endpoint: `${base}${config.authServerAuthorizationPath ?? "/authorize"}`, |
| 137 | token_endpoint: `${base}/token`, | 139 | token_endpoint: `${base}${config.authServerTokenPath ?? "/token"}`, |
| 138 | response_types_supported: ["code"], | 140 | response_types_supported: ["code"], |
| 139 | code_challenge_methods_supported: ["S256"], | 141 | code_challenge_methods_supported: ["S256"], |
| 140 | scopes_supported: config.authServerScopes, | 142 | scopes_supported: config.authServerScopes, |
| @@ -325,6 +327,49 @@ describe("oauth.start integration-driven scopes", () => { · 184 unmodified lines |
| 325 | ), | 327 | ), |
| 326 | ); | 328 | ); |
| 327 | | 329 | |
| | 330 | it.effect("keeps declared scopes when same-origin metadata describes another OAuth surface", () => |
| | 331 | Effect.scoped( |
| | 332 | Effect.gen(function* () { |
| | 333 | const server = yield* serveMetadataServer({ |
| | 334 | authServerScopes: ["mcp:connect"], |
| | 335 | authServerAuthorizationPath: "/oauth/mcp", |
| | 336 | }); |
| | 337 | const plugins = [ |
| | 338 | memoryCredentialsPlugin(), |
| | 339 | makeScopePlugin({ scopes: ["file_content:read", "file_comments:write"] }), |
| | 340 | ] as const; |
| | 341 | const { executor } = yield* makeTestWorkspaceHarness({ plugins }); |
| | 342 | yield* executor.acme.seed(); |
| | 343 | |
| | 344 | yield* executor.oauth.createClient({ |
| | 345 | owner: "org", |
| | 346 | slug: CLIENT, |
| | 347 | authorizationUrl: server.authorizationEndpoint, |
| | 348 | tokenUrl: server.tokenEndpoint, |
| | 349 | grant: "authorization_code", |
| | 350 | clientId: "test-client", |
| | 351 | clientSecret: "test-secret", |
| | 352 | }); |
| | 353 | |
| | 354 | const started = yield* executor.oauth.start({ |
| | 355 | owner: "org", |
| | 356 | client: CLIENT, |
| | 357 | clientOwner: "org", |
| | 358 | name: ConnectionName.make("main"), |
| | 359 | integration: INTEG, |
| | 360 | template: TEMPLATE, |
| | 361 | }); |
| | 362 | expect(started.status).toBe("redirect"); |
| | 363 | if (started.status !== "redirect") return; |
| | 364 | |
| | 365 | expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([ |
| | 366 | "file_content:read", |
| | 367 | "file_comments:write", |
| | 368 | ]); |
| | 369 | }), |
| | 370 | ), |
| | 371 | ); |
| | 372 | |
| 328 | it.effect( | 373 | it.effect( |
| 329 | "(d) for MCP, the resource's own scopes_supported wins over a divergent authorization server", | 374 | "(d) for MCP, the resource's own scopes_supported wins over a divergent authorization server", |
| 330 | () => | 375 | () => |