From f4e2f48e06f2d0423348771c0b3e502e26433b34 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:24:29 -0700 Subject: [PATCH] Carry OAuth scopes through extraction to the invocation credential Tool catalogs were compiled with no notion of scope: an operation's security declaration never survived extraction, and the credential built for dispatch never carried what the connection's grant covers. A connection whose grant is narrower than its integration's catalog (the multi-product Google bundle case) exposed every tool as equally invocable, and the eventual upstream 403 could not say which scope was missing versus held. Extract per-operation security scopes into ExtractedOperation and OperationBinding (all three compile paths: whole-tree, streamed, and structure-streamed; optional so stored bindings keep decoding), add grantedScopes to ToolInvocationCredential sourced from the connection row's oauth_scope, and use both to annotate scope-insufficient 403s with the exact required and granted scopes. Advisory-only by design: scope-string containment needs provider semantics (a broad Google scope does not textually contain a narrow one), so nothing is blocked locally and unknown grants fail open. Refs #1384 --- .../oauth-scope-insufficient.test.ts | 12 ++++ packages/core/sdk/src/executor.ts | 15 +++++ packages/core/sdk/src/plugin.ts | 6 ++ packages/plugins/openapi/src/sdk/backing.ts | 20 +++++- .../plugins/openapi/src/sdk/extract.test.ts | 66 +++++++++++++++++++ packages/plugins/openapi/src/sdk/extract.ts | 58 ++++++++++++++++ packages/plugins/openapi/src/sdk/types.ts | 13 ++++ .../openapi/src/sdk/upstream-failures.test.ts | 46 +++++++++++++ 8 files changed, 234 insertions(+), 2 deletions(-) diff --git a/e2e/scenarios/oauth-scope-insufficient.test.ts b/e2e/scenarios/oauth-scope-insufficient.test.ts index c9a6b7d1f..dcfea135e 100644 --- a/e2e/scenarios/oauth-scope-insufficient.test.ts +++ b/e2e/scenarios/oauth-scope-insufficient.test.ts @@ -281,6 +281,18 @@ scenario( scopeFailure.error?.message ?? "", "the message says re-authenticating will not help", ).toContain("Re-authenticating with the same grant"); + // Google's 403 body names no scope; the operation's own declared + // scope (carried through extraction into the stored binding) and + // the connection's granted scope (from its oauth_scope) fill in + // exactly what is missing versus what is held. + expect( + scopeFailure.error?.message ?? "", + "the message names the scope the operation requires, from the binding", + ).toContain("files.read"); + expect( + scopeFailure.error?.message ?? "", + "the message names the scope the grant holds, from the connection", + ).toContain("mail.read"); expect( scopeFailure.error?.details?.recovery?.startOAuthTool, "no oauth.start recovery hint", diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 5f470a70e..90acd5f16 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -582,6 +582,17 @@ const rowToConnection = (row: ConnectionRow): Connection => { }; }; +/** Parse a connection row's `oauth_scope` (space-delimited, as echoed by the + * token endpoint) into the credential's `grantedScopes`. Undefined when the + * row carries none, so scope comparisons downstream fail open. */ +const grantedScopesFromRow = (row: { + readonly oauth_scope?: unknown; +}): readonly string[] | undefined => { + if (row.oauth_scope == null) return undefined; + const scopes = String(row.oauth_scope).split(/\s+/).filter(Boolean); + return scopes.length > 0 ? scopes : undefined; +}; + /** The canonical credential variable for a single-secret connection. OAuth tokens * and the primary apiKey value resolve through it. */ const PRIMARY_INPUT_VARIABLE = "token"; @@ -2833,6 +2844,7 @@ export const createExecutor = ; /** The integration's stored config, for template rendering. */ readonly config: IntegrationConfig; + /** The OAuth scopes the connection's grant actually covers, from the + * connection row's `oauth_scope` (space-delimited, as returned by the + * token endpoint). Absent for non-OAuth connections and for OAuth + * providers that never echo a scope; consumers comparing against an + * operation's declared scopes must fail open when this is undefined. */ + readonly grantedScopes?: readonly string[]; } // --------------------------------------------------------------------------- diff --git a/packages/plugins/openapi/src/sdk/backing.ts b/packages/plugins/openapi/src/sdk/backing.ts index 234cefe09..0e7c87818 100644 --- a/packages/plugins/openapi/src/sdk/backing.ts +++ b/packages/plugins/openapi/src/sdk/backing.ts @@ -197,6 +197,9 @@ const toBinding = (def: ToolDefinition): OperationBinding => parameters: [...def.operation.parameters], requestBody: def.operation.requestBody, responseBody: def.operation.responseBody, + ...(def.operation.requiredScopeAlternatives + ? { requiredScopeAlternatives: def.operation.requiredScopeAlternatives } + : {}), }); const descriptionFor = (def: ToolDefinition): string => { @@ -731,11 +734,24 @@ export const invokeOpenApiBackedTool = (input: { ? detectInsufficientScope({ body: result.error, headers: result.headers }) : null; if (insufficientScope) { - const required = insufficientScope.requiredScopes; + // Name the shortfall as precisely as the data allows: the scopes + // the upstream challenge asked for, else the operation's declared + // requirement (from the binding; alternatives joined with "or", + // since each Security Requirement Object is one acceptable set), + // plus what the connection's grant actually holds. Advisory only — + // the upstream made the call; this annotation tells the agent/user + // what to reconnect with. + const required = + insufficientScope.requiredScopes.length > 0 + ? insufficientScope.requiredScopes.join(" ") + : (binding.requiredScopeAlternatives ?? []) + .map((alternative) => alternative.join(" ")) + .join(", or "); + const granted = input.credential.grantedScopes; return openApiAuthToolFailure({ code: "oauth_scope_insufficient", status: result.status, - message: `The connection "${input.credential.connection}" for "${integration}" is authorized, but its grant does not cover the scope this operation requires${required.length > 0 ? ` (${required.join(" ")})` : ""}. Re-authenticating with the same grant will return the same error; reconnect with broader access.`, + message: `The connection "${input.credential.connection}" for "${integration}" is authorized, but its grant${granted && granted.length > 0 ? ` (${granted.join(" ")})` : ""} does not cover the scope this operation requires${required.length > 0 ? ` (${required})` : ""}. Re-authenticating with the same grant will return the same error; reconnect with broader access.`, owner: input.credential.owner, integration, connection: String(input.credential.connection), diff --git a/packages/plugins/openapi/src/sdk/extract.test.ts b/packages/plugins/openapi/src/sdk/extract.test.ts index a10cd83bc..189cbe001 100644 --- a/packages/plugins/openapi/src/sdk/extract.test.ts +++ b/packages/plugins/openapi/src/sdk/extract.test.ts @@ -88,3 +88,69 @@ describe("OpenAPI extract response bodies", () => { }), ); }); + +describe("OpenAPI extract required scopes", () => { + it.effect("preserves requirement alternatives and applies document-level inheritance", () => + Effect.gen(function* () { + const doc = yield* parse( + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Scoped", version: "1.0.0" }, + servers: [{ url: "https://api.example.com" }], + // Document default: everything needs base.read unless overridden. + security: [{ oauth: ["base.read"] }], + paths: { + "/files": { + get: { + operationId: "listFiles", + // Two ALTERNATIVE requirement objects (an OR): a caller needs + // files.read, OR files.admin — never both at once. Alternatives + // must survive extraction separately, not as a union. + security: [{ oauth: ["files.read"] }, { oauth: ["files.admin"] }], + responses: { "200": { description: "ok" } }, + }, + }, + "/mixed": { + get: { + operationId: "mixedSchemes", + // One requirement object spanning two schemes: an AND — its + // scopes union into a single alternative. + security: [{ oauth: ["a.read"], other: ["b.read"] }], + responses: { "200": { description: "ok" } }, + }, + }, + "/inherited": { + get: { + operationId: "inheritedOp", + // No security key: inherits the document default. + responses: { "200": { description: "ok" } }, + }, + }, + "/public": { + get: { + operationId: "publicPing", + // Explicit []: auth disabled — no scopes, despite the default. + security: [], + responses: { "200": { description: "ok" } }, + }, + }, + }, + }), + ); + + const result = yield* extract(doc); + const byId = (id: string) => result.operations.find((op) => op.operationId === id); + + expect(byId("listFiles")?.requiredScopeAlternatives).toEqual([ + ["files.read"], + ["files.admin"], + ]); + expect(byId("mixedSchemes")?.requiredScopeAlternatives).toEqual([["a.read", "b.read"]]); + expect(byId("inheritedOp")?.requiredScopeAlternatives).toEqual([["base.read"]]); + expect( + byId("publicPing")?.requiredScopeAlternatives, + "explicit security: [] disables auth, so no scopes", + ).toBeUndefined(); + }), + ); +}); diff --git a/packages/plugins/openapi/src/sdk/extract.ts b/packages/plugins/openapi/src/sdk/extract.ts index ea3533342..26ce5597a 100644 --- a/packages/plugins/openapi/src/sdk/extract.ts +++ b/packages/plugins/openapi/src/sdk/extract.ts @@ -477,6 +477,49 @@ const operationServers = ( return docServers; }; +/** OAuth scope requirements an operation declares via `security`, with the + * spec's semantics preserved (OpenAPI 3.x Security Requirement Objects): + * + * - Each requirement object is one acceptable ALTERNATIVE; the array is an + * OR. Alternatives stay separate — unioning them would tell a user to + * grant scopes from mutually alternative schemes at once. + * - Within one requirement object the schemes are ANDed, so their scopes + * union into that alternative's set (sorted, deduped). + * - An ABSENT operation `security` inherits the document-level default; + * an explicit `security: []` disables auth. Both yield `undefined` only + * when nothing (or nothing scoped) is genuinely declared. */ +const securityScopeAlternatives = ( + operation: OperationObject, + documentSecurity: unknown, +): readonly (readonly string[])[] | undefined => { + const security = operation.security !== undefined ? operation.security : documentSecurity; + if (!Array.isArray(security) || security.length === 0) return undefined; + const alternatives: (readonly string[])[] = []; + const seen = new Set(); + for (const requirement of security) { + if (requirement === null || typeof requirement !== "object") continue; + const scopes = new Set(); + for (const schemeScopes of Object.values(requirement)) { + if (!Array.isArray(schemeScopes)) continue; + for (const scope of schemeScopes) { + if (typeof scope === "string" && scope.trim().length > 0) scopes.add(scope); + } + } + if (scopes.size === 0) continue; + const alternative = [...scopes].sort(); + const key = alternative.join(" "); + if (seen.has(key)) continue; + seen.add(key); + alternatives.push(alternative); + } + return alternatives.length > 0 ? alternatives : undefined; +}; + +const documentSecurityOf = (doc: unknown): unknown => + doc !== null && typeof doc === "object" && !Array.isArray(doc) + ? (doc as Record).security + : undefined; + // --------------------------------------------------------------------------- // Main extraction // --------------------------------------------------------------------------- @@ -512,6 +555,10 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume const tags = (operation.tags ?? []).filter((t) => t.trim().length > 0); const operationPathTemplate = explicitPathTemplate(operation) ?? pathTemplate; + const requiredScopeAlternatives = securityScopeAlternatives( + operation, + documentSecurityOf(doc), + ); operations.push( ExtractedOperation.make({ operationId: OperationId.make(deriveOperationId(method, pathTemplate, operation)), @@ -528,6 +575,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume inputSchema: Option.fromNullishOr(inputSchema), outputSchema: Option.fromNullishOr(outputSchema), deprecated: operation.deprecated === true, + ...(requiredScopeAlternatives ? { requiredScopeAlternatives } : {}), }), ); } @@ -643,6 +691,10 @@ export const streamOperationBindings = ( const requestBody = extractRequestBody(ref.operation, r); const responseBody = extractResponseBody(ref.operation, r); const servers = operationServers(ref.pathItem, ref.operation, docServers); + const requiredScopeAlternatives = securityScopeAlternatives( + ref.operation, + documentSecurityOf(doc), + ); chunk.push({ toolName: plan.toolPath, description: @@ -656,6 +708,7 @@ export const streamOperationBindings = ( parameters, requestBody: Option.fromNullishOr(requestBody), responseBody: Option.fromNullishOr(responseBody), + ...(requiredScopeAlternatives ? { requiredScopeAlternatives } : {}), }), }); if (chunk.length >= chunkSize) { @@ -773,6 +826,10 @@ export const streamOperationBindingsFromStructure = ( const requestBody = extractRequestBody(operation, r); const responseBody = extractResponseBody(operation, r); const servers = operationServers(pathItem, operation, docServers); + const requiredScopeAlternatives = securityScopeAlternatives( + operation, + documentSecurityOf(resolverDoc), + ); chunk.push({ toolName: plan.toolPath, description: @@ -786,6 +843,7 @@ export const streamOperationBindingsFromStructure = ( parameters, requestBody: Option.fromNullishOr(requestBody), responseBody: Option.fromNullishOr(responseBody), + ...(requiredScopeAlternatives ? { requiredScopeAlternatives } : {}), }), }); if (chunk.length >= chunkSize) { diff --git a/packages/plugins/openapi/src/sdk/types.ts b/packages/plugins/openapi/src/sdk/types.ts index 61df6fc11..c8e8271ed 100644 --- a/packages/plugins/openapi/src/sdk/types.ts +++ b/packages/plugins/openapi/src/sdk/types.ts @@ -180,6 +180,13 @@ export const ExtractedOperation = Schema.Struct({ inputSchema: Schema.OptionFromOptional(Schema.Unknown), outputSchema: Schema.OptionFromOptional(Schema.Unknown), deprecated: Schema.Boolean, + /** OAuth scope requirements from `security`, alternatives preserved: each + * inner array is one acceptable Security Requirement Object's scope set + * (sorted, deduped); the outer array is an OR across alternatives. An + * absent operation `security` inherits the document default; an explicit + * `security: []` (auth disabled) and a scope-less declaration both omit + * the field. */ + requiredScopeAlternatives: Schema.optional(Schema.Array(Schema.Array(Schema.String))), }); export type ExtractedOperation = typeof ExtractedOperation.Type; @@ -204,6 +211,12 @@ export const OperationBinding = Schema.Struct({ parameters: Schema.Array(OperationParameter), requestBody: Schema.OptionFromOptional(OperationRequestBody), responseBody: Schema.OptionFromOptional(OperationResponseBody), + /** Declared OAuth scope alternatives (see + * ExtractedOperation.requiredScopeAlternatives), persisted with the + * binding so the invoke path can annotate a scope-insufficient rejection + * with exactly what the operation needs. Optional so bindings stored + * before this field existed keep decoding. */ + requiredScopeAlternatives: Schema.optional(Schema.Array(Schema.Array(Schema.String))), }); export type OperationBinding = typeof OperationBinding.Type; diff --git a/packages/plugins/openapi/src/sdk/upstream-failures.test.ts b/packages/plugins/openapi/src/sdk/upstream-failures.test.ts index 146876be4..2089e2fdf 100644 --- a/packages/plugins/openapi/src/sdk/upstream-failures.test.ts +++ b/packages/plugins/openapi/src/sdk/upstream-failures.test.ts @@ -304,6 +304,52 @@ describe("OpenAPI upstream failure modes", () => { }), ); + it.effect("scope-insufficient 403 names the operation's declared scopes from the binding", () => + Effect.gen(function* () { + // The upstream signals only the CLASS of failure (Google's ErrorInfo + // carries no scope name); the operation's own `security` declaration — + // extracted into the stored binding — fills in what the operation + // needs, so the agent can tell the user exactly what to grant. + const server = yield* startScriptedServer(() => ({ + status: 403, + headers: { "content-type": "application/json" }, + body: '{"error":{"status":"PERMISSION_DENIED","details":[{"reason":"ACCESS_TOKEN_SCOPE_INSUFFICIENT"}]}}', + })); + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + // Declare the operation's scope in the spec blob before registering it, + // so the extracted binding carries requiredScopes. + const SpecJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)); + const parsed = yield* Schema.decodeUnknownEffect(SpecJson)(server.specJson); + const paths = parsed.paths as Record>>; + paths["/things"]!.get!.security = [{ oauth: ["things.read"] }]; + yield* executor.openapi.addSpec({ + spec: { kind: "blob", value: yield* Schema.encodeEffect(SpecJson)(parsed) }, + slug: "f", + baseUrl: server.baseUrl, + }); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("f"), + template: AuthTemplateSlug.make("apiKey"), + value: "token", + }); + + const result = yield* executor.execute( + ToolAddress.make(`tools.f.org.main.${LIST_THINGS}`), + {}, + ); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "oauth_scope_insufficient", + message: expect.stringContaining("things.read"), + }, + }); + }), + ); + it.effect("ordinary 403 without a scope signal stays connection_rejected", () => Effect.gen(function* () { const server = yield* startScriptedServer(() => ({