History

Gate the RFC 8707 resource parameter on advertised authorization-server support

Rhys Sullivan committed 2 months ago

Commit fb952d81 parent ac2dec65 files · +409 −21Browse files

Unified · Split

5 files
  1. .changeset/oauth-resource-indicator-capability-gate.md +19 −0
  2. packages/core/sdk/src/oauth-discovery.test.ts +59 −0
  3. packages/core/sdk/src/oauth-discovery.ts +12 −0
  4. packages/core/sdk/src/oauth-helpers.test.ts +230 −0
  5. packages/core/sdk/src/oauth-helpers.ts +89 −21
.changeset/oauth-resource-indicator-capability-gate.md+19−0
@@ -0,0 +1,19 @@
1+---
2+"executor": patch
3+---
4+
5+**The RFC 8707 `resource` parameter is now gated on what the authorization server advertises**
6+
7+Executor sent `resource` unconditionally on every authorization, code exchange, refresh, and client-credentials request whenever an OAuth app had a resource configured. Microsoft Entra v2 rejects that: a request carrying both `resource` and a v2 `scope` such as `https://api.fabric.microsoft.com/.default` fails with `AADSTS9010010` before the consent screen, so the Microsoft Fabric Core MCP server could not be connected at all.
8+
9+The rule now applied on all four grants, in full:
10+
11+- **No authorization-server metadata was discovered → send `resource`.** Nothing is known about the server, and the MCP authorization spec expects resource indicators. This is the previous behavior, unchanged, and it covers every manually configured provider.
12+- **Metadata was discovered and advertises `resource_indicators_supported: true` → send `resource`.**
13+- **Metadata was discovered and the flag is absent or `false` → omit `resource`.** RFC 8414 §2 makes an omitted metadata field mean "not advertised", and RFC 8707 §2 makes `resource` optional for clients, so omitting it is conformant.
14+
15+The decision reads only what a server publishes about itself — there is no Microsoft special case and no host allowlist. `resource_indicators_supported` is not in the IANA authorization-server metadata registry (RFC 8707 registered no discovery parameter), but it is the only machine-readable signal a server gives, so discovery now parses it and threads it to the grant helpers.
16+
17+The protected resource is still discovered, validated against the requested endpoint, and retained in the flow state for MCP binding when the parameter itself is withheld. The `executor.oauth.has_resource` span attribute now reports what actually went on the wire rather than what was configured.
18+
19+Providers whose authorization server advertises the capability, and providers reached without any metadata discovery, are unaffected.
packages/core/sdk/src/oauth-discovery.test.ts+59−0
@@ -369,6 +369,9 @@ describe("beginDynamicAuthorization", () => { · 368 unmodified lines
369369 scopes_supported: ["openid", "profile", "email", "offline_access", "workspace:member"],
370370 response_types_supported: ["code"],
371371 code_challenge_methods_supported: ["S256"],
372+ // An MCP-profile server implements RFC 8707, so it advertises it —
373+ // that is what earns the `resource` parameter asserted below.
374+ resource_indicators_supported: true,
372375 });
373376 }
374377 if (request.url === "/oauth/register") {
@@ -624,6 +627,7 @@ describe("beginDynamicAuthorization", () => { · 249 unmodified lines
624627 registration_endpoint: `${baseUrl}/register`,
625628 response_types_supported: ["code"],
626629 code_challenge_methods_supported: ["S256"],
630+ resource_indicators_supported: true,
627631 });
628632 }
629633 if (request.url === "/register") {
@@ -652,6 +656,61 @@ describe("beginDynamicAuthorization", () => { · 22 unmodified lines
652656 ),
653657 );
654658
659+ // #1789 — Microsoft Entra v2 publishes authorization-server metadata, does
660+ // NOT advertise RFC 8707, and rejects `resource` alongside a v2 `scope` with
661+ // AADSTS9010010. Metadata that stays silent on the capability means "not
662+ // advertised" (RFC 8414 §2), so the resource indicator is withheld — while
663+ // the protected resource itself is still discovered and kept in the state
664+ // for MCP binding.
665+ it.effect("omits the resource parameter when the AS metadata does not advertise RFC 8707", () =>
666+ withOAuthFixture(
667+ (request, baseUrl) => {
668+ if (request.url === "/.well-known/oauth-protected-resource/v1/mcp/core") {
669+ return sendJson({
670+ resource: baseUrl,
671+ authorization_servers: [baseUrl],
672+ scopes_supported: [`${baseUrl}/.default`],
673+ });
674+ }
675+ if (request.url === "/.well-known/oauth-authorization-server") {
676+ return sendJson({
677+ issuer: baseUrl,
678+ authorization_endpoint: `${baseUrl}/oauth2/v2.0/authorize`,
679+ token_endpoint: `${baseUrl}/oauth2/v2.0/token`,
680+ registration_endpoint: `${baseUrl}/register`,
681+ response_types_supported: ["code"],
682+ code_challenge_methods_supported: ["S256"],
683+ });
684+ }
685+ if (request.url === "/register") {
686+ return sendJson(
687+ {
688+ client_id: "entra-client",
689+ redirect_uris: ["https://app/cb"],
690+ token_endpoint_auth_method: "none",
691+ },
692+ 201,
693+ );
694+ }
695+ return notFound();
696+ },
697+ ({ baseUrl }) =>
698+ Effect.gen(function* () {
699+ const result = yield* beginDynamicAuthorization({
700+ endpoint: `${baseUrl}/v1/mcp/core`,
701+ redirectUrl: "https://app/cb",
702+ state: "s",
703+ });
704+
705+ const authUrl = new URL(result.authorizationUrl);
706+ expect(authUrl.searchParams.has("resource")).toBe(false);
707+ expect(authUrl.searchParams.get("scope")).toBe(`${baseUrl}/.default`);
708+ // Withheld from the wire, still retained for MCP discovery/binding.
709+ expect(result.state.resource).toBe(baseUrl);
710+ }),
711+ ),
712+ );
713+
655714 it.effect("includes client_uri in the DCR body", () =>
656715 withOAuthFixture(
657716 (request, baseUrl) => {
packages/core/sdk/src/oauth-discovery.ts+12−0
@@ -82,6 +82,14 @@ export const OAuthAuthorizationServerMetadataSchema = Schema.Struct({ · 81 unmodified lines
8282 introspection_endpoint: Schema.optional(Schema.String),
8383 userinfo_endpoint: Schema.optional(Schema.String),
8484 id_token_signing_alg_values_supported: Schema.optional(StringArray),
85+ /** Whether this server implements RFC 8707 resource indicators. RFC 8707
86+ * registered no metadata parameter, so this is a de-facto field rather than
87+ * an IANA-registered one — but it is the only machine-readable signal a
88+ * server gives, and `shouldSendResourceIndicator` reads it to decide whether
89+ * `resource` goes on authorization and token requests. An absent field means
90+ * "not advertised" (RFC 8414 §2), NOT "unknown": a server that publishes
91+ * metadata and stays silent here gets no `resource`. */
92+ resource_indicators_supported: Schema.optional(Schema.Boolean),
8593 /** draft-ietf-oauth-identity-assertion-authz-grant-04 §7.2 — the
8694 * authorization grant profiles this Resource Authorization Server
8795 * implements. Advertising a profile says only that the server implements
@@ -898,6 +906,10 @@ export const beginDynamicAuthorization = ( · 810 unmodified lines
898906 state: input.state,
899907 codeChallenge,
900908 resource: resourceValue,
909+ // The AS metadata was just discovered, so its RFC 8707 capability is
910+ // known: send `resource` only if it advertises support. Entra v2 rejects
911+ // `resource` alongside a v2 `scope` (AADSTS9010010).
912+ authorizationServerMetadata: authServer.metadata,
901913 endpointUrlPolicy: options.endpointUrlPolicy,
902914 });
903915
packages/core/sdk/src/oauth-helpers.test.ts+230−0
@@ -26,7 +26,10 @@ import { · 25 unmodified lines
2626 isUnusableSuccessTokenResponse,
2727 refreshAccessToken,
2828 shouldRefreshToken,
29+ shouldSendResourceIndicator,
30+ type ResourceIndicatorSupport,
2931 } from "./oauth-helpers";
32+import type { OAuthAuthorizationServerMetadata } from "./oauth-discovery";
3033 import { serveTestHttpApp } from "./testing";
3134
3235 interface TokenCall {
@@ -1423,6 +1426,233 @@ describe("refreshAccessToken", () => { · 1390 unmodified lines
14231426 );
14241427 });
14251428
1429+// ---------------------------------------------------------------------------
1430+// RFC 8707 resource indicators — the AS capability gate (#1789)
1431+//
1432+// THE RULE under test, on every grant that can carry `resource`:
1433+// no metadata → send (unchanged; MCP's default expectation)
1434+// metadata + flag true → send
1435+// metadata + flag absent → omit (RFC 8414 §2: not advertised)
1436+// metadata + flag false → omit
1437+//
1438+// The "metadata + flag absent" row is the bug: Microsoft Entra v2 publishes
1439+// metadata, does not advertise resource indicators, and rejects `resource`
1440+// alongside a v2 `scope` with AADSTS9010010.
1441+// ---------------------------------------------------------------------------
1442+
1443+const RESOURCE = "https://api.example.com/v1/mcp";
1444+const AS_SUPPORTED: ResourceIndicatorSupport = { resource_indicators_supported: true };
1445+const AS_UNSUPPORTED: ResourceIndicatorSupport = { resource_indicators_supported: false };
1446+/** Metadata that exists but never mentions resource indicators — the Entra
1447+ * shape. Typed as the whole discovered document, not just the one flag, so
1448+ * this fixture stays honest about what a real caller threads through. */
1449+const AS_SILENT: OAuthAuthorizationServerMetadata = {
1450+ issuer: "https://login.microsoftonline.com/tenant/v2.0",
1451+ authorization_endpoint: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
1452+ token_endpoint: "https://login.microsoftonline.com/tenant/oauth2/v2.0/token",
1453+};
1454+
1455+describe("shouldSendResourceIndicator", () => {
1456+ it("sends when no metadata was discovered", () => {
1457+ expect(shouldSendResourceIndicator()).toBe(true);
1458+ expect(shouldSendResourceIndicator(undefined)).toBe(true);
1459+ expect(shouldSendResourceIndicator(null)).toBe(true);
1460+ });
1461+
1462+ it("sends only when the server advertises support", () => {
1463+ expect(shouldSendResourceIndicator(AS_SUPPORTED)).toBe(true);
1464+ expect(shouldSendResourceIndicator(AS_UNSUPPORTED)).toBe(false);
1465+ expect(shouldSendResourceIndicator(AS_SILENT)).toBe(false);
1466+ expect(shouldSendResourceIndicator({})).toBe(false);
1467+ });
1468+});
1469+
1470+describe("buildAuthorizationUrl resource-indicator gating", () => {
1471+ const baseInput = {
1472+ authorizationUrl: "https://example.com/authorize",
1473+ clientId: "client-123",
1474+ redirectUrl: "https://app.example.com/callback",
1475+ scopes: ["https://api.fabric.microsoft.com/.default"] as const,
1476+ state: "state-abc",
1477+ codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
1478+ resource: RESOURCE,
1479+ };
1480+
1481+ it("sends resource when no authorization-server metadata is known", () => {
1482+ const url = new URL(buildAuthorizationUrl(baseInput));
1483+ expect(url.searchParams.get("resource")).toBe(RESOURCE);
1484+ });
1485+
1486+ it("sends resource when the server advertises resource_indicators_supported", () => {
1487+ const url = new URL(
1488+ buildAuthorizationUrl({ ...baseInput, authorizationServerMetadata: AS_SUPPORTED }),
1489+ );
1490+ expect(url.searchParams.get("resource")).toBe(RESOURCE);
1491+ });
1492+
1493+ it("omits resource when the server publishes metadata without the capability", () => {
1494+ for (const metadata of [AS_UNSUPPORTED, AS_SILENT]) {
1495+ const url = new URL(
1496+ buildAuthorizationUrl({ ...baseInput, authorizationServerMetadata: metadata }),
1497+ );
1498+ expect(url.searchParams.has("resource")).toBe(false);
1499+ // The scope the AS DOES accept must survive the veto untouched.
1500+ expect(url.searchParams.get("scope")).toBe("https://api.fabric.microsoft.com/.default");
1501+ }
1502+ });
1503+});
1504+
1505+describe("exchangeAuthorizationCode resource-indicator gating", () => {
1506+ const exchange = (
1507+ tokenUrl: string,
1508+ authorizationServerMetadata?: ResourceIndicatorSupport | null,
1509+ ) =>
1510+ exchangeAuthorizationCode({
1511+ tokenUrl,
1512+ clientId: "cid",
1513+ redirectUrl: "https://app.example.com/cb",
1514+ codeVerifier: "verifier",
1515+ code: "abc",
1516+ resource: RESOURCE,
1517+ authorizationServerMetadata,
1518+ });
1519+
1520+ it.effect("sends resource when no authorization-server metadata is known", () =>
1521+ withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
1522+ Effect.gen(function* () {
1523+ yield* exchange(tokenUrl);
1524+ expect((yield* calls)[0]!.body.get("resource")).toBe(RESOURCE);
1525+ }),
1526+ ),
1527+ );
1528+
1529+ it.effect("sends resource when the server advertises support", () =>
1530+ withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
1531+ Effect.gen(function* () {
1532+ yield* exchange(tokenUrl, AS_SUPPORTED);
1533+ expect((yield* calls)[0]!.body.get("resource")).toBe(RESOURCE);
1534+ }),
1535+ ),
1536+ );
1537+
1538+ it.effect("omits resource when the server publishes metadata without the capability", () =>
1539+ withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
1540+ Effect.gen(function* () {
1541+ yield* exchange(tokenUrl, AS_SILENT);
1542+ yield* exchange(tokenUrl, AS_UNSUPPORTED);
1543+ const bodies = (yield* calls).map((call) => call.body);
1544+ expect(bodies).toHaveLength(2);
1545+ for (const body of bodies) {
1546+ expect(body.has("resource")).toBe(false);
1547+ // The grant itself is untouched — only `resource` is withheld.
1548+ expect(body.get("grant_type")).toBe("authorization_code");
1549+ expect(body.get("code_verifier")).toBe("verifier");
1550+ }
1551+ }),
1552+ ),
1553+ );
1554+});
1555+
1556+describe("exchangeClientCredentials resource-indicator gating", () => {
1557+ const exchange = (
1558+ tokenUrl: string,
1559+ authorizationServerMetadata?: ResourceIndicatorSupport | null,
1560+ ) =>
1561+ exchangeClientCredentials({
1562+ tokenUrl,
1563+ clientId: "cid",
1564+ clientSecret: "secret",
1565+ scopes: ["https://api.fabric.microsoft.com/.default"],
1566+ resource: RESOURCE,
1567+ authorizationServerMetadata,
1568+ });
1569+
1570+ it.effect("sends resource when no authorization-server metadata is known", () =>
1571+ withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
1572+ Effect.gen(function* () {
1573+ yield* exchange(tokenUrl);
1574+ expect((yield* calls)[0]!.body.get("resource")).toBe(RESOURCE);
1575+ }),
1576+ ),
1577+ );
1578+
1579+ it.effect("sends resource when the server advertises support", () =>
1580+ withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
1581+ Effect.gen(function* () {
1582+ yield* exchange(tokenUrl, AS_SUPPORTED);
1583+ expect((yield* calls)[0]!.body.get("resource")).toBe(RESOURCE);
1584+ }),
1585+ ),
1586+ );
1587+
1588+ it.effect("omits resource when the server publishes metadata without the capability", () =>
1589+ withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
1590+ Effect.gen(function* () {
1591+ yield* exchange(tokenUrl, AS_SILENT);
1592+ yield* exchange(tokenUrl, AS_UNSUPPORTED);
1593+ const bodies = (yield* calls).map((call) => call.body);
1594+ expect(bodies).toHaveLength(2);
1595+ for (const body of bodies) {
1596+ expect(body.has("resource")).toBe(false);
1597+ expect(body.get("grant_type")).toBe("client_credentials");
1598+ expect(body.get("scope")).toBe("https://api.fabric.microsoft.com/.default");
1599+ }
1600+ }),
1601+ ),
1602+ );
1603+});
1604+
1605+describe("refreshAccessToken resource-indicator gating", () => {
1606+ const refresh = (
1607+ tokenUrl: string,
1608+ authorizationServerMetadata?: ResourceIndicatorSupport | null,
1609+ ) =>
1610+ refreshAccessToken({
1611+ tokenUrl,
1612+ clientId: "cid",
1613+ refreshToken: "old",
1614+ resource: RESOURCE,
1615+ authorizationServerMetadata,
1616+ });
1617+
1618+ it.effect("sends resource when no authorization-server metadata is known", () =>
1619+ withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
1620+ Effect.gen(function* () {
1621+ yield* refresh(tokenUrl);
1622+ expect((yield* calls)[0]!.body.get("resource")).toBe(RESOURCE);
1623+ }),
1624+ ),
1625+ );
1626+
1627+ it.effect("sends resource when the server advertises support", () =>
1628+ withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
1629+ Effect.gen(function* () {
1630+ yield* refresh(tokenUrl, AS_SUPPORTED);
1631+ expect((yield* calls)[0]!.body.get("resource")).toBe(RESOURCE);
1632+ }),
1633+ ),
1634+ );
1635+
1636+ // Refresh builds its extra params conditionally and passes `undefined` when
1637+ // the set is empty, so the veto must leave a well-formed refresh request
1638+ // rather than an empty `additionalParameters` bag.
1639+ it.effect("omits resource when the server publishes metadata without the capability", () =>
1640+ withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) =>
1641+ Effect.gen(function* () {
1642+ yield* refresh(tokenUrl, AS_SILENT);
1643+ yield* refresh(tokenUrl, AS_UNSUPPORTED);
1644+ const bodies = (yield* calls).map((call) => call.body);
1645+ expect(bodies).toHaveLength(2);
1646+ for (const body of bodies) {
1647+ expect(body.has("resource")).toBe(false);
1648+ expect(body.get("grant_type")).toBe("refresh_token");
1649+ expect(body.get("refresh_token")).toBe("old");
1650+ }
1651+ }),
1652+ ),
1653+ );
1654+});
1655+
14261656 describe("shouldRefreshToken", () => {
14271657 it("never refreshes when expiresAt is null", () => {
14281658 expect(shouldRefreshToken({ expiresAt: null })).toBe(false);
packages/core/sdk/src/oauth-helpers.ts+89−21
@@ -181,6 +181,57 @@ export const createPkceCodeChallenge = (verifier: string): Promise<string> => · 180 unmodified lines
181181 * and redeemed by `oauth.complete`. */
182182 export const createOAuthState = (): string => oauth.generateRandomState();
183183
184+// ---------------------------------------------------------------------------
185+// RFC 8707 resource indicators — when `resource` may be sent
186+// ---------------------------------------------------------------------------
187+
188+/** The authorization server's discovered RFC 8414 metadata, narrowed to the one
189+ * field the resource-indicator decision reads.
190+ *
191+ * `resource_indicators_supported` is NOT in the IANA "OAuth Authorization
192+ * Server Metadata" registry — RFC 8707 registered no discovery parameter at
193+ * all. It is the de-facto flag servers that do implement resource indicators
194+ * advertise, so it is the only machine-readable signal available. */
195+export type ResourceIndicatorSupport = {
196+ readonly resource_indicators_supported?: boolean;
197+};
198+
199+/** Whether the RFC 8707 `resource` parameter may be sent to this authorization
200+ * server. THE RULE, in full:
201+ *
202+ * - No metadata was discovered (`undefined` / `null`) → SEND. Nothing is known
203+ * about the server, and MCP Authorization 2025-06-18 tells clients to send
204+ * resource indicators. This is the unchanged, pre-existing behavior and it
205+ * covers every manually configured provider.
206+ * - Metadata was discovered and `resource_indicators_supported` is `true`
207+ * → SEND. The server says it implements RFC 8707.
208+ * - Metadata was discovered and the flag is absent or `false` → OMIT. Per
209+ * RFC 8414 §2 an omitted metadata field means the capability is not
210+ * advertised, and RFC 8707 §2 makes `resource` OPTIONAL for clients, so
211+ * omitting it is conformant. Sending it anyway is what breaks Microsoft
212+ * Entra v2, which rejects `resource` alongside a v2 `scope` with
213+ * AADSTS9010010.
214+ *
215+ * Deliberately NOT a per-provider special case: the decision reads only what
216+ * the server advertises about itself. */
217+export const shouldSendResourceIndicator = (
218+ authorizationServerMetadata?: ResourceIndicatorSupport | null,
219+): boolean =>
220+ authorizationServerMetadata == null
221+ ? true
222+ : authorizationServerMetadata.resource_indicators_supported === true;
223+
224+/** The `resource` value to actually put on the wire — `undefined` when the
225+ * caller has none, or when the authorization server does not advertise
226+ * RFC 8707 support. */
227+const resourceParamFor = (input: {
228+ readonly resource?: string | null;
229+ readonly authorizationServerMetadata?: ResourceIndicatorSupport | null;
230+}): string | undefined =>
231+ input.resource && shouldSendResourceIndicator(input.authorizationServerMetadata)
232+ ? input.resource
233+ : undefined;
234+
184235 // ---------------------------------------------------------------------------
185236 // Authorization URL builder
186237 // ---------------------------------------------------------------------------
@@ -196,9 +247,12 @@ export type BuildAuthorizationUrlInput = { · 9 unmodified lines
196247 /** Separator between scopes. RFC 6749 says space; some providers use comma. */
197248 readonly scopeSeparator?: string;
198249 /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 §"Resource
199− * Parameter Implementation" requires clients to send this on every
200− * authorization request, regardless of AS support. */
250+ * Parameter Implementation" asks clients to send this on every authorization
251+ * request; `authorizationServerMetadata` can veto it. */
201252 readonly resource?: string;
253+ /** The authorization server's discovered metadata. Gates `resource` — see
254+ * `shouldSendResourceIndicator`. Omit when nothing was discovered. */
255+ readonly authorizationServerMetadata?: ResourceIndicatorSupport | null;
202256 /** Provider-specific extras (e.g. Google's `access_type=offline`). */
203257 readonly extraParams?: Readonly<Record<string, string>>;
204258 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
@@ -227,8 +281,9 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string · 22 unmodified lines
227281 url.searchParams.set("state", input.state);
228282 url.searchParams.set("code_challenge_method", "S256");
229283 url.searchParams.set("code_challenge", input.codeChallenge);
230− if (input.resource) {
231− url.searchParams.set("resource", input.resource);
284+ const resource = resourceParamFor(input);
285+ if (resource) {
286+ url.searchParams.set("resource", resource);
232287 }
233288 if (input.extraParams) {
234289 for (const [k, v] of Object.entries(input.extraParams)) {
@@ -1070,10 +1125,13 @@ export type ExchangeAuthorizationCodeInput = { · 835 unmodified lines
10701125 readonly code: string;
10711126 readonly clientAuth?: ClientAuthMethod;
10721127 readonly idTokenSigningAlgValuesSupported?: readonly string[];
1073− /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on
1074− * the token request when the client knows the resource it intends
1075− * to call. */
1128+ /** RFC 8707 Resource Indicator. The MCP Auth spec asks for this on the token
1129+ * request when the client knows the resource it intends to call;
1130+ * `authorizationServerMetadata` can veto it. */
10761131 readonly resource?: string;
1132+ /** The authorization server's discovered metadata. Gates `resource` — see
1133+ * `shouldSendResourceIndicator`. Omit when nothing was discovered. */
1134+ readonly authorizationServerMetadata?: ResourceIndicatorSupport | null;
10771135 readonly timeoutMs?: number;
10781136 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
10791137 readonly fetch?: typeof globalThis.fetch;
@@ -1103,8 +1161,9 @@ export const exchangeAuthorizationCode = ( · 23 unmodified lines
11031161 redirect_uri: input.redirectUrl,
11041162 code_verifier: input.codeVerifier,
11051163 });
1106− if (input.resource) {
1107− params.set("resource", input.resource);
1164+ const resource = resourceParamFor(input);
1165+ if (resource) {
1166+ params.set("resource", resource);
11081167 }
11091168 const response = await oauth.genericTokenEndpointRequest(
11101169 as,
@@ -1128,7 +1187,7 @@ export const exchangeAuthorizationCode = ( · 17 unmodified lines
11281187 grantType: "authorization_code",
11291188 tokenUrl: input.tokenUrl,
11301189 clientAuth: input.clientAuth,
1131− hasResource: input.resource !== undefined,
1190+ hasResource: resourceParamFor(input) !== undefined,
11321191 }),
11331192 );
11341193
@@ -1143,9 +1202,13 @@ export type ExchangeClientCredentialsInput = { · 8 unmodified lines
11431202 readonly scopes?: readonly string[];
11441203 readonly scopeSeparator?: string;
11451204 readonly clientAuth?: ClientAuthMethod;
1146− /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 requires this
1147− * on token requests when the client knows the protected resource. */
1205+ /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 asks for this on
1206+ * token requests when the client knows the protected resource;
1207+ * `authorizationServerMetadata` can veto it. */
11481208 readonly resource?: string;
1209+ /** The authorization server's discovered metadata. Gates `resource` — see
1210+ * `shouldSendResourceIndicator`. Omit when nothing was discovered. */
1211+ readonly authorizationServerMetadata?: ResourceIndicatorSupport | null;
11491212 readonly timeoutMs?: number;
11501213 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
11511214 readonly fetch?: typeof globalThis.fetch;
@@ -1166,8 +1229,9 @@ export const exchangeClientCredentials = ( · 14 unmodified lines
11661229 if (input.scopes && input.scopes.length > 0) {
11671230 params.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
11681231 }
1169− if (input.resource) {
1170− params.set("resource", input.resource);
1232+ const resource = resourceParamFor(input);
1233+ if (resource) {
1234+ params.set("resource", resource);
11711235 }
11721236 const response = await oauth.clientCredentialsGrantRequest(
11731237 as,
@@ -1191,7 +1255,7 @@ export const exchangeClientCredentials = ( · 17 unmodified lines
11911255 grantType: "client_credentials",
11921256 tokenUrl: input.tokenUrl,
11931257 clientAuth: input.clientAuth,
1194− hasResource: input.resource !== undefined,
1258+ hasResource: resourceParamFor(input) !== undefined,
11951259 }),
11961260 );
11971261
@@ -1209,10 +1273,13 @@ export type RefreshAccessTokenInput = { · 11 unmodified lines
12091273 readonly scopeSeparator?: string;
12101274 readonly clientAuth?: ClientAuthMethod;
12111275 readonly idTokenSigningAlgValuesSupported?: readonly string[];
1212− /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on
1213− * refresh requests so the new access token's audience is bound to
1214− * the same resource. */
1276+ /** RFC 8707 Resource Indicator — the MCP spec asks for this on refresh
1277+ * requests so the new access token's audience stays bound to the same
1278+ * resource; `authorizationServerMetadata` can veto it. */
12151279 readonly resource?: string;
1280+ /** The authorization server's discovered metadata. Gates `resource` — see
1281+ * `shouldSendResourceIndicator`. Omit when nothing was discovered. */
1282+ readonly authorizationServerMetadata?: ResourceIndicatorSupport | null;
12161283 readonly timeoutMs?: number;
12171284 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
12181285 readonly fetch?: typeof globalThis.fetch;
@@ -1236,8 +1303,9 @@ export const refreshAccessToken = ( · 17 unmodified lines
12361303 if (input.scopes && input.scopes.length > 0) {
12371304 extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
12381305 }
1239− if (input.resource) {
1240− extraParams.set("resource", input.resource);
1306+ const resource = resourceParamFor(input);
1307+ if (resource) {
1308+ extraParams.set("resource", resource);
12411309 }
12421310 const additionalParameters =
12431311 Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;
@@ -1270,7 +1338,7 @@ export const refreshAccessToken = ( · 26 unmodified lines
12701338 grantType: "refresh_token",
12711339 tokenUrl: input.tokenUrl,
12721340 clientAuth: input.clientAuth,
1273− hasResource: input.resource !== undefined,
1341+ hasResource: resourceParamFor(input) !== undefined,
12741342 }),
12751343 );
12761344