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 | const DcrRequestBody = Schema.Struct({ | 14 | const DcrRequestBody = Schema.Struct({ |
| 15 | redirect_uris: Schema.Array(Schema.String), | 15 | redirect_uris: Schema.Array(Schema.String), |
| 16 | token_endpoint_auth_method: Schema.String, | 16 | token_endpoint_auth_method: Schema.String, |
| 17 | scope: Schema.optional(Schema.String), | ||
| 17 | }); | 18 | }); |
| 18 | const decodeDcrRequestBody = Schema.decodeUnknownSync(Schema.fromJsonString(DcrRequestBody)); | 19 | const decodeDcrRequestBody = Schema.decodeUnknownSync(Schema.fromJsonString(DcrRequestBody)); |
| 19 | 20 | ||
| @@ -349,6 +350,62 @@ describe("beginDynamicAuthorization", () => { · 329 unmodified lines | |||
| 349 | expect(result.state.resourceMetadata?.resource).toBe("https://backboard.railway.com"); | 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 | it("skips discovery + DCR when previousState is provided", async () => { | 409 | it("skips discovery + DCR when previousState is provided", async () => { |
| 353 | const { calls } = installFetchRouter([]); | 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 | const baseClientMetadata: DynamicClientMetadata = { | 645 | const baseClientMetadata: DynamicClientMetadata = { |
| 644 | grant_types: ["authorization_code", "refresh_token"], | 646 | grant_types: ["authorization_code", "refresh_token"], |
| 645 | response_types: ["code"], | 647 | response_types: ["code"], |
| 646 | token_endpoint_auth_method: "none", | 648 | token_endpoint_auth_method: "none", |
| 647 | client_name: "Executor", | 649 | client_name: "Executor", |
| 650 | ...(scopes.length > 0 ? { scope: scopes.join(" ") } : {}), | ||
| 648 | ...(input.clientMetadata ?? {}), | 651 | ...(input.clientMetadata ?? {}), |
| 649 | redirect_uris: input.clientMetadata?.redirect_uris ?? [input.redirectUrl], | 652 | redirect_uris: input.clientMetadata?.redirect_uris ?? [input.redirectUrl], |
| 650 | }; | 653 | }; |
| @@ -669,7 +672,6 @@ export const beginDynamicAuthorization = ( · 18 unmodified lines | |||
| 669 | 672 | ||
| 670 | const codeVerifier = createPkceCodeVerifier(); | 673 | const codeVerifier = createPkceCodeVerifier(); |
| 671 | const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier)); | 674 | const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier)); |
| 672 | const scopes = input.scopes ?? authServer.metadata.scopes_supported ?? []; | ||
| 673 | 675 | ||
| 674 | const authorizationUrl = buildAuthorizationUrl({ | 676 | const authorizationUrl = buildAuthorizationUrl({ |
| 675 | authorizationUrl: authServer.metadata.authorization_endpoint, | 677 | authorizationUrl: authServer.metadata.authorization_endpoint, |