History

Merge pull request #689 from RhysSullivan/fix/oauth-dcr-scope-registration

fix(oauth): declare requested scopes in DCR body

Rhys Sullivan committed 6 months ago

Commit b87fecc2 parents 64c2eb7 b4041082 files · +60 −1Browse files

Unified · Split

2 files
  1. packages/core/sdk/src/oauth-discovery.test.ts +57 −0
  2. packages/core/sdk/src/oauth-discovery.ts +3 −1
packages/core/sdk/src/oauth-discovery.test.ts+57−0
@@ -14,6 +14,7 @@ type Handler = (url: string, init: RequestInit) => Response | Promise<Response>; · 13 unmodified lines
14const DcrRequestBody = Schema.Struct({14const 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});
18const decodeDcrRequestBody = Schema.decodeUnknownSync(Schema.fromJsonString(DcrRequestBody));19const 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
packages/core/sdk/src/oauth-discovery.ts+3−1
@@ -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,