Skip to content

Commit fb952d8

Browse files
committed
Gate the RFC 8707 resource parameter on advertised authorization-server support
1 parent ac2dec6 commit fb952d8

5 files changed

Lines changed: 409 additions & 21 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,9 @@ describe("beginDynamicAuthorization", () => {
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", () => {
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", () => {
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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ export const OAuthAuthorizationServerMetadataSchema = Schema.Struct({
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 = (
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

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ import {
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", () => {
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);

0 commit comments

Comments
 (0)