History

Preserve an explicitly absent OAuth resource instead of gating on discovery metadata

Rhys Sullivan committed 2 months ago

Commit 32c702b1 parent 2d9dcd911 files · +282 −437Browse files

Unified · Split

11 files
  1. .changeset/oauth-resource-explicit-absence.md +17 −0
  2. .changeset/oauth-resource-indicator-capability-gate.md +0 −19
  3. packages/core/sdk/src/executor.ts +8 −2
  4. packages/core/sdk/src/oauth-discovery.test.ts +0 −59
  5. packages/core/sdk/src/oauth-discovery.ts +0 −12
  6. packages/core/sdk/src/oauth-flow.test.ts +157 −0
  7. packages/core/sdk/src/oauth-helpers.test.ts +0 −230
  8. packages/core/sdk/src/oauth-helpers.ts +21 −89
  9. packages/core/sdk/src/oauth-scope-union.test.ts +28 −16
  10. packages/core/sdk/src/oauth-service.ts +21 −7
  11. packages/react/src/components/oauth-client-form.tsx +30 −3
.changeset/oauth-resource-explicit-absence.md+17−0
@@ -0,0 +1,17 @@
1---
2"executor": patch
3"@executor-js/react": patch
4---
5
6**An OAuth app can now be registered without an RFC 8707 resource, and that absence holds on every request**
7
8Microsoft Entra v2 rejects any authorization request that carries both a v2 `scope` (such as `https://api.fabric.microsoft.com/.default`) and the RFC 8707 `resource` parameter, failing with `AADSTS9010010` before the consent screen. Executor made that unavoidable for MCP servers behind Entra: registering an app for an MCP integration always derived the MCP endpoint as the resource, the form had no field to change it, and so every request carried the parameter Entra rejects.
9
10The register/edit OAuth app form now shows the resource indicator. It is still prefilled for MCP servers — nothing changes for providers that accept the parameter — but it can be cleared, and a cleared value persists as "no resource". A resource-less app then omits `resource` on all four grants alike: the authorization request, the code exchange, token refresh, and client-credentials. Symmetry matters here — sending `resource` on authorize but not on the token request (or the reverse) would bind the two tokens to different audiences.
11
12Two adjacent gaps closed with it:
13
14- MCP scope discovery no longer depends on the app's resource. It now falls back to the integration's own discovery URL (the MCP endpoint), so clearing the resource does not break connecting.
15- Token refresh for a first-party OAuth app dropped the app's configured resource, refreshing to a different audience than the original grant. It now sends the same resource the authorization request sent.
16
17Apps that keep their resource — the default for every discovered MCP server — behave exactly as before: the parameter is sent on every grant, as the MCP authorization spec expects.
.changeset/oauth-resource-indicator-capability-gate.md+0−19
@@ -1,19 +0,0 @@
1---
2"executor": patch
3---
4
5**The RFC 8707 `resource` parameter is now gated on what the authorization server advertises**
6
7Executor 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
9The 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
15The 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
17The 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
19Providers whose authorization server advertises the capability, and providers reached without any metadata discovery, are unaffected.
packages/core/sdk/src/executor.ts+8−2
@@ -2262,7 +2262,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2261 unmodified lines
2262 clientSecret: firstParty.clientSecret,2262 clientSecret: firstParty.clientSecret,
2263 tokenUrl: firstParty.tokenUrl,2263 tokenUrl: firstParty.tokenUrl,
2264 grant: "authorization_code",2264 grant: "authorization_code",
2265 resource: null,2265 // RFC 8707: the SAME resource the authorize/exchange path sent
2266 // (`loadedFirstPartyClient`), so the refreshed token keeps the
2267 // audience the original grant was bound to. Dropping it here
2268 // made refresh asymmetric with authorize for first-party apps.
2269 resource: firstParty.resource ?? null,
2266 } satisfies RefreshClient;2270 } satisfies RefreshClient;
2267 }2271 }
2268 const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;2272 const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;
@@ -5202,8 +5206,10 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2933 unmodified lines
5202 const oauth = selected?.kind === "oauth" ? selected.oauth : undefined;5206 const oauth = selected?.kind === "oauth" ? selected.oauth : undefined;
5203 // Declared scopes win. Discover only when the selected method5207 // Declared scopes win. Discover only when the selected method
5204 // declares none but names a source to discover them from (MCP).5208 // declares none but names a source to discover them from (MCP).
5209 // The discovery URL rides along so `oauth.start` can discover
5210 // scopes even for a client whose RFC 8707 resource was cleared.
5205 if (oauth?.scopes === undefined && oauth?.discoveryUrl !== undefined) {5211 if (oauth?.scopes === undefined && oauth?.discoveryUrl !== undefined) {
5206 return { kind: "discover" };5212 return { kind: "discover", discoveryUrl: oauth.discoveryUrl };
5207 }5213 }
5208 return { kind: "scopes", scopes: oauth?.scopes ?? [] };5214 return { kind: "scopes", scopes: oauth?.scopes ?? [] };
5209 }),5215 }),
packages/core/sdk/src/oauth-discovery.test.ts+0−59
@@ -369,9 +369,6 @@ describe("beginDynamicAuthorization", () => { · 368 unmodified lines
369 scopes_supported: ["openid", "profile", "email", "offline_access", "workspace:member"],369 scopes_supported: ["openid", "profile", "email", "offline_access", "workspace:member"],
370 response_types_supported: ["code"],370 response_types_supported: ["code"],
371 code_challenge_methods_supported: ["S256"],371 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,
375 });372 });
376 }373 }
377 if (request.url === "/oauth/register") {374 if (request.url === "/oauth/register") {
@@ -627,7 +624,6 @@ describe("beginDynamicAuthorization", () => { · 249 unmodified lines
627 registration_endpoint: `${baseUrl}/register`,624 registration_endpoint: `${baseUrl}/register`,
628 response_types_supported: ["code"],625 response_types_supported: ["code"],
629 code_challenge_methods_supported: ["S256"],626 code_challenge_methods_supported: ["S256"],
630 resource_indicators_supported: true,
631 });627 });
632 }628 }
633 if (request.url === "/register") {629 if (request.url === "/register") {
@@ -656,61 +652,6 @@ describe("beginDynamicAuthorization", () => { · 22 unmodified lines
656 ),652 ),
657 );653 );
658 654
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
714 it.effect("includes client_uri in the DCR body", () =>655 it.effect("includes client_uri in the DCR body", () =>
715 withOAuthFixture(656 withOAuthFixture(
716 (request, baseUrl) => {657 (request, baseUrl) => {
packages/core/sdk/src/oauth-discovery.ts+0−12
@@ -82,14 +82,6 @@ export const OAuthAuthorizationServerMetadataSchema = Schema.Struct({ · 81 unmodified lines
82 introspection_endpoint: Schema.optional(Schema.String),82 introspection_endpoint: Schema.optional(Schema.String),
83 userinfo_endpoint: Schema.optional(Schema.String),83 userinfo_endpoint: Schema.optional(Schema.String),
84 id_token_signing_alg_values_supported: Schema.optional(StringArray),84 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),
93 /** draft-ietf-oauth-identity-assertion-authz-grant-04 §7.2 — the85 /** draft-ietf-oauth-identity-assertion-authz-grant-04 §7.2 — the
94 * authorization grant profiles this Resource Authorization Server86 * authorization grant profiles this Resource Authorization Server
95 * implements. Advertising a profile says only that the server implements87 * implements. Advertising a profile says only that the server implements
@@ -906,10 +898,6 @@ export const beginDynamicAuthorization = ( · 810 unmodified lines
906 state: input.state,898 state: input.state,
907 codeChallenge,899 codeChallenge,
908 resource: resourceValue,900 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,
913 endpointUrlPolicy: options.endpointUrlPolicy,901 endpointUrlPolicy: options.endpointUrlPolicy,
914 });902 });
915 903
packages/core/sdk/src/oauth-flow.test.ts+157−0
@@ -2005,3 +2005,160 @@ describe("reactive OAuth refresh on upstream 401", () => { · 2004 unmodified lines
2005 ),2005 ),
2006 );2006 );
2007});2007});
2008
2009// ---------------------------------------------------------------------------
2010// RFC 8707 resource omission for a resource-less client (#1789)
2011//
2012// A client persisted with NO resource sends no `resource` parameter on ANY
2013// request — authorize, code exchange, refresh, client-credentials. Microsoft
2014// Entra v2 rejects requests that carry `resource` next to a v2 `scope`
2015// (AADSTS9010010), and the way out is a client whose resource is absent; that
2016// absence must hold on every grant, or the token audience diverges between
2017// authorize and token. The mirror-image assertions — a client WITH a resource
2018// sends it on authorize + exchange + refresh — live in the tests above.
2019// ---------------------------------------------------------------------------
2020describe("resource-less client sends no resource parameter (#1789)", () => {
2021 it.effect("authorize, code exchange, and refresh all omit `resource`", () =>
2022 Effect.scoped(
2023 Effect.gen(function* () {
2024 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2025 const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
2026 yield* executor.acme.seed();
2027
2028 // `resource: null` — explicitly none, not merely unset.
2029 yield* executor.oauth.createClient({
2030 owner: "org",
2031 slug: CLIENT,
2032 authorizationUrl: server.authorizationEndpoint,
2033 tokenUrl: server.tokenEndpoint,
2034 grant: "authorization_code",
2035 clientId: "test-client",
2036 clientSecret: "test-secret",
2037 resource: null,
2038 });
2039
2040 const started = yield* executor.oauth.start({
2041 owner: "org",
2042 client: CLIENT,
2043 clientOwner: "org",
2044 name: ConnectionName.make("main"),
2045 integration: INTEG,
2046 template: TEMPLATE,
2047 });
2048 expect(started.status).toBe("redirect");
2049 if (started.status !== "redirect") return;
2050 expect(new URL(started.authorizationUrl).searchParams.has("resource")).toBe(false);
2051
2052 const callback = yield* server.completeAuthorizationCodeFlow({
2053 authorizationUrl: started.authorizationUrl,
2054 });
2055 yield* executor.oauth.complete({ state: started.state, code: callback.code });
2056
2057 // Force expiry so the next execute refreshes.
2058 yield* Effect.promise(() =>
2059 config.db.updateMany("connection", {
2060 where: (b) => b("name", "=", "main"),
2061 set: { expires_at: Date.now() - 60_000 },
2062 }),
2063 );
2064 const refreshed = (yield* executor.execute(
2065 ToolAddress.make("tools.acme.org.main.whoami"),
2066 {},
2067 )) as { token: string };
2068 expect(refreshed.token).toMatch(/^at_/);
2069
2070 // What the authorization server actually SAW: the authorize request,
2071 // the code exchange, and the refresh each carried no `resource`.
2072 const requests = yield* server.requests;
2073 const authorize = requests.find((r) => r.path === "/authorize" && r.method === "GET");
2074 expect(authorize).toBeDefined();
2075 expect(authorize?.query.resource ?? null).toBeNull();
2076 const exchange = requests.find(
2077 (r) => r.path === "/token" && r.body.includes("grant_type=authorization_code"),
2078 );
2079 expect(exchange).toBeDefined();
2080 expect(exchange?.body ?? "").not.toContain("resource=");
2081 const refresh = requests.find(
2082 (r) => r.path === "/token" && r.body.includes("grant_type=refresh_token"),
2083 );
2084 expect(refresh).toBeDefined();
2085 expect(refresh?.body ?? "").not.toContain("resource=");
2086 }),
2087 ),
2088 );
2089
2090 it.effect("client_credentials omits `resource` for a resource-less client", () =>
2091 Effect.scoped(
2092 Effect.gen(function* () {
2093 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2094 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
2095 yield* executor.acme.seed();
2096
2097 yield* executor.oauth.createClient({
2098 owner: "org",
2099 slug: CLIENT,
2100 authorizationUrl: server.authorizationEndpoint,
2101 tokenUrl: server.tokenEndpoint,
2102 grant: "client_credentials",
2103 clientId: "test-client",
2104 clientSecret: "test-secret",
2105 resource: null,
2106 });
2107
2108 const started = yield* executor.oauth.start({
2109 owner: "org",
2110 client: CLIENT,
2111 clientOwner: "org",
2112 name: ConnectionName.make("cc"),
2113 integration: INTEG,
2114 template: TEMPLATE,
2115 });
2116 expect(started.status).toBe("connected");
2117
2118 const requests = yield* server.requests;
2119 const grant = requests.find(
2120 (r) => r.path === "/token" && r.body.includes("grant_type=client_credentials"),
2121 );
2122 expect(grant).toBeDefined();
2123 expect(grant?.body ?? "").not.toContain("resource=");
2124 }),
2125 ),
2126 );
2127
2128 it.effect("client_credentials sends `resource` when the client has one", () =>
2129 Effect.scoped(
2130 Effect.gen(function* () {
2131 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
2132 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
2133 yield* executor.acme.seed();
2134
2135 yield* executor.oauth.createClient({
2136 owner: "org",
2137 slug: CLIENT,
2138 authorizationUrl: server.authorizationEndpoint,
2139 tokenUrl: server.tokenEndpoint,
2140 grant: "client_credentials",
2141 clientId: "test-client",
2142 clientSecret: "test-secret",
2143 resource: server.mcpResourceUrl,
2144 });
2145
2146 const started = yield* executor.oauth.start({
2147 owner: "org",
2148 client: CLIENT,
2149 clientOwner: "org",
2150 name: ConnectionName.make("cc"),
2151 integration: INTEG,
2152 template: TEMPLATE,
2153 });
2154 expect(started.status).toBe("connected");
2155
2156 const requests = yield* server.requests;
2157 const grant = requests.find(
2158 (r) => r.path === "/token" && r.body.includes("grant_type=client_credentials"),
2159 );
2160 expect(grant?.body).toContain(`resource=${encodeURIComponent(server.mcpResourceUrl)}`);
2161 }),
2162 ),
2163 );
2164});
packages/core/sdk/src/oauth-helpers.test.ts+0−230
@@ -26,10 +26,7 @@ import { · 25 unmodified lines
26 isUnusableSuccessTokenResponse,26 isUnusableSuccessTokenResponse,
27 refreshAccessToken,27 refreshAccessToken,
28 shouldRefreshToken,28 shouldRefreshToken,
29 shouldSendResourceIndicator,
30 type ResourceIndicatorSupport,
31} from "./oauth-helpers";29} from "./oauth-helpers";
32import type { OAuthAuthorizationServerMetadata } from "./oauth-discovery";
33import { serveTestHttpApp } from "./testing";30import { serveTestHttpApp } from "./testing";
34 31
35interface TokenCall {32interface TokenCall {
@@ -1426,233 +1423,6 @@ describe("refreshAccessToken", () => { · 1390 unmodified lines
1426 );1423 );
1427});1424});
1428 1425
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
1443const RESOURCE = "https://api.example.com/v1/mcp";
1444const AS_SUPPORTED: ResourceIndicatorSupport = { resource_indicators_supported: true };
1445const 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. */
1449const 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
1455describe("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
1470describe("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
1505describe("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
1556describe("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
1605describe("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
1656describe("shouldRefreshToken", () => {1426describe("shouldRefreshToken", () => {
1657 it("never refreshes when expiresAt is null", () => {1427 it("never refreshes when expiresAt is null", () => {
1658 expect(shouldRefreshToken({ expiresAt: null })).toBe(false);1428 expect(shouldRefreshToken({ expiresAt: null })).toBe(false);
packages/core/sdk/src/oauth-helpers.ts+21−89
@@ -181,57 +181,6 @@ export const createPkceCodeChallenge = (verifier: string): Promise<string> => · 180 unmodified lines
181 * and redeemed by `oauth.complete`. */181 * and redeemed by `oauth.complete`. */
182export const createOAuthState = (): string => oauth.generateRandomState();182export const createOAuthState = (): string => oauth.generateRandomState();
183 183
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. */
195export 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. */
217export 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. */
227const 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
235// ---------------------------------------------------------------------------184// ---------------------------------------------------------------------------
236// Authorization URL builder185// Authorization URL builder
237// ---------------------------------------------------------------------------186// ---------------------------------------------------------------------------
@@ -247,12 +196,9 @@ export type BuildAuthorizationUrlInput = { · 9 unmodified lines
247 /** Separator between scopes. RFC 6749 says space; some providers use comma. */196 /** Separator between scopes. RFC 6749 says space; some providers use comma. */
248 readonly scopeSeparator?: string;197 readonly scopeSeparator?: string;
249 /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 §"Resource198 /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 §"Resource
250 * Parameter Implementation" asks clients to send this on every authorization199 * Parameter Implementation" requires clients to send this on every
251 * request; `authorizationServerMetadata` can veto it. */200 * authorization request, regardless of AS support. */
252 readonly resource?: string;201 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;
256 /** Provider-specific extras (e.g. Google's `access_type=offline`). */202 /** Provider-specific extras (e.g. Google's `access_type=offline`). */
257 readonly extraParams?: Readonly<Record<string, string>>;203 readonly extraParams?: Readonly<Record<string, string>>;
258 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;204 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
@@ -281,9 +227,8 @@ export const buildAuthorizationUrl = (input: BuildAuthorizationUrlInput): string · 22 unmodified lines
281 url.searchParams.set("state", input.state);227 url.searchParams.set("state", input.state);
282 url.searchParams.set("code_challenge_method", "S256");228 url.searchParams.set("code_challenge_method", "S256");
283 url.searchParams.set("code_challenge", input.codeChallenge);229 url.searchParams.set("code_challenge", input.codeChallenge);
284 const resource = resourceParamFor(input);230 if (input.resource) {
285 if (resource) {231 url.searchParams.set("resource", input.resource);
286 url.searchParams.set("resource", resource);
287 }232 }
288 if (input.extraParams) {233 if (input.extraParams) {
289 for (const [k, v] of Object.entries(input.extraParams)) {234 for (const [k, v] of Object.entries(input.extraParams)) {
@@ -1125,13 +1070,10 @@ export type ExchangeAuthorizationCodeInput = { · 835 unmodified lines
1125 readonly code: string;1070 readonly code: string;
1126 readonly clientAuth?: ClientAuthMethod;1071 readonly clientAuth?: ClientAuthMethod;
1127 readonly idTokenSigningAlgValuesSupported?: readonly string[];1072 readonly idTokenSigningAlgValuesSupported?: readonly string[];
1128 /** RFC 8707 Resource Indicator. The MCP Auth spec asks for this on the token1073 /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on
1129 * request when the client knows the resource it intends to call;1074 * the token request when the client knows the resource it intends
1130 * `authorizationServerMetadata` can veto it. */1075 * to call. */
1131 readonly resource?: string;1076 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;
1135 readonly timeoutMs?: number;1077 readonly timeoutMs?: number;
1136 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;1078 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
1137 readonly fetch?: typeof globalThis.fetch;1079 readonly fetch?: typeof globalThis.fetch;
@@ -1161,9 +1103,8 @@ export const exchangeAuthorizationCode = ( · 23 unmodified lines
1161 redirect_uri: input.redirectUrl,1103 redirect_uri: input.redirectUrl,
1162 code_verifier: input.codeVerifier,1104 code_verifier: input.codeVerifier,
1163 });1105 });
1164 const resource = resourceParamFor(input);1106 if (input.resource) {
1165 if (resource) {1107 params.set("resource", input.resource);
1166 params.set("resource", resource);
1167 }1108 }
1168 const response = await oauth.genericTokenEndpointRequest(1109 const response = await oauth.genericTokenEndpointRequest(
1169 as,1110 as,
@@ -1187,7 +1128,7 @@ export const exchangeAuthorizationCode = ( · 17 unmodified lines
1187 grantType: "authorization_code",1128 grantType: "authorization_code",
1188 tokenUrl: input.tokenUrl,1129 tokenUrl: input.tokenUrl,
1189 clientAuth: input.clientAuth,1130 clientAuth: input.clientAuth,
1190 hasResource: resourceParamFor(input) !== undefined,1131 hasResource: input.resource !== undefined,
1191 }),1132 }),
1192 );1133 );
1193 1134
@@ -1202,13 +1143,9 @@ export type ExchangeClientCredentialsInput = { · 8 unmodified lines
1202 readonly scopes?: readonly string[];1143 readonly scopes?: readonly string[];
1203 readonly scopeSeparator?: string;1144 readonly scopeSeparator?: string;
1204 readonly clientAuth?: ClientAuthMethod;1145 readonly clientAuth?: ClientAuthMethod;
1205 /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 asks for this on1146 /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 requires this
1206 * token requests when the client knows the protected resource;1147 * on token requests when the client knows the protected resource. */
1207 * `authorizationServerMetadata` can veto it. */
1208 readonly resource?: string;1148 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;
1212 readonly timeoutMs?: number;1149 readonly timeoutMs?: number;
1213 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;1150 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
1214 readonly fetch?: typeof globalThis.fetch;1151 readonly fetch?: typeof globalThis.fetch;
@@ -1229,9 +1166,8 @@ export const exchangeClientCredentials = ( · 14 unmodified lines
1229 if (input.scopes && input.scopes.length > 0) {1166 if (input.scopes && input.scopes.length > 0) {
1230 params.set("scope", input.scopes.join(input.scopeSeparator ?? " "));1167 params.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
1231 }1168 }
1232 const resource = resourceParamFor(input);1169 if (input.resource) {
1233 if (resource) {1170 params.set("resource", input.resource);
1234 params.set("resource", resource);
1235 }1171 }
1236 const response = await oauth.clientCredentialsGrantRequest(1172 const response = await oauth.clientCredentialsGrantRequest(
1237 as,1173 as,
@@ -1255,7 +1191,7 @@ export const exchangeClientCredentials = ( · 17 unmodified lines
1255 grantType: "client_credentials",1191 grantType: "client_credentials",
1256 tokenUrl: input.tokenUrl,1192 tokenUrl: input.tokenUrl,
1257 clientAuth: input.clientAuth,1193 clientAuth: input.clientAuth,
1258 hasResource: resourceParamFor(input) !== undefined,1194 hasResource: input.resource !== undefined,
1259 }),1195 }),
1260 );1196 );
1261 1197
@@ -1273,13 +1209,10 @@ export type RefreshAccessTokenInput = { · 11 unmodified lines
1273 readonly scopeSeparator?: string;1209 readonly scopeSeparator?: string;
1274 readonly clientAuth?: ClientAuthMethod;1210 readonly clientAuth?: ClientAuthMethod;
1275 readonly idTokenSigningAlgValuesSupported?: readonly string[];1211 readonly idTokenSigningAlgValuesSupported?: readonly string[];
1276 /** RFC 8707 Resource Indicator — the MCP spec asks for this on refresh1212 /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on
1277 * requests so the new access token's audience stays bound to the same1213 * refresh requests so the new access token's audience is bound to
1278 * resource; `authorizationServerMetadata` can veto it. */1214 * the same resource. */
1279 readonly resource?: string;1215 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;
1283 readonly timeoutMs?: number;1216 readonly timeoutMs?: number;
1284 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;1217 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
1285 readonly fetch?: typeof globalThis.fetch;1218 readonly fetch?: typeof globalThis.fetch;
@@ -1303,9 +1236,8 @@ export const refreshAccessToken = ( · 17 unmodified lines
1303 if (input.scopes && input.scopes.length > 0) {1236 if (input.scopes && input.scopes.length > 0) {
1304 extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " "));1237 extraParams.set("scope", input.scopes.join(input.scopeSeparator ?? " "));
1305 }1238 }
1306 const resource = resourceParamFor(input);1239 if (input.resource) {
1307 if (resource) {1240 extraParams.set("resource", input.resource);
1308 extraParams.set("resource", resource);
1309 }1241 }
1310 const additionalParameters =1242 const additionalParameters =
1311 Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;1243 Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;
@@ -1338,7 +1270,7 @@ export const refreshAccessToken = ( · 26 unmodified lines
1338 grantType: "refresh_token",1270 grantType: "refresh_token",
1339 tokenUrl: input.tokenUrl,1271 tokenUrl: input.tokenUrl,
1340 clientAuth: input.clientAuth,1272 clientAuth: input.clientAuth,
1341 hasResource: resourceParamFor(input) !== undefined,1273 hasResource: input.resource !== undefined,
1342 }),1274 }),
1343 );1275 );
1344 1276
packages/core/sdk/src/oauth-scope-union.test.ts+28−16
@@ -38,7 +38,7 @@ const DECLARED_SCOPES = ["calendar", "gmail", "drive", "sheets"] as const; · 37 unmodified lines
38const makeScopePluginWithId = <const TId extends string>(38const makeScopePluginWithId = <const TId extends string>(
39 id: TId,39 id: TId,
40 config: { readonly scopes: readonly string[] | null },40 config: { readonly scopes: readonly string[] | null },
41 options: { readonly discoversScopes?: boolean } = {},41 options: { readonly discoversScopes?: boolean; readonly discoveryUrl?: string } = {},
42) =>42) =>
43 definePlugin(() => ({43 definePlugin(() => ({
44 id,44 id,
@@ -62,7 +62,7 @@ const makeScopePluginWithId = <const TId extends string>( · 17 unmodified lines
62 kind: "oauth",62 kind: "oauth",
63 template: String(TEMPLATE),63 template: String(TEMPLATE),
64 ...(options.discoversScopes64 ...(options.discoversScopes
65 ? { oauth: { discoveryUrl: `https://${id}.example/mcp` } }65 ? { oauth: { discoveryUrl: options.discoveryUrl ?? `https://${id}.example/mcp` } }
66 : {}),66 : {}),
67 },67 },
68 ];68 ];
@@ -495,19 +495,28 @@ describe("oauth.start integration-driven scopes", () => { · 426 unmodified lines
495 );495 );
496 496
497 it.effect(497 it.effect(
498 "(h) for MCP, a client with no resource fails start (discovery cannot run without one)",498 "(h) for MCP, a client with no resource still discovers scopes from the integration's discovery URL",
499 () =>499 () =>
500 Effect.scoped(500 Effect.scoped(
501 Effect.gen(function* () {501 Effect.gen(function* () {
502 // #1789 — a user may CLEAR the client's RFC 8707 resource (Entra v2
503 // rejects the parameter). Scope discovery must not die with it: the
504 // integration's own discovery URL (the MCP endpoint) is probed
505 // instead, and the authorize request carries no `resource`.
502 const server = yield* serveMetadataServer({ prm: { scopesSupported: ["read"] } });506 const server = yield* serveMetadataServer({ prm: { scopesSupported: ["read"] } });
503 const plugins = [507 const plugins = [
504 memoryCredentialsPlugin(),508 memoryCredentialsPlugin(),
505 makeMcpScopePlugin({ scopes: null }),509 makeScopePluginWithId(
510 "mcp",
511 { scopes: null },
512 { discoversScopes: true, discoveryUrl: server.mcpResourceUrl },
513 ),
506 ] as const;514 ] as const;
507 const { executor } = yield* makeTestWorkspaceHarness({ plugins });515 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
508 yield* executor.mcp.seed();516 yield* executor.mcp.seed();
509 517
510 // No `resource` on the client — discovery has nothing to probe.518 // No `resource` on the client — the wire parameter is absent by
519 // choice, while discovery still has the integration's URL.
511 yield* executor.oauth.createClient({520 yield* executor.oauth.createClient({
512 owner: "org",521 owner: "org",
513 slug: CLIENT,522 slug: CLIENT,
@@ -518,17 +527,20 @@ describe("oauth.start integration-driven scopes", () => { · 4 unmodified lines
518 clientSecret: "test-secret",527 clientSecret: "test-secret",
519 });528 });
520 529
521 const exit = yield* Effect.exit(530 const started = yield* executor.oauth.start({
522 executor.oauth.start({531 owner: "org",
523 owner: "org",532 client: CLIENT,
524 client: CLIENT,533 clientOwner: "org",
525 clientOwner: "org",534 name: ConnectionName.make("main"),
526 name: ConnectionName.make("main"),535 integration: INTEG,
527 integration: INTEG,536 template: TEMPLATE,
528 template: TEMPLATE,537 });
529 }),538 expect(started.status).toBe("redirect");
530 );539 if (started.status !== "redirect") return;
531 expect(Exit.isFailure(exit)).toBe(true);540
541 expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual(["read"]);
542 // The cleared resource stays cleared on the wire.
543 expect(new URL(started.authorizationUrl).searchParams.has("resource")).toBe(false);
532 }),544 }),
533 ),545 ),
534 );546 );
packages/core/sdk/src/oauth-service.ts+21−7
@@ -167,10 +167,15 @@ const startErrorFromEnterpriseManaged = (cause: EnterpriseManagedMintError): OAu · 166 unmodified lines
167 * integration declares the scopes to request (`scopes`, possibly empty — an167 * integration declares the scopes to request (`scopes`, possibly empty — an
168 * empty set requests no scopes), or it declares none and the request scopes168 * empty set requests no scopes), or it declares none and the request scopes
169 * are discovered from the server's metadata at connect (`discover`, used by169 * are discovered from the server's metadata at connect (`discover`, used by
170 * MCP). The two are mutually exclusive by construction. */170 * MCP). The two are mutually exclusive by construction.
171 *
172 * `discover` carries the integration's own discovery URL (the MCP endpoint)
173 * so scope discovery does not depend on the CLIENT having a persisted RFC
174 * 8707 resource: a user may clear the client's resource (Entra v2 rejects
175 * the parameter, #1789) without losing scope discovery. */
171export type OAuthScopePolicy =176export type OAuthScopePolicy =
172 | { readonly kind: "scopes"; readonly scopes: readonly string[] }177 | { readonly kind: "scopes"; readonly scopes: readonly string[] }
173 | { readonly kind: "discover" };178 | { readonly kind: "discover"; readonly discoveryUrl: string };
174 179
175/** Everything the OAuth service needs from the executor: fuma access for the180/** Everything the OAuth service needs from the executor: fuma access for the
176 * owned `oauth_client` / `oauth_session` tables, the default credential181 * owned `oauth_client` / `oauth_session` tables, the default credential
@@ -205,10 +210,12 @@ export interface OAuthServiceDeps { · 28 unmodified lines
205 * DECLARES (e.g. an OpenAPI bundle's authentication-template scope union),210 * DECLARES (e.g. an OpenAPI bundle's authentication-template scope union),
206 * NOT the scopes frozen on a specific `oauth_client` row. These are211 * NOT the scopes frozen on a specific `oauth_client` row. These are
207 * requested verbatim at connect (`start`); an empty set requests none.212 * requested verbatim at connect (`start`); an empty set requests none.
208 * - `{ kind: "discover" }`: the integration declares no scopes, so `start`213 * - `{ kind: "discover", discoveryUrl }`: the integration declares no
209 * discovers the request scopes from the server's RFC 9728 / RFC 8414214 * scopes, so `start` discovers the request scopes from the server's RFC
210 * metadata. Used by server-targeting integrations (MCP) whose scopes live215 * 9728 / RFC 8414 metadata. Used by server-targeting integrations (MCP)
211 * on the server rather than in a template.216 * whose scopes live on the server rather than in a template.
217 * `discoveryUrl` is the integration's protected-resource URL (the MCP
218 * endpoint), used when the client persists no resource.
212 */219 */
213 readonly resolveOAuthScopePolicy: (220 readonly resolveOAuthScopePolicy: (
214 integration: IntegrationSlug,221 integration: IntegrationSlug,
@@ -1453,7 +1460,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1238 unmodified lines
1453 const requestedScopes =1460 const requestedScopes =
1454 scopePolicy.kind === "discover"1461 scopePolicy.kind === "discover"
1455 ? yield* (() => {1462 ? yield* (() => {
1456 const discovered = discoverScopesForResource(client.resource).pipe(1463 // Scope discovery reads protected-resource metadata. The client's
1464 // persisted resource is the historical source and stays primary,
1465 // but it is a WIRE parameter the user may clear (Entra v2 rejects
1466 // `resource`, #1789) — the integration's own discovery URL then
1467 // keeps scope discovery working for a resource-less client.
1468 const discovered = discoverScopesForResource(
1469 client.resource ?? scopePolicy.discoveryUrl,
1470 ).pipe(
1457 Effect.mapError(1471 Effect.mapError(
1458 (cause) =>1472 (cause) =>
1459 new OAuthStartError({1473 new OAuthStartError({
packages/react/src/components/oauth-client-form.tsx+30−3
@@ -224,6 +224,13 @@ export function OAuthClientForm(props: { · 223 unmodified lines
224 mode: "promiseExit",224 mode: "promiseExit",
225 });225 });
226 226
227 // Blank means "send no RFC 8707 resource": persist null so every OAuth
228 // request (authorize, exchange, refresh, client-credentials) omits the
229 // parameter. Clearing must stick — no endpoint is re-derived over an
230 // intentional absence (Entra v2 rejects `resource`, #1789).
231 const normalizedResource =
232 resource == null || resource.trim().length === 0 ? null : resource.trim();
233
227 const canSubmit = canSubmitOAuthClientForm({234 const canSubmit = canSubmitOAuthClientForm({
228 submitting,235 submitting,
229 name,236 name,
@@ -250,7 +257,7 @@ export function OAuthClientForm(props: { · 20 unmodified lines
250 authorizationUrl,257 authorizationUrl,
251 tokenUrl,258 tokenUrl,
252 issuer: discoveredIssuer,259 issuer: discoveredIssuer,
253 resource,260 resource: normalizedResource,
254 });261 });
255 const showAppSetup =262 const showAppSetup =
256 appSetup !== undefined && grant === "authorization_code" && !showAutoRegister;263 appSetup !== undefined && grant === "authorization_code" && !showAutoRegister;
@@ -303,7 +310,7 @@ export function OAuthClientForm(props: { · 46 unmodified lines
303 registrationEndpoint: registrationEndpoint.trim(),310 registrationEndpoint: registrationEndpoint.trim(),
304 authorizationUrl: authorizationUrl.trim(),311 authorizationUrl: authorizationUrl.trim(),
305 tokenUrl: tokenUrl.trim(),312 tokenUrl: tokenUrl.trim(),
306 resource,313 resource: normalizedResource,
307 // DCR sends the integration's declared scopes, or the discovered set when314 // DCR sends the integration's declared scopes, or the discovered set when
308 // none are declared, to the AS at registration (the app stores none).315 // none are declared, to the AS at registration (the app stores none).
309 scopes: [...registrationScopes(declaredScopes, discoveredScopes)],316 scopes: [...registrationScopes(declaredScopes, discoveredScopes)],
@@ -337,7 +344,7 @@ export function OAuthClientForm(props: { · 27 unmodified lines
337 grant,344 grant,
338 clientId: clientId.trim(),345 clientId: clientId.trim(),
339 clientSecret: clientSecret.trim(),346 clientSecret: clientSecret.trim(),
340 resource,347 resource: normalizedResource,
341 // Editing preserves the app's already-recorded origin (via348 // Editing preserves the app's already-recorded origin (via
342 // `intentIntegration`, passed verbatim by the caller); a fresh349 // `intentIntegration`, passed verbatim by the caller); a fresh
343 // registration from an integration's dialog stamps recorded intent.350 // registration from an integration's dialog stamps recorded intent.
@@ -613,6 +620,26 @@ export function OAuthClientForm(props: { · 269 unmodified lines
613 />620 />
614 </div>621 </div>
615 622
623 {/* RFC 8707 resource indicator. Prefilled for MCP servers; the field
624 exists so a user can CLEAR it — some authorization servers
625 (Microsoft Entra v2) reject requests that carry `resource`, and a
626 cleared value persists as "no resource" on every OAuth request. */}
627 <div className="space-y-1.5">
628 <Label htmlFor="oauth-resource" className="text-xs text-muted-foreground">
629 Resource indicator
630 <span className="font-normal text-muted-foreground/70">
631 optional — leave empty if the server rejects the resource parameter
632 </span>
633 </Label>
634 <Input
635 id="oauth-resource"
636 placeholder="https://mcp.example.com/mcp"
637 value={resource ?? ""}
638 onChange={(e: React.ChangeEvent<HTMLInputElement>) => setResource(e.target.value)}
639 className="font-mono"
640 />
641 </div>
642
616 {endpointsKnown ? (643 {endpointsKnown ? (
617 <Button644 <Button
618 type="button"645 type="button"