Skip to content

Commit 0b0dab4

Browse files
authored
Carry OAuth scopes through extraction to the invocation credential (#1386)
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
1 parent eabb67a commit 0b0dab4

8 files changed

Lines changed: 234 additions & 2 deletions

File tree

e2e/scenarios/oauth-scope-insufficient.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,18 @@ scenario(
281281
scopeFailure.error?.message ?? "",
282282
"the message says re-authenticating will not help",
283283
).toContain("Re-authenticating with the same grant");
284+
// Google's 403 body names no scope; the operation's own declared
285+
// scope (carried through extraction into the stored binding) and
286+
// the connection's granted scope (from its oauth_scope) fill in
287+
// exactly what is missing versus what is held.
288+
expect(
289+
scopeFailure.error?.message ?? "",
290+
"the message names the scope the operation requires, from the binding",
291+
).toContain("files.read");
292+
expect(
293+
scopeFailure.error?.message ?? "",
294+
"the message names the scope the grant holds, from the connection",
295+
).toContain("mail.read");
284296
expect(
285297
scopeFailure.error?.details?.recovery?.startOAuthTool,
286298
"no oauth.start recovery hint",

packages/core/sdk/src/executor.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,17 @@ const rowToConnection = (row: ConnectionRow): Connection => {
581581
};
582582
};
583583

584+
/** Parse a connection row's `oauth_scope` (space-delimited, as echoed by the
585+
* token endpoint) into the credential's `grantedScopes`. Undefined when the
586+
* row carries none, so scope comparisons downstream fail open. */
587+
const grantedScopesFromRow = (row: {
588+
readonly oauth_scope?: unknown;
589+
}): readonly string[] | undefined => {
590+
if (row.oauth_scope == null) return undefined;
591+
const scopes = String(row.oauth_scope).split(/\s+/).filter(Boolean);
592+
return scopes.length > 0 ? scopes : undefined;
593+
};
594+
584595
/** The canonical credential variable for a single-secret connection. OAuth tokens
585596
* and the primary apiKey value resolve through it. */
586597
const PRIMARY_INPUT_VARIABLE = "token";
@@ -2832,6 +2843,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
28322843
integrationRow,
28332844
describeAuthMethodsForRow(integrationRow),
28342845
);
2846+
const grantedScopes = grantedScopesFromRow(connectionRow);
28352847
const credential: ToolInvocationCredential = {
28362848
owner: connectionRow.owner as Owner,
28372849
integration: ref.integration,
@@ -2840,6 +2852,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
28402852
value: values[PRIMARY_INPUT_VARIABLE] ?? null,
28412853
values,
28422854
config: record.config,
2855+
...(grantedScopes ? { grantedScopes } : {}),
28432856
};
28442857
// Core resolves the declared spec (its own column) and hands it to the
28452858
// plugin; plugins no longer read it out of their config.
@@ -3768,6 +3781,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
37683781
// the primary `token` for single-input + OAuth callers.
37693782
const values = yield* resolveConnectionValues(connectionRow);
37703783
const integrationRow = yield* findIntegrationRow(parsed.integration);
3784+
const grantedScopes = grantedScopesFromRow(connectionRow);
37713785
const credential: ToolInvocationCredential = {
37723786
owner: parsed.owner,
37733787
integration: parsed.integration,
@@ -3776,6 +3790,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
37763790
value: values[PRIMARY_INPUT_VARIABLE] ?? null,
37773791
values,
37783792
config: integrationRow ? decodeJsonColumn(integrationRow.config) : undefined,
3793+
...(grantedScopes ? { grantedScopes } : {}),
37793794
};
37803795

37813796
return yield* wrapInvocationError(

packages/core/sdk/src/plugin.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,12 @@ export interface ToolInvocationCredential {
369369
readonly values: Record<string, string | null>;
370370
/** The integration's stored config, for template rendering. */
371371
readonly config: IntegrationConfig;
372+
/** The OAuth scopes the connection's grant actually covers, from the
373+
* connection row's `oauth_scope` (space-delimited, as returned by the
374+
* token endpoint). Absent for non-OAuth connections and for OAuth
375+
* providers that never echo a scope; consumers comparing against an
376+
* operation's declared scopes must fail open when this is undefined. */
377+
readonly grantedScopes?: readonly string[];
372378
}
373379

374380
// ---------------------------------------------------------------------------

packages/plugins/openapi/src/sdk/backing.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ const toBinding = (def: ToolDefinition): OperationBinding =>
198198
parameters: [...def.operation.parameters],
199199
requestBody: def.operation.requestBody,
200200
responseBody: def.operation.responseBody,
201+
...(def.operation.requiredScopeAlternatives
202+
? { requiredScopeAlternatives: def.operation.requiredScopeAlternatives }
203+
: {}),
201204
});
202205

203206
const descriptionFor = (def: ToolDefinition): string => {
@@ -732,11 +735,24 @@ export const invokeOpenApiBackedTool = (input: {
732735
? detectInsufficientScope({ body: result.error, headers: result.headers })
733736
: null;
734737
if (insufficientScope) {
735-
const required = insufficientScope.requiredScopes;
738+
// Name the shortfall as precisely as the data allows: the scopes
739+
// the upstream challenge asked for, else the operation's declared
740+
// requirement (from the binding; alternatives joined with "or",
741+
// since each Security Requirement Object is one acceptable set),
742+
// plus what the connection's grant actually holds. Advisory only —
743+
// the upstream made the call; this annotation tells the agent/user
744+
// what to reconnect with.
745+
const required =
746+
insufficientScope.requiredScopes.length > 0
747+
? insufficientScope.requiredScopes.join(" ")
748+
: (binding.requiredScopeAlternatives ?? [])
749+
.map((alternative) => alternative.join(" "))
750+
.join(", or ");
751+
const granted = input.credential.grantedScopes;
736752
return openApiAuthToolFailure({
737753
code: "oauth_scope_insufficient",
738754
status: result.status,
739-
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.`,
755+
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.`,
740756
owner: input.credential.owner,
741757
integration,
742758
connection: String(input.credential.connection),

packages/plugins/openapi/src/sdk/extract.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,69 @@ describe("OpenAPI extract response bodies", () => {
177177
}),
178178
);
179179
});
180+
181+
describe("OpenAPI extract required scopes", () => {
182+
it.effect("preserves requirement alternatives and applies document-level inheritance", () =>
183+
Effect.gen(function* () {
184+
const doc = yield* parse(
185+
JSON.stringify({
186+
openapi: "3.0.3",
187+
info: { title: "Scoped", version: "1.0.0" },
188+
servers: [{ url: "https://api.example.com" }],
189+
// Document default: everything needs base.read unless overridden.
190+
security: [{ oauth: ["base.read"] }],
191+
paths: {
192+
"/files": {
193+
get: {
194+
operationId: "listFiles",
195+
// Two ALTERNATIVE requirement objects (an OR): a caller needs
196+
// files.read, OR files.admin — never both at once. Alternatives
197+
// must survive extraction separately, not as a union.
198+
security: [{ oauth: ["files.read"] }, { oauth: ["files.admin"] }],
199+
responses: { "200": { description: "ok" } },
200+
},
201+
},
202+
"/mixed": {
203+
get: {
204+
operationId: "mixedSchemes",
205+
// One requirement object spanning two schemes: an AND — its
206+
// scopes union into a single alternative.
207+
security: [{ oauth: ["a.read"], other: ["b.read"] }],
208+
responses: { "200": { description: "ok" } },
209+
},
210+
},
211+
"/inherited": {
212+
get: {
213+
operationId: "inheritedOp",
214+
// No security key: inherits the document default.
215+
responses: { "200": { description: "ok" } },
216+
},
217+
},
218+
"/public": {
219+
get: {
220+
operationId: "publicPing",
221+
// Explicit []: auth disabled — no scopes, despite the default.
222+
security: [],
223+
responses: { "200": { description: "ok" } },
224+
},
225+
},
226+
},
227+
}),
228+
);
229+
230+
const result = yield* extract(doc);
231+
const byId = (id: string) => result.operations.find((op) => op.operationId === id);
232+
233+
expect(byId("listFiles")?.requiredScopeAlternatives).toEqual([
234+
["files.read"],
235+
["files.admin"],
236+
]);
237+
expect(byId("mixedSchemes")?.requiredScopeAlternatives).toEqual([["a.read", "b.read"]]);
238+
expect(byId("inheritedOp")?.requiredScopeAlternatives).toEqual([["base.read"]]);
239+
expect(
240+
byId("publicPing")?.requiredScopeAlternatives,
241+
"explicit security: [] disables auth, so no scopes",
242+
).toBeUndefined();
243+
}),
244+
);
245+
});

packages/plugins/openapi/src/sdk/extract.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,49 @@ const operationServers = (
495495
return docServers;
496496
};
497497

498+
/** OAuth scope requirements an operation declares via `security`, with the
499+
* spec's semantics preserved (OpenAPI 3.x Security Requirement Objects):
500+
*
501+
* - Each requirement object is one acceptable ALTERNATIVE; the array is an
502+
* OR. Alternatives stay separate — unioning them would tell a user to
503+
* grant scopes from mutually alternative schemes at once.
504+
* - Within one requirement object the schemes are ANDed, so their scopes
505+
* union into that alternative's set (sorted, deduped).
506+
* - An ABSENT operation `security` inherits the document-level default;
507+
* an explicit `security: []` disables auth. Both yield `undefined` only
508+
* when nothing (or nothing scoped) is genuinely declared. */
509+
const securityScopeAlternatives = (
510+
operation: OperationObject,
511+
documentSecurity: unknown,
512+
): readonly (readonly string[])[] | undefined => {
513+
const security = operation.security !== undefined ? operation.security : documentSecurity;
514+
if (!Array.isArray(security) || security.length === 0) return undefined;
515+
const alternatives: (readonly string[])[] = [];
516+
const seen = new Set<string>();
517+
for (const requirement of security) {
518+
if (requirement === null || typeof requirement !== "object") continue;
519+
const scopes = new Set<string>();
520+
for (const schemeScopes of Object.values(requirement)) {
521+
if (!Array.isArray(schemeScopes)) continue;
522+
for (const scope of schemeScopes) {
523+
if (typeof scope === "string" && scope.trim().length > 0) scopes.add(scope);
524+
}
525+
}
526+
if (scopes.size === 0) continue;
527+
const alternative = [...scopes].sort();
528+
const key = alternative.join(" ");
529+
if (seen.has(key)) continue;
530+
seen.add(key);
531+
alternatives.push(alternative);
532+
}
533+
return alternatives.length > 0 ? alternatives : undefined;
534+
};
535+
536+
const documentSecurityOf = (doc: unknown): unknown =>
537+
doc !== null && typeof doc === "object" && !Array.isArray(doc)
538+
? (doc as Record<string, unknown>).security
539+
: undefined;
540+
498541
// ---------------------------------------------------------------------------
499542
// Main extraction
500543
// ---------------------------------------------------------------------------
@@ -530,6 +573,10 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume
530573
const tags = (operation.tags ?? []).filter((t) => t.trim().length > 0);
531574
const operationPathTemplate = explicitPathTemplate(operation) ?? pathTemplate;
532575

576+
const requiredScopeAlternatives = securityScopeAlternatives(
577+
operation,
578+
documentSecurityOf(doc),
579+
);
533580
operations.push(
534581
ExtractedOperation.make({
535582
operationId: OperationId.make(deriveOperationId(method, pathTemplate, operation)),
@@ -546,6 +593,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume
546593
inputSchema: Option.fromNullishOr(inputSchema),
547594
outputSchema: Option.fromNullishOr(outputSchema),
548595
deprecated: operation.deprecated === true,
596+
...(requiredScopeAlternatives ? { requiredScopeAlternatives } : {}),
549597
}),
550598
);
551599
}
@@ -661,6 +709,10 @@ export const streamOperationBindings = <E, R>(
661709
const requestBody = extractRequestBody(ref.operation, r);
662710
const responseBody = extractResponseBody(ref.operation, r);
663711
const servers = operationServers(ref.pathItem, ref.operation, docServers);
712+
const requiredScopeAlternatives = securityScopeAlternatives(
713+
ref.operation,
714+
documentSecurityOf(doc),
715+
);
664716
chunk.push({
665717
toolName: plan.toolPath,
666718
description:
@@ -674,6 +726,7 @@ export const streamOperationBindings = <E, R>(
674726
parameters,
675727
requestBody: Option.fromNullishOr(requestBody),
676728
responseBody: Option.fromNullishOr(responseBody),
729+
...(requiredScopeAlternatives ? { requiredScopeAlternatives } : {}),
677730
}),
678731
});
679732
if (chunk.length >= chunkSize) {
@@ -791,6 +844,10 @@ export const streamOperationBindingsFromStructure = <E, R>(
791844
const requestBody = extractRequestBody(operation, r);
792845
const responseBody = extractResponseBody(operation, r);
793846
const servers = operationServers(pathItem, operation, docServers);
847+
const requiredScopeAlternatives = securityScopeAlternatives(
848+
operation,
849+
documentSecurityOf(resolverDoc),
850+
);
794851
chunk.push({
795852
toolName: plan.toolPath,
796853
description:
@@ -804,6 +861,7 @@ export const streamOperationBindingsFromStructure = <E, R>(
804861
parameters,
805862
requestBody: Option.fromNullishOr(requestBody),
806863
responseBody: Option.fromNullishOr(responseBody),
864+
...(requiredScopeAlternatives ? { requiredScopeAlternatives } : {}),
807865
}),
808866
});
809867
if (chunk.length >= chunkSize) {

packages/plugins/openapi/src/sdk/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@ export const ExtractedOperation = Schema.Struct({
180180
inputSchema: Schema.OptionFromOptional(Schema.Unknown),
181181
outputSchema: Schema.OptionFromOptional(Schema.Unknown),
182182
deprecated: Schema.Boolean,
183+
/** OAuth scope requirements from `security`, alternatives preserved: each
184+
* inner array is one acceptable Security Requirement Object's scope set
185+
* (sorted, deduped); the outer array is an OR across alternatives. An
186+
* absent operation `security` inherits the document default; an explicit
187+
* `security: []` (auth disabled) and a scope-less declaration both omit
188+
* the field. */
189+
requiredScopeAlternatives: Schema.optional(Schema.Array(Schema.Array(Schema.String))),
183190
});
184191
export type ExtractedOperation = typeof ExtractedOperation.Type;
185192

@@ -204,6 +211,12 @@ export const OperationBinding = Schema.Struct({
204211
parameters: Schema.Array(OperationParameter),
205212
requestBody: Schema.OptionFromOptional(OperationRequestBody),
206213
responseBody: Schema.OptionFromOptional(OperationResponseBody),
214+
/** Declared OAuth scope alternatives (see
215+
* ExtractedOperation.requiredScopeAlternatives), persisted with the
216+
* binding so the invoke path can annotate a scope-insufficient rejection
217+
* with exactly what the operation needs. Optional so bindings stored
218+
* before this field existed keep decoding. */
219+
requiredScopeAlternatives: Schema.optional(Schema.Array(Schema.Array(Schema.String))),
207220
});
208221
export type OperationBinding = typeof OperationBinding.Type;
209222

packages/plugins/openapi/src/sdk/upstream-failures.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,52 @@ describe("OpenAPI upstream failure modes", () => {
304304
}),
305305
);
306306

307+
it.effect("scope-insufficient 403 names the operation's declared scopes from the binding", () =>
308+
Effect.gen(function* () {
309+
// The upstream signals only the CLASS of failure (Google's ErrorInfo
310+
// carries no scope name); the operation's own `security` declaration —
311+
// extracted into the stored binding — fills in what the operation
312+
// needs, so the agent can tell the user exactly what to grant.
313+
const server = yield* startScriptedServer(() => ({
314+
status: 403,
315+
headers: { "content-type": "application/json" },
316+
body: '{"error":{"status":"PERMISSION_DENIED","details":[{"reason":"ACCESS_TOKEN_SCOPE_INSUFFICIENT"}]}}',
317+
}));
318+
const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() }));
319+
// Declare the operation's scope in the spec blob before registering it,
320+
// so the extracted binding carries requiredScopes.
321+
const SpecJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown));
322+
const parsed = yield* Schema.decodeUnknownEffect(SpecJson)(server.specJson);
323+
const paths = parsed.paths as Record<string, Record<string, Record<string, unknown>>>;
324+
paths["/things"]!.get!.security = [{ oauth: ["things.read"] }];
325+
yield* executor.openapi.addSpec({
326+
spec: { kind: "blob", value: yield* Schema.encodeEffect(SpecJson)(parsed) },
327+
slug: "f",
328+
baseUrl: server.baseUrl,
329+
});
330+
yield* executor.connections.create({
331+
owner: "org",
332+
name: ConnectionName.make("main"),
333+
integration: IntegrationSlug.make("f"),
334+
template: AuthTemplateSlug.make("apiKey"),
335+
value: "token",
336+
});
337+
338+
const result = yield* executor.execute(
339+
ToolAddress.make(`tools.f.org.main.${LIST_THINGS}`),
340+
{},
341+
);
342+
343+
expect(result).toMatchObject({
344+
ok: false,
345+
error: {
346+
code: "oauth_scope_insufficient",
347+
message: expect.stringContaining("things.read"),
348+
},
349+
});
350+
}),
351+
);
352+
307353
it.effect("ordinary 403 without a scope signal stays connection_rejected", () =>
308354
Effect.gen(function* () {
309355
const server = yield* startScriptedServer(() => ({

0 commit comments

Comments
 (0)