History

Merge pull request #691 from RhysSullivan/fix/mcp-oauth-hardening

fix(oauth): MCP authorization spec hardening

Rhys Sullivan committed 6 months ago

Commit e42476a2 parents 63cdca3 c37e5a96 files · +738 −30Browse files

Unified · Split

6 files
  1. packages/core/sdk/src/oauth-discovery.test.ts +501 −4
  2. packages/core/sdk/src/oauth-discovery.ts +115 −14
  3. packages/core/sdk/src/oauth-helpers.test.ts +60 −0
  4. packages/core/sdk/src/oauth-helpers.ts +26 −5
  5. packages/core/sdk/src/oauth-service.ts +27 −7
  6. packages/core/sdk/src/oauth.ts +9 −0
packages/core/sdk/src/oauth-discovery.test.ts+501−4
@@ -4,6 +4,7 @@ import { Cause, Effect, Exit, Schema } from "effect"; · 3 unmodified lines
44 import {
55 OAuthDiscoveryError,
66 beginDynamicAuthorization,
7+ canonicalResourceUrl,
78 discoverAuthorizationServerMetadata,
89 discoverProtectedResourceMetadata,
910 registerDynamicClient,
@@ -15,6 +16,7 @@ const DcrRequestBody = Schema.Struct({ · 5 unmodified lines
1516 redirect_uris: Schema.Array(Schema.String),
1617 token_endpoint_auth_method: Schema.String,
1718 scope: Schema.optional(Schema.String),
19+ client_uri: Schema.optional(Schema.String),
1820 });
1921 const decodeDcrRequestBody = Schema.decodeUnknownSync(Schema.fromJsonString(DcrRequestBody));
2022
@@ -34,6 +36,18 @@ const installFetchRouter = ( · 13 unmodified lines
3436
3537 const originalFetch = globalThis.fetch;
3638
39+describe("canonicalResourceUrl", () => {
40+ it("lowercases scheme + host, drops trailing slash, fragment, and query", () => {
41+ expect(canonicalResourceUrl("https://API.Example.com/v1/mcp/")).toBe(
42+ "https://api.example.com/v1/mcp",
43+ );
44+ expect(canonicalResourceUrl("HTTPS://api.example.com/v1/mcp?x=1#frag")).toBe(
45+ "https://api.example.com/v1/mcp",
46+ );
47+ expect(canonicalResourceUrl("https://api.example.com/")).toBe("https://api.example.com");
48+ });
49+});
50+
3751 describe("discoverProtectedResourceMetadata", () => {
3852 afterEach(() => {
3953 globalThis.fetch = originalFetch;
@@ -350,7 +364,7 @@ describe("beginDynamicAuthorization", () => { · 310 unmodified lines
350364 expect(result.state.resourceMetadata?.resource).toBe("https://backboard.railway.com");
351365 });
352366
353− it("declares requested scopes in the DCR body so Auth0-style servers don't reject /authorize", async () => {
367+ it("declares requested scopes in the DCR body when caller passes them explicitly", async () => {
354368 const { calls } = installFetchRouter([
355369 {
356370 match: (u) => u === "https://mcp.grata.com/.well-known/oauth-protected-resource",
@@ -381,7 +395,7 @@ describe("beginDynamicAuthorization", () => { · 24 unmodified lines
381395 client_id: "grata-client-id",
382396 redirect_uris: ["https://app.example/cb"],
383397 token_endpoint_auth_method: "none",
384− scope: "openid profile email offline_access",
398+ scope: "openid offline_access",
385399 }),
386400 { status: 201, headers: { "content-type": "application/json" } },
387401 ),
@@ -393,19 +407,502 @@ describe("beginDynamicAuthorization", () => { · 5 unmodified lines
393407 endpoint: "https://mcp.grata.com/",
394408 redirectUrl: "https://app.example/cb",
395409 state: "state-grata",
410+ scopes: ["openid", "offline_access"],
396411 }),
397412 );
398413
399414 const dcrCall = calls.find((c) => c.url === "https://mcp.grata.com/register");
400415 expect(dcrCall).toBeDefined();
401416 const body = decodeDcrRequestBody(String(dcrCall!.init.body));
402− expect(body.scope).toBe("openid profile email offline_access");
417+ expect(body.scope).toBe("openid offline_access");
403418
404419 const authUrl = new URL(result.authorizationUrl);
405− expect(authUrl.searchParams.get("scope")).toBe("openid profile email offline_access");
420+ expect(authUrl.searchParams.get("scope")).toBe("openid offline_access");
406421 expect(authUrl.searchParams.get("client_id")).toBe("grata-client-id");
407422 });
408423
424+ it("requests only PRM scopes_supported when advertised (RFC 9728 §2 limited scope)", async () => {
425+ const { calls } = installFetchRouter([
426+ {
427+ match: (u) => u === "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp",
428+ handle: () =>
429+ new Response(
430+ JSON.stringify({
431+ resource: "https://api.example.com/v1/mcp",
432+ authorization_servers: ["https://as.example.com"],
433+ scopes_supported: ["mcp:read"],
434+ }),
435+ { status: 200, headers: { "content-type": "application/json" } },
436+ ),
437+ },
438+ {
439+ match: (u) => u === "https://as.example.com/.well-known/oauth-authorization-server",
440+ handle: () =>
441+ new Response(
442+ JSON.stringify({
443+ issuer: "https://as.example.com",
444+ authorization_endpoint: "https://as.example.com/authorize",
445+ token_endpoint: "https://as.example.com/token",
446+ registration_endpoint: "https://as.example.com/register",
447+ scopes_supported: ["openid", "profile", "mcp:read", "mcp:admin", "offline_access"],
448+ response_types_supported: ["code"],
449+ code_challenge_methods_supported: ["S256"],
450+ }),
451+ { status: 200, headers: { "content-type": "application/json" } },
452+ ),
453+ },
454+ {
455+ match: (u) => u === "https://as.example.com/register",
456+ handle: () =>
457+ new Response(
458+ JSON.stringify({
459+ client_id: "narrow-scope-client",
460+ redirect_uris: ["https://app/cb"],
461+ token_endpoint_auth_method: "none",
462+ }),
463+ { status: 201, headers: { "content-type": "application/json" } },
464+ ),
465+ },
466+ ]);
467+
468+ const result = await Effect.runPromise(
469+ beginDynamicAuthorization({
470+ endpoint: "https://api.example.com/v1/mcp",
471+ redirectUrl: "https://app/cb",
472+ state: "s",
473+ }),
474+ );
475+
476+ const dcrCall = calls.find((c) => c.url === "https://as.example.com/register");
477+ const body = decodeDcrRequestBody(String(dcrCall!.init.body));
478+ expect(body.scope).toBe("mcp:read");
479+
480+ const authUrl = new URL(result.authorizationUrl);
481+ expect(authUrl.searchParams.get("scope")).toBe("mcp:read");
482+ });
483+
484+ it("requests empty scope when only AS-level scopes_supported is advertised (RFC 9728 §2)", async () => {
485+ const { calls } = installFetchRouter([
486+ {
487+ match: (u) => u === "https://only-as.example.com/.well-known/oauth-protected-resource",
488+ handle: () => new Response(null, { status: 404 }),
489+ },
490+ {
491+ match: (u) => u === "https://only-as.example.com/.well-known/oauth-authorization-server",
492+ handle: () =>
493+ new Response(
494+ JSON.stringify({
495+ issuer: "https://only-as.example.com",
496+ authorization_endpoint: "https://only-as.example.com/authorize",
497+ token_endpoint: "https://only-as.example.com/token",
498+ registration_endpoint: "https://only-as.example.com/register",
499+ scopes_supported: ["openid", "profile", "admin", "offline_access"],
500+ response_types_supported: ["code"],
501+ code_challenge_methods_supported: ["S256"],
502+ }),
503+ { status: 200, headers: { "content-type": "application/json" } },
504+ ),
505+ },
506+ {
507+ match: (u) => u === "https://only-as.example.com/register",
508+ handle: () =>
509+ new Response(
510+ JSON.stringify({
511+ client_id: "no-scope-client",
512+ redirect_uris: ["https://app/cb"],
513+ token_endpoint_auth_method: "none",
514+ }),
515+ { status: 201, headers: { "content-type": "application/json" } },
516+ ),
517+ },
518+ ]);
519+
520+ const result = await Effect.runPromise(
521+ beginDynamicAuthorization({
522+ endpoint: "https://only-as.example.com/",
523+ redirectUrl: "https://app/cb",
524+ state: "s",
525+ }),
526+ );
527+
528+ const dcrCall = calls.find((c) => c.url === "https://only-as.example.com/register");
529+ const body = decodeDcrRequestBody(String(dcrCall!.init.body));
530+ expect(body.scope).toBeUndefined();
531+
532+ const authUrl = new URL(result.authorizationUrl);
533+ expect(authUrl.searchParams.get("scope")).toBe("");
534+ });
535+
536+ it("includes RFC 8707 resource parameter on the authorization URL (PRM resource claim)", async () => {
537+ installFetchRouter([
538+ {
539+ match: (u) => u === "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp",
540+ handle: () =>
541+ new Response(
542+ JSON.stringify({
543+ resource: "https://api.example.com/canonical-id",
544+ authorization_servers: ["https://api.example.com"],
545+ }),
546+ { status: 200, headers: { "content-type": "application/json" } },
547+ ),
548+ },
549+ {
550+ match: (u) => u === "https://api.example.com/.well-known/oauth-authorization-server",
551+ handle: () =>
552+ new Response(
553+ JSON.stringify({
554+ issuer: "https://api.example.com",
555+ authorization_endpoint: "https://api.example.com/authorize",
556+ token_endpoint: "https://api.example.com/token",
557+ registration_endpoint: "https://api.example.com/register",
558+ response_types_supported: ["code"],
559+ code_challenge_methods_supported: ["S256"],
560+ }),
561+ { status: 200, headers: { "content-type": "application/json" } },
562+ ),
563+ },
564+ {
565+ match: (u) => u === "https://api.example.com/register",
566+ handle: () =>
567+ new Response(
568+ JSON.stringify({
569+ client_id: "res-client",
570+ redirect_uris: ["https://app/cb"],
571+ token_endpoint_auth_method: "none",
572+ }),
573+ { status: 201, headers: { "content-type": "application/json" } },
574+ ),
575+ },
576+ ]);
577+
578+ const result = await Effect.runPromise(
579+ beginDynamicAuthorization({
580+ endpoint: "https://api.example.com/v1/mcp",
581+ redirectUrl: "https://app/cb",
582+ state: "s",
583+ }),
584+ );
585+
586+ const authUrl = new URL(result.authorizationUrl);
587+ expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/canonical-id");
588+ expect(result.state.resource).toBe("https://api.example.com/canonical-id");
589+ });
590+
591+ it("falls back to canonical endpoint URL for the resource parameter when PRM is absent", async () => {
592+ installFetchRouter([
593+ {
594+ match: (u) => u === "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp",
595+ handle: () => new Response(null, { status: 404 }),
596+ },
597+ {
598+ match: (u) => u === "https://api.example.com/.well-known/oauth-protected-resource",
599+ handle: () => new Response(null, { status: 404 }),
600+ },
601+ {
602+ match: (u) => u === "https://api.example.com/.well-known/oauth-authorization-server",
603+ handle: () =>
604+ new Response(
605+ JSON.stringify({
606+ issuer: "https://api.example.com",
607+ authorization_endpoint: "https://api.example.com/authorize",
608+ token_endpoint: "https://api.example.com/token",
609+ registration_endpoint: "https://api.example.com/register",
610+ response_types_supported: ["code"],
611+ code_challenge_methods_supported: ["S256"],
612+ }),
613+ { status: 200, headers: { "content-type": "application/json" } },
614+ ),
615+ },
616+ {
617+ match: (u) => u === "https://api.example.com/register",
618+ handle: () =>
619+ new Response(
620+ JSON.stringify({
621+ client_id: "ep-client",
622+ redirect_uris: ["https://app/cb"],
623+ token_endpoint_auth_method: "none",
624+ }),
625+ { status: 201, headers: { "content-type": "application/json" } },
626+ ),
627+ },
628+ ]);
629+
630+ const result = await Effect.runPromise(
631+ beginDynamicAuthorization({
632+ endpoint: "https://API.example.com/v1/mcp/",
633+ redirectUrl: "https://app/cb",
634+ state: "s",
635+ }),
636+ );
637+
638+ const authUrl = new URL(result.authorizationUrl);
639+ expect(authUrl.searchParams.get("resource")).toBe("https://api.example.com/v1/mcp");
640+ });
641+
642+ it("includes client_uri in the DCR body (RFC 7591 §2 RECOMMENDED)", async () => {
643+ const { calls } = installFetchRouter([
644+ {
645+ match: (u) => u === "https://only-as.example.com/.well-known/oauth-protected-resource",
646+ handle: () => new Response(null, { status: 404 }),
647+ },
648+ {
649+ match: (u) => u === "https://only-as.example.com/.well-known/oauth-authorization-server",
650+ handle: () =>
651+ new Response(
652+ JSON.stringify({
653+ issuer: "https://only-as.example.com",
654+ authorization_endpoint: "https://only-as.example.com/authorize",
655+ token_endpoint: "https://only-as.example.com/token",
656+ registration_endpoint: "https://only-as.example.com/register",
657+ response_types_supported: ["code"],
658+ code_challenge_methods_supported: ["S256"],
659+ }),
660+ { status: 200, headers: { "content-type": "application/json" } },
661+ ),
662+ },
663+ {
664+ match: (u) => u === "https://only-as.example.com/register",
665+ handle: () =>
666+ new Response(
667+ JSON.stringify({
668+ client_id: "uri-client",
669+ redirect_uris: ["https://app/cb"],
670+ token_endpoint_auth_method: "none",
671+ }),
672+ { status: 201, headers: { "content-type": "application/json" } },
673+ ),
674+ },
675+ ]);
676+
677+ await Effect.runPromise(
678+ beginDynamicAuthorization({
679+ endpoint: "https://only-as.example.com/",
680+ redirectUrl: "https://app/cb",
681+ state: "s",
682+ }),
683+ );
684+
685+ const dcrCall = calls.find((c) => c.url === "https://only-as.example.com/register");
686+ const body = decodeDcrRequestBody(String(dcrCall!.init.body));
687+ expect(body.client_uri).toBe("https://executor.sh");
688+ });
689+
690+ it("negotiates client_secret_post when the AS does not advertise 'none' (Clay-style)", async () => {
691+ const { calls } = installFetchRouter([
692+ {
693+ match: (u) => u === "https://api.clay.com/.well-known/oauth-protected-resource/v3/mcp",
694+ handle: () =>
695+ new Response(
696+ JSON.stringify({
697+ resource: "https://api.clay.com/v3/mcp",
698+ authorization_servers: ["https://api.clay.com"],
699+ scopes_supported: ["mcp"],
700+ }),
701+ { status: 200, headers: { "content-type": "application/json" } },
702+ ),
703+ },
704+ {
705+ match: (u) => u === "https://api.clay.com/.well-known/oauth-authorization-server",
706+ handle: () =>
707+ new Response(
708+ JSON.stringify({
709+ issuer: "https://api.clay.com",
710+ authorization_endpoint: "https://api.clay.com/authorize",
711+ token_endpoint: "https://api.clay.com/token",
712+ registration_endpoint: "https://api.clay.com/register",
713+ token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"],
714+ response_types_supported: ["code"],
715+ code_challenge_methods_supported: ["S256"],
716+ }),
717+ { status: 200, headers: { "content-type": "application/json" } },
718+ ),
719+ },
720+ {
721+ match: (u) => u === "https://api.clay.com/register",
722+ handle: () =>
723+ new Response(
724+ JSON.stringify({
725+ client_id: "clay-id",
726+ client_secret: "clay-secret",
727+ redirect_uris: ["https://app/cb"],
728+ token_endpoint_auth_method: "client_secret_post",
729+ }),
730+ { status: 201, headers: { "content-type": "application/json" } },
731+ ),
732+ },
733+ ]);
734+
735+ await Effect.runPromise(
736+ beginDynamicAuthorization({
737+ endpoint: "https://api.clay.com/v3/mcp",
738+ redirectUrl: "https://app/cb",
739+ state: "s",
740+ }),
741+ );
742+
743+ const dcrCall = calls.find((c) => c.url === "https://api.clay.com/register");
744+ const body = decodeDcrRequestBody(String(dcrCall!.init.body));
745+ expect(body.token_endpoint_auth_method).toBe("client_secret_post");
746+ });
747+
748+ it("fails with a clear error when the AS advertises only auth methods we don't support", async () => {
749+ installFetchRouter([
750+ {
751+ match: (u) => u === "https://jwt-only.example.com/.well-known/oauth-protected-resource",
752+ handle: () => new Response(null, { status: 404 }),
753+ },
754+ {
755+ match: (u) => u === "https://jwt-only.example.com/.well-known/oauth-authorization-server",
756+ handle: () =>
757+ new Response(
758+ JSON.stringify({
759+ issuer: "https://jwt-only.example.com",
760+ authorization_endpoint: "https://jwt-only.example.com/authorize",
761+ token_endpoint: "https://jwt-only.example.com/token",
762+ registration_endpoint: "https://jwt-only.example.com/register",
763+ token_endpoint_auth_methods_supported: ["private_key_jwt"],
764+ response_types_supported: ["code"],
765+ code_challenge_methods_supported: ["S256"],
766+ }),
767+ { status: 200, headers: { "content-type": "application/json" } },
768+ ),
769+ },
770+ ]);
771+
772+ const exit = await Effect.runPromiseExit(
773+ beginDynamicAuthorization({
774+ endpoint: "https://jwt-only.example.com/",
775+ redirectUrl: "https://app/cb",
776+ state: "s",
777+ }),
778+ );
779+ expect(Exit.isFailure(exit)).toBe(true);
780+ if (!Exit.isFailure(exit)) return;
781+ const reason = exit.cause.reasons.find(Cause.isFailReason);
782+ expect(reason?.error).toEqual(
783+ expect.objectContaining({
784+ _tag: "OAuthDiscoveryError",
785+ message: expect.stringMatching(/usable token_endpoint_auth_method/),
786+ }),
787+ );
788+ });
789+
790+ it("falls through to a later authorization_servers entry when the first has no metadata", async () => {
791+ installFetchRouter([
792+ {
793+ match: (u) => u === "https://multi-as.example.com/.well-known/oauth-protected-resource/api",
794+ handle: () =>
795+ new Response(
796+ JSON.stringify({
797+ resource: "https://multi-as.example.com/api",
798+ authorization_servers: ["https://primary.example.com", "https://backup.example.com"],
799+ }),
800+ { status: 200, headers: { "content-type": "application/json" } },
801+ ),
802+ },
803+ {
804+ match: (u) => u === "https://primary.example.com/.well-known/oauth-authorization-server",
805+ handle: () => new Response(null, { status: 404 }),
806+ },
807+ {
808+ match: (u) => u === "https://primary.example.com/.well-known/openid-configuration",
809+ handle: () => new Response(null, { status: 404 }),
810+ },
811+ {
812+ match: (u) => u === "https://backup.example.com/.well-known/oauth-authorization-server",
813+ handle: () =>
814+ new Response(
815+ JSON.stringify({
816+ issuer: "https://backup.example.com",
817+ authorization_endpoint: "https://backup.example.com/authorize",
818+ token_endpoint: "https://backup.example.com/token",
819+ registration_endpoint: "https://backup.example.com/register",
820+ response_types_supported: ["code"],
821+ code_challenge_methods_supported: ["S256"],
822+ }),
823+ { status: 200, headers: { "content-type": "application/json" } },
824+ ),
825+ },
826+ {
827+ match: (u) => u === "https://backup.example.com/register",
828+ handle: () =>
829+ new Response(
830+ JSON.stringify({
831+ client_id: "backup-client",
832+ redirect_uris: ["https://app/cb"],
833+ token_endpoint_auth_method: "none",
834+ }),
835+ { status: 201, headers: { "content-type": "application/json" } },
836+ ),
837+ },
838+ ]);
839+
840+ const result = await Effect.runPromise(
841+ beginDynamicAuthorization({
842+ endpoint: "https://multi-as.example.com/api",
843+ redirectUrl: "https://app/cb",
844+ state: "s",
845+ }),
846+ );
847+
848+ expect(result.state.authorizationServerUrl).toBe("https://backup.example.com");
849+ expect(result.state.clientInformation.client_id).toBe("backup-client");
850+ });
851+
852+ it("propagates AS error code + description on DCR failure (RFC 7591 §3.2.2)", async () => {
853+ installFetchRouter([
854+ {
855+ match: (u) => u === "https://errd.example.com/.well-known/oauth-protected-resource",
856+ handle: () => new Response(null, { status: 404 }),
857+ },
858+ {
859+ match: (u) => u === "https://errd.example.com/.well-known/oauth-authorization-server",
860+ handle: () =>
861+ new Response(
862+ JSON.stringify({
863+ issuer: "https://errd.example.com",
864+ authorization_endpoint: "https://errd.example.com/authorize",
865+ token_endpoint: "https://errd.example.com/token",
866+ registration_endpoint: "https://errd.example.com/register",
867+ response_types_supported: ["code"],
868+ code_challenge_methods_supported: ["S256"],
869+ }),
870+ { status: 200, headers: { "content-type": "application/json" } },
871+ ),
872+ },
873+ {
874+ match: (u) => u === "https://errd.example.com/register",
875+ handle: () =>
876+ new Response(
877+ JSON.stringify({
878+ error: "invalid_redirect_uri",
879+ error_description: "redirect_uri must be from an allowed domain",
880+ }),
881+ { status: 400, headers: { "content-type": "application/json" } },
882+ ),
883+ },
884+ ]);
885+
886+ const exit = await Effect.runPromiseExit(
887+ beginDynamicAuthorization({
888+ endpoint: "https://errd.example.com/",
889+ redirectUrl: "https://app/cb",
890+ state: "s",
891+ }),
892+ );
893+ expect(Exit.isFailure(exit)).toBe(true);
894+ if (!Exit.isFailure(exit)) return;
895+ const reason = exit.cause.reasons.find(Cause.isFailReason);
896+ expect(reason?.error).toEqual(
897+ expect.objectContaining({
898+ _tag: "OAuthDiscoveryError",
899+ status: 400,
900+ error: "invalid_redirect_uri",
901+ errorDescription: "redirect_uri must be from an allowed domain",
902+ }),
903+ );
904+ });
905+
409906 it("skips discovery + DCR when previousState is provided", async () => {
410907 const { calls } = installFetchRouter([]);
411908
packages/core/sdk/src/oauth-discovery.ts+115−14
@@ -40,6 +40,12 @@ import { · 39 unmodified lines
4040 export class OAuthDiscoveryError extends Data.TaggedError("OAuthDiscoveryError")<{
4141 readonly message: string;
4242 readonly status?: number;
43+ /** RFC 6749 §5.2 / RFC 7591 §3.2.2 error code, when the AS returned
44+ * one (`invalid_client_metadata`, `invalid_redirect_uri`, ...). Lets
45+ * the HTTP edge surface a structured error to the UI rather than
46+ * swallow the AS's response into a generic message string. */
47+ readonly error?: string;
48+ readonly errorDescription?: string;
4349 readonly cause?: unknown;
4450 }> {}
4551
@@ -523,6 +529,8 @@ export const registerDynamicClient = ( · 477 unmodified lines
523529 err.error_description ? ` — ${err.error_description}` : ""
524530 }`,
525531 status: err.status,
532+ error: err.error,
533+ errorDescription: err.error_description,
526534 cause: err,
527535 }),
528536 ),
@@ -537,6 +545,44 @@ export const registerDynamicClient = ( · 8 unmodified lines
537545 }),
538546 );
539547
548+// ---------------------------------------------------------------------------
549+// RFC 8707 — Resource Indicator canonicalisation
550+//
551+// MCP Authorization 2025-06-18 requires `resource` on /authorize and /token
552+// requests. RFC 8707 §2 says the value is "an absolute URI" identifying the
553+// protected resource — same scheme + host + (optional) path, no fragment,
554+// no query, lowercased scheme/host.
555+// ---------------------------------------------------------------------------
556+
557+export const canonicalResourceUrl = (value: string): string => {
558+ const url = new URL(value);
559+ const scheme = url.protocol.toLowerCase();
560+ const host = url.host.toLowerCase();
561+ const path = url.pathname.replace(/\/+$/, "");
562+ return `${scheme}//${host}${path}`;
563+};
564+
565+// ---------------------------------------------------------------------------
566+// Token-endpoint auth method negotiation
567+//
568+// OAuth 2.1 §2.4 leaves the choice to the client; our preference order is
569+// security-first: PKCE-only public client > client_secret_post >
570+// client_secret_basic. Servers like Clay only advertise the secret variants;
571+// servers like most MCP examples only advertise `none`.
572+// ---------------------------------------------------------------------------
573+
574+const SUPPORTED_DCR_AUTH_METHODS = ["none", "client_secret_post", "client_secret_basic"] as const;
575+
576+type DcrAuthMethod = (typeof SUPPORTED_DCR_AUTH_METHODS)[number];
577+
578+const negotiateAuthMethod = (advertised: readonly string[] | undefined): DcrAuthMethod | null => {
579+ if (!advertised || advertised.length === 0) return "none";
580+ for (const candidate of SUPPORTED_DCR_AUTH_METHODS) {
581+ if (advertised.includes(candidate)) return candidate;
582+ }
583+ return null;
584+};
585+
540586 // ---------------------------------------------------------------------------
541587 // Convenience: begin the full dynamic flow in one call
542588 // ---------------------------------------------------------------------------
@@ -548,6 +594,12 @@ export interface DynamicAuthorizationState { · 5 unmodified lines
548594 readonly authorizationServerMetadataUrl: string;
549595 readonly authorizationServerMetadata: OAuthAuthorizationServerMetadata;
550596 readonly clientInformation: OAuthClientInformation;
597+ /** RFC 8707 canonical resource URL passed on /authorize and persisted
598+ * for the matching /token + refresh calls. */
599+ readonly resource: string;
600+ /** Scopes ultimately requested at /authorize. Persisted so refresh
601+ * can replay the same set. */
602+ readonly scopes: readonly string[];
551603 }
552604
553605 export interface DynamicAuthorizationStartResult {
@@ -604,27 +656,48 @@ export const beginDynamicAuthorization = ( · 50 unmodified lines
604656 : null
605657 : yield* discoverProtectedResourceMetadata(input.endpoint, options);
606658
607− const authorizationServerUrl = (() => {
608− if (prior.authorizationServerUrl) return prior.authorizationServerUrl;
609− const fromResource = resource && resource.metadata.authorization_servers?.[0];
610− if (fromResource) return fromResource;
659+ // RFC 9728 allows multiple authorization_servers — try each in
660+ // listed order. Fall back to the endpoint's origin only when no
661+ // PRM is advertised.
662+ const candidateAuthorizationServerUrls: readonly string[] = (() => {
663+ if (prior.authorizationServerUrl) return [prior.authorizationServerUrl];
664+ const fromResource = resource?.metadata.authorization_servers ?? [];
665+ if (fromResource.length > 0) return fromResource;
611666 const u = new URL(input.endpoint);
612− return `${u.protocol}//${u.host}`;
667+ return [`${u.protocol}//${u.host}`];
613668 })();
614669
615− const authServer =
670+ const priorAuthServer =
616671 prior.authorizationServerMetadata && prior.authorizationServerMetadataUrl
617672 ? {
618673 metadata: prior.authorizationServerMetadata,
619674 metadataUrl: prior.authorizationServerMetadataUrl,
675+ url: prior.authorizationServerUrl ?? candidateAuthorizationServerUrls[0]!,
620676 }
621− : yield* discoverAuthorizationServerMetadata(authorizationServerUrl, options);
677+ : null;
678+
679+ const discovered = priorAuthServer
680+ ? priorAuthServer
681+ : yield* (() => {
682+ const tried: string[] = [];
683+ return Effect.gen(function* () {
684+ for (const candidate of candidateAuthorizationServerUrls) {
685+ tried.push(candidate);
686+ const md = yield* discoverAuthorizationServerMetadata(candidate, options).pipe(
687+ Effect.catchTag("OAuthDiscoveryError", () => Effect.succeed(null)),
688+ );
689+ if (md) {
690+ return { metadata: md.metadata, metadataUrl: md.metadataUrl, url: candidate };
691+ }
692+ }
693+ return yield* new OAuthDiscoveryError({
694+ message: `No OAuth authorization server metadata found (tried: ${tried.join(", ")})`,
695+ });
696+ });
697+ })();
622698
623− if (!authServer) {
624− return yield* new OAuthDiscoveryError({
625− message: `No OAuth authorization server metadata at ${authorizationServerUrl}`,
626− });
627− }
699+ const authServer = { metadata: discovered.metadata, metadataUrl: discovered.metadataUrl };
700+ const authorizationServerUrl = discovered.url;
628701
629702 const pkceMethods = authServer.metadata.code_challenge_methods_supported ?? [];
630703 if (pkceMethods.length > 0 && !pkceMethods.includes("S256")) {
@@ -640,13 +713,36 @@ export const beginDynamicAuthorization = ( · 9 unmodified lines
640713 });
641714 }
642715
643− const scopes = input.scopes ?? authServer.metadata.scopes_supported ?? [];
716+ // RFC 9728 §2: PRM `scopes_supported` is the resource-scoped list and is
717+ // authoritative when present. AS-level `scopes_supported` is global and
718+ // (per RFC 9728 §2) "not meant to indicate that an OAuth client should
719+ // request all scopes in the list", so we don't auto-expand it. When only
720+ // AS-level scopes are advertised we request none and let the AS apply
721+ // its default — callers wanting refresh tokens / specific scopes pass
722+ // them explicitly via `input.scopes`.
723+ const scopes: readonly string[] =
724+ input.scopes ??
725+ (resource?.metadata.scopes_supported && resource.metadata.scopes_supported.length > 0
726+ ? resource.metadata.scopes_supported
727+ : []);
728+
729+ const negotiatedAuthMethod = negotiateAuthMethod(
730+ authServer.metadata.token_endpoint_auth_methods_supported,
731+ );
732+ if (!negotiatedAuthMethod) {
733+ return yield* new OAuthDiscoveryError({
734+ message: `Authorization server does not support a usable token_endpoint_auth_method (advertised: ${(
735+ authServer.metadata.token_endpoint_auth_methods_supported ?? []
736+ ).join(", ")})`,
737+ });
738+ }
644739
645740 const baseClientMetadata: DynamicClientMetadata = {
646741 grant_types: ["authorization_code", "refresh_token"],
647742 response_types: ["code"],
648− token_endpoint_auth_method: "none",
743+ token_endpoint_auth_method: negotiatedAuthMethod,
649744 client_name: "Executor",
745+ client_uri: "https://executor.sh",
650746 ...(scopes.length > 0 ? { scope: scopes.join(" ") } : {}),
651747 ...(input.clientMetadata ?? {}),
652748 redirect_uris: input.clientMetadata?.redirect_uris ?? [input.redirectUrl],
@@ -670,6 +766,8 @@ export const beginDynamicAuthorization = ( · 17 unmodified lines
670766 );
671767 })());
672768
769+ const resourceValue = resource?.metadata.resource ?? canonicalResourceUrl(input.endpoint);
770+
673771 const codeVerifier = createPkceCodeVerifier();
674772 const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier));
675773
@@ -680,6 +778,7 @@ export const beginDynamicAuthorization = ( · 4 unmodified lines
680778 scopes,
681779 state: input.state,
682780 codeChallenge,
781+ resource: resourceValue,
683782 });
684783
685784 return {
@@ -692,6 +791,8 @@ export const beginDynamicAuthorization = ( · 6 unmodified lines
692791 authorizationServerMetadataUrl: authServer.metadataUrl,
693792 authorizationServerMetadata: authServer.metadata,
694793 clientInformation,
794+ resource: resourceValue,
795+ scopes,
695796 },
696797 };
697798 });
packages/core/sdk/src/oauth-helpers.test.ts+60−0
@@ -117,6 +117,21 @@ describe("buildAuthorizationUrl", () => { · 116 unmodified lines
117117 expect(url.searchParams.get("tenant")).toBe("acme");
118118 expect(url.searchParams.get("client_id")).toBe("client-123");
119119 });
120+
121+ it("includes RFC 8707 resource indicator when provided", () => {
122+ const url = new URL(
123+ buildAuthorizationUrl({
124+ ...baseInput,
125+ resource: "https://api.example.com/v1/mcp",
126+ }),
127+ );
128+ expect(url.searchParams.get("resource")).toBe("https://api.example.com/v1/mcp");
129+ });
130+
131+ it("omits resource parameter when not provided", () => {
132+ const url = new URL(buildAuthorizationUrl(baseInput));
133+ expect(url.searchParams.has("resource")).toBe(false);
134+ });
120135 });
121136
122137 // ---------------------------------------------------------------------------
@@ -184,6 +199,37 @@ describe("exchangeAuthorizationCode", () => { · 61 unmodified lines
184199 expect(body.get("code")).toBe("abc");
185200 });
186201
202+ it("includes RFC 8707 resource parameter on the token request when provided", async () => {
203+ const { calls } = captureFetch(jsonResponse(200, validBody));
204+ await Effect.runPromise(
205+ exchangeAuthorizationCode({
206+ tokenUrl: "https://example.com/token",
207+ clientId: "cid",
208+ redirectUrl: "https://app.example.com/cb",
209+ codeVerifier: "verifier",
210+ code: "abc",
211+ resource: "https://api.example.com/v1/mcp",
212+ }),
213+ );
214+ const body = calls[0]!.init.body as URLSearchParams;
215+ expect(body.get("resource")).toBe("https://api.example.com/v1/mcp");
216+ });
217+
218+ it("omits resource parameter when not provided", async () => {
219+ const { calls } = captureFetch(jsonResponse(200, validBody));
220+ await Effect.runPromise(
221+ exchangeAuthorizationCode({
222+ tokenUrl: "https://example.com/token",
223+ clientId: "cid",
224+ redirectUrl: "https://app.example.com/cb",
225+ codeVerifier: "verifier",
226+ code: "abc",
227+ }),
228+ );
229+ const body = calls[0]!.init.body as URLSearchParams;
230+ expect(body.has("resource")).toBe(false);
231+ });
232+
187233 it("omits client_secret when none is provided (public clients with PKCE)", async () => {
188234 const { calls } = captureFetch(jsonResponse(200, validBody));
189235 await Effect.runPromise(
@@ -456,6 +502,20 @@ describe("refreshAccessToken", () => { · 266 unmodified lines
456502 expect(body.has("scope")).toBe(false);
457503 });
458504
505+ it("includes RFC 8707 resource parameter on refresh requests when provided", async () => {
506+ const { calls } = captureFetch(jsonResponse(200, validBody));
507+ await Effect.runPromise(
508+ refreshAccessToken({
509+ tokenUrl: "https://example.com/token",
510+ clientId: "cid",
511+ refreshToken: "old",
512+ resource: "https://api.example.com/v1/mcp",
513+ }),
514+ );
515+ const body = calls[0]!.init.body as URLSearchParams;
516+ expect(body.get("resource")).toBe("https://api.example.com/v1/mcp");
517+ });
518+
459519 it("strips refreshed id_tokens whose iss does not match AS metadata", async () => {
460520 captureFetch(
461521 jsonResponse(200, {
packages/core/sdk/src/oauth-helpers.ts+26−5
@@ -80,6 +80,10 @@ export type BuildAuthorizationUrlInput = { · 79 unmodified lines
8080 readonly codeChallenge: string;
8181 /** Separator between scopes. RFC 6749 says space; some providers use comma. */
8282 readonly scopeSeparator?: string;
83+ /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 §"Resource
84+ * Parameter Implementation" requires clients to send this on every
85+ * authorization request, regardless of AS support. */
86+ readonly resource?: string;
8387 /** Provider-specific extras (e.g. Google's `access_type=offline`). */
8488 readonly extraParams?: Readonly<Record<string, string>>;
8589 };
@@ -96,6 +100,9 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string · 10 unmodified lines
96100 url.searchParams.set("state", input.state);
97101 url.searchParams.set("code_challenge_method", "S256");
98102 url.searchParams.set("code_challenge", input.codeChallenge);
103+ if (input.resource) {
104+ url.searchParams.set("resource", input.resource);
105+ }
99106 if (input.extraParams) {
100107 for (const [k, v] of Object.entries(input.extraParams)) {
101108 url.searchParams.set(k, v);
@@ -261,6 +268,10 @@ export type ExchangeAuthorizationCodeInput = { · 159 unmodified lines
261268 readonly code: string;
262269 readonly clientAuth?: ClientAuthMethod;
263270 readonly idTokenSigningAlgValuesSupported?: readonly string[];
271+ /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on
272+ * the token request when the client knows the resource it intends
273+ * to call. */
274+ readonly resource?: string;
264275 readonly timeoutMs?: number;
265276 };
266277
@@ -284,6 +295,9 @@ export const exchangeAuthorizationCode = ( · 17 unmodified lines
284295 redirect_uri: input.redirectUrl,
285296 code_verifier: input.codeVerifier,
286297 });
298+ if (input.resource) {
299+ params.set("resource", input.resource);
300+ }
287301 const response = await oauth.genericTokenEndpointRequest(
288302 as,
289303 client,
@@ -350,6 +364,10 @@ export type RefreshAccessTokenInput = { · 60 unmodified lines
350364 readonly scopeSeparator?: string;
351365 readonly clientAuth?: ClientAuthMethod;
352366 readonly idTokenSigningAlgValuesSupported?: readonly string[];
367+ /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on
368+ * refresh requests so the new access token's audience is bound to
369+ * the same resource. */
370+ readonly resource?: string;
353371 readonly timeoutMs?: number;
354372 };
355373
@@ -363,12 +381,15 @@ export const refreshAccessToken = ( · 7 unmodified lines
363381 });
364382 const client: oauth.Client = { client_id: input.clientId };
365383 const clientAuth = pickClientAuth(input.clientSecret, input.clientAuth ?? "body");
384+ const extraParams = new URLSearchParams();
385+ if (input.scopes && input.scopes.length > 0) {
386+ extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
387+ }
388+ if (input.resource) {
389+ extraParams.set("resource", input.resource);
390+ }
366391 const additionalParameters =
367− input.scopes && input.scopes.length > 0
368− ? new URLSearchParams({
369− scope: input.scopes.join(input.scopeSeparator ?? " "),
370− })
371− : undefined;
392+ Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;
372393 const response = await oauth.refreshTokenGrantRequest(
373394 as,
374395 client,
packages/core/sdk/src/oauth-service.ts+27−7
@@ -107,6 +107,7 @@ const DynamicDcrSessionPayload = Schema.Struct({ · 106 unmodified lines
107107 resourceMetadataUrl: Schema.NullOr(Schema.String),
108108 resourceMetadata: Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown)),
109109 scopes: Schema.Array(Schema.String),
110+ resource: Schema.NullOr(Schema.String).pipe(Schema.withDecodingDefaultType(Effect.succeed(null))),
110111 });
111112
112113 const AuthorizationCodeSessionPayload = Schema.Struct({
@@ -344,9 +345,20 @@ export const makeOAuth2Service = ( · 231 unmodified lines
344345 )
345346 : null;
346347
348+ // Dynamic registration is only viable when the AS advertises a
349+ // registration_endpoint AND a token_endpoint_auth_method we can use
350+ // (`none`, `client_secret_post`, or `client_secret_basic`). If the AS
351+ // doesn't list any methods we assume `none` is acceptable per OAuth
352+ // 2.1 §2.4 (server's choice).
353+ const advertisedAuthMethods =
354+ authServer?.metadata.token_endpoint_auth_methods_supported ?? [];
355+ const hasNegotiableAuthMethod =
356+ advertisedAuthMethods.length === 0 ||
357+ advertisedAuthMethods.some(
358+ (m) => m === "none" || m === "client_secret_post" || m === "client_secret_basic",
359+ );
347360 const supportsDynamicRegistration = !!(
348− authServer?.metadata.registration_endpoint &&
349− (authServer.metadata.token_endpoint_auth_methods_supported ?? []).includes("none")
361+ authServer?.metadata.registration_endpoint && hasNegotiableAuthMethod
350362 );
351363
352364 // Bearer challenge probe — POST the endpoint unauth, look for
@@ -421,10 +433,12 @@ export const makeOAuth2Service = ( · 68 unmodified lines
421433 resourceQueryParams: input.queryParams,
422434 },
423435 ).pipe(
424− Effect.catchTag("OAuthDiscoveryError", ({ message }) =>
436+ Effect.catchTag("OAuthDiscoveryError", ({ message, error, errorDescription }) =>
425437 Effect.fail(
426438 new OAuthStartError({
427439 message: `Dynamic authorization setup failed: ${message}`,
440+ error,
441+ errorDescription,
428442 }),
429443 ),
430444 ),
@@ -444,9 +458,10 @@ export const makeOAuth2Service = ( · 13 unmodified lines
444458 authorizationUrl: started.state.authorizationServerMetadata.authorization_endpoint,
445459 clientId: started.state.clientInformation.client_id,
446460 redirectUrl: input.redirectUrl,
447− scopes: strategy.scopes ?? started.state.authorizationServerMetadata.scopes_supported ?? [],
461+ scopes: started.state.scopes,
448462 state: sessionId,
449463 codeChallenge,
464+ resource: started.state.resource,
450465 });
451466
452467 const payload: OAuthSessionPayload = {
@@ -466,9 +481,8 @@ export const makeOAuth2Service = ( · 13 unmodified lines
466481 resourceMetadataUrl: started.state.resourceMetadataUrl,
467482 resourceMetadata:
468483 (started.state.resourceMetadata as Record<string, unknown> | null) ?? null,
469− scopes: [
470− ...(strategy.scopes ?? started.state.authorizationServerMetadata.scopes_supported ?? []),
471− ],
484+ scopes: [...started.state.scopes],
485+ resource: started.state.resource,
472486 };
473487
474488 yield* writeSession({
@@ -811,6 +825,7 @@ export const makeOAuth2Service = ( · 336 unmodified lines
811825 : "body",
812826 scopes: [...payload.scopes],
813827 scope: exchangeResult.tokens.scope ?? null,
828+ resource: payload.resource,
814829 }
815830 : {
816831 kind: "authorization-code",
@@ -922,6 +937,7 @@ export const makeOAuth2Service = ( · 105 unmodified lines
922937 code,
923938 idTokenSigningAlgValuesSupported: md.id_token_signing_alg_values_supported,
924939 clientAuth: ci.token_endpoint_auth_method === "client_secret_basic" ? "basic" : "body",
940+ resource: payload.resource ?? undefined,
925941 }).pipe(
926942 Effect.mapError(
927943 ({ message, error }: OAuth2Error) =>
@@ -1198,6 +1214,10 @@ export const makeOAuth2Service = ( · 270 unmodified lines
11981214 clientAuth: state.clientAuth,
11991215 idTokenSigningAlgValuesSupported:
12001216 state.kind === "dynamic-dcr" ? state.idTokenSigningAlgValuesSupported : undefined,
1217+ resource:
1218+ state.kind === "dynamic-dcr" || state.kind === "authorization-code"
1219+ ? (state.resource ?? undefined)
1220+ : undefined,
12011221 })
12021222 ).pipe(
12031223 Effect.mapError(
packages/core/sdk/src/oauth.ts+9−0
@@ -127,6 +127,9 @@ export const OAuthProviderState = Schema.Union([ · 126 unmodified lines
127127 scopes: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultType(Effect.succeed([]))),
128128 scopeSeparator: Schema.optional(Schema.String),
129129 scope: Schema.NullOr(Schema.String),
130+ /** RFC 8707 canonical resource URL. Replayed on refresh so the new
131+ * access token's audience stays bound to the same resource. */
132+ resource: Schema.optional(Schema.NullOr(Schema.String)),
130133 }),
131134 Schema.Struct({
132135 kind: Schema.Literal("authorization-code"),
@@ -138,6 +141,7 @@ export const OAuthProviderState = Schema.Union([ · 5 unmodified lines
138141 scopes: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultType(Effect.succeed([]))),
139142 scopeSeparator: Schema.optional(Schema.String),
140143 scope: Schema.NullOr(Schema.String),
144+ resource: Schema.optional(Schema.NullOr(Schema.String)),
141145 }),
142146 Schema.Struct({
143147 kind: Schema.Literal("client-credentials"),
@@ -268,6 +272,11 @@ export class OAuthProbeError extends Schema.TaggedErrorClass<OAuthProbeError>()( · 124 unmodified lines
268272
269273 export class OAuthStartError extends Schema.TaggedErrorClass<OAuthStartError>()("OAuthStartError", {
270274 message: Schema.String,
275+ /** RFC 6749 §5.2 / RFC 7591 §3.2.2 error code propagated up from the
276+ * authorization server (e.g. `invalid_client_metadata`). UI surfaces
277+ * it as the structured "AS rejected the registration" reason. */
278+ error: Schema.optional(Schema.String),
279+ errorDescription: Schema.optional(Schema.String),
271280 }) {
272281 static annotations = { httpApiStatus: 400 };
273282 }