fix(oauth): declare requested scopes in DCR body
Unified · Split
| @@ -14,6 +14,7 @@ type Handler = (url: string, init: RequestInit) => Response | Promise<Response>; · 13 unmodified lines | ||
| 14 | 14 | const DcrRequestBody = Schema.Struct({ |
| 15 | 15 | redirect_uris: Schema.Array(Schema.String), |
| 16 | 16 | token_endpoint_auth_method: Schema.String, |
| 17 | + scope: Schema.optional(Schema.String), | |
| 17 | 18 | }); |
| 18 | 19 | const decodeDcrRequestBody = Schema.decodeUnknownSync(Schema.fromJsonString(DcrRequestBody)); |
| 19 | 20 | |
| @@ -349,6 +350,62 @@ describe("beginDynamicAuthorization", () => { · 329 unmodified lines | ||
| 349 | 350 | expect(result.state.resourceMetadata?.resource).toBe("https://backboard.railway.com"); |
| 350 | 351 | }); |
| 351 | 352 | |
| 353 | + it("declares requested scopes in the DCR body so Auth0-style servers don't reject /authorize", async () => { | |
| 354 | + const { calls } = installFetchRouter([ | |
| 355 | + { | |
| 356 | + match: (u) => u === "https://mcp.grata.com/.well-known/oauth-protected-resource", | |
| 357 | + handle: () => new Response(null, { status: 404 }), | |
| 358 | + }, | |
| 359 | + { | |
| 360 | + match: (u) => u === "https://mcp.grata.com/.well-known/oauth-authorization-server", | |
| 361 | + handle: () => | |
| 362 | + new Response( | |
| 363 | + JSON.stringify({ | |
| 364 | + issuer: "https://mcp.grata.com/", | |
| 365 | + authorization_endpoint: "https://mcp.grata.com/authorize", | |
| 366 | + token_endpoint: "https://mcp.grata.com/token", | |
| 367 | + registration_endpoint: "https://mcp.grata.com/register", | |
| 368 | + scopes_supported: ["openid", "profile", "email", "offline_access"], | |
| 369 | + response_types_supported: ["code"], | |
| 370 | + grant_types_supported: ["authorization_code", "refresh_token"], | |
| 371 | + code_challenge_methods_supported: ["S256"], | |
| 372 | + }), | |
| 373 | + { status: 200, headers: { "content-type": "application/json" } }, | |
| 374 | + ), | |
| 375 | + }, | |
| 376 | + { | |
| 377 | + match: (u) => u === "https://mcp.grata.com/register", | |
| 378 | + handle: () => | |
| 379 | + new Response( | |
| 380 | + JSON.stringify({ | |
| 381 | + client_id: "grata-client-id", | |
| 382 | + redirect_uris: ["https://app.example/cb"], | |
| 383 | + token_endpoint_auth_method: "none", | |
| 384 | + scope: "openid profile email offline_access", | |
| 385 | + }), | |
| 386 | + { status: 201, headers: { "content-type": "application/json" } }, | |
| 387 | + ), | |
| 388 | + }, | |
| 389 | + ]); | |
| 390 | + | |
| 391 | + const result = await Effect.runPromise( | |
| 392 | + beginDynamicAuthorization({ | |
| 393 | + endpoint: "https://mcp.grata.com/", | |
| 394 | + redirectUrl: "https://app.example/cb", | |
| 395 | + state: "state-grata", | |
| 396 | + }), | |
| 397 | + ); | |
| 398 | + | |
| 399 | + const dcrCall = calls.find((c) => c.url === "https://mcp.grata.com/register"); | |
| 400 | + expect(dcrCall).toBeDefined(); | |
| 401 | + const body = decodeDcrRequestBody(String(dcrCall!.init.body)); | |
| 402 | + expect(body.scope).toBe("openid profile email offline_access"); | |
| 403 | + | |
| 404 | + const authUrl = new URL(result.authorizationUrl); | |
| 405 | + expect(authUrl.searchParams.get("scope")).toBe("openid profile email offline_access"); | |
| 406 | + expect(authUrl.searchParams.get("client_id")).toBe("grata-client-id"); | |
| 407 | + }); | |
| 408 | + | |
| 352 | 409 | it("skips discovery + DCR when previousState is provided", async () => { |
| 353 | 410 | const { calls } = installFetchRouter([]); |
| 354 | 411 | |
| @@ -640,11 +640,14 @@ export const beginDynamicAuthorization = ( · 639 unmodified lines | ||
| 640 | 640 | }); |
| 641 | 641 | } |
| 642 | 642 | |
| 643 | + const scopes = input.scopes ?? authServer.metadata.scopes_supported ?? []; | |
| 644 | + | |
| 643 | 645 | const baseClientMetadata: DynamicClientMetadata = { |
| 644 | 646 | grant_types: ["authorization_code", "refresh_token"], |
| 645 | 647 | response_types: ["code"], |
| 646 | 648 | token_endpoint_auth_method: "none", |
| 647 | 649 | client_name: "Executor", |
| 650 | + ...(scopes.length > 0 ? { scope: scopes.join(" ") } : {}), | |
| 648 | 651 | ...(input.clientMetadata ?? {}), |
| 649 | 652 | redirect_uris: input.clientMetadata?.redirect_uris ?? [input.redirectUrl], |
| 650 | 653 | }; |
| @@ -669,7 +672,6 @@ export const beginDynamicAuthorization = ( · 18 unmodified lines | ||
| 669 | 672 | |
| 670 | 673 | const codeVerifier = createPkceCodeVerifier(); |
| 671 | 674 | const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier)); |
| 672 | − const scopes = input.scopes ?? authServer.metadata.scopes_supported ?? []; | |
| 673 | 675 | |
| 674 | 676 | const authorizationUrl = buildAuthorizationUrl({ |
| 675 | 677 | authorizationUrl: authServer.metadata.authorization_endpoint, |