Skip to content

Commit 1308617

Browse files
committed
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
1 parent 735d363 commit 1308617

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
@@ -582,6 +582,17 @@ const rowToConnection = (row: ConnectionRow): Connection => {
582582
};
583583
};
584584

585+
/** Parse a connection row's `oauth_scope` (space-delimited, as echoed by the
586+
* token endpoint) into the credential's `grantedScopes`. Undefined when the
587+
* row carries none, so scope comparisons downstream fail open. */
588+
const grantedScopesFromRow = (row: {
589+
readonly oauth_scope?: unknown;
590+
}): readonly string[] | undefined => {
591+
if (row.oauth_scope == null) return undefined;
592+
const scopes = String(row.oauth_scope).split(/\s+/).filter(Boolean);
593+
return scopes.length > 0 ? scopes : undefined;
594+
};
595+
585596
/** The canonical credential variable for a single-secret connection. OAuth tokens
586597
* and the primary apiKey value resolve through it. */
587598
const PRIMARY_INPUT_VARIABLE = "token";
@@ -2833,6 +2844,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
28332844
integrationRow,
28342845
describeAuthMethodsForRow(integrationRow),
28352846
);
2847+
const grantedScopes = grantedScopesFromRow(connectionRow);
28362848
const credential: ToolInvocationCredential = {
28372849
owner: connectionRow.owner as Owner,
28382850
integration: ref.integration,
@@ -2841,6 +2853,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
28412853
value: values[PRIMARY_INPUT_VARIABLE] ?? null,
28422854
values,
28432855
config: record.config,
2856+
...(grantedScopes ? { grantedScopes } : {}),
28442857
};
28452858
// Core resolves the declared spec (its own column) and hands it to the
28462859
// plugin; plugins no longer read it out of their config.
@@ -3769,6 +3782,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
37693782
// the primary `token` for single-input + OAuth callers.
37703783
const values = yield* resolveConnectionValues(connectionRow);
37713784
const integrationRow = yield* findIntegrationRow(parsed.integration);
3785+
const grantedScopes = grantedScopesFromRow(connectionRow);
37723786
const credential: ToolInvocationCredential = {
37733787
owner: parsed.owner,
37743788
integration: parsed.integration,
@@ -3777,6 +3791,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
37773791
value: values[PRIMARY_INPUT_VARIABLE] ?? null,
37783792
values,
37793793
config: integrationRow ? decodeJsonColumn(integrationRow.config) : undefined,
3794+
...(grantedScopes ? { grantedScopes } : {}),
37803795
};
37813796

37823797
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
@@ -197,6 +197,9 @@ const toBinding = (def: ToolDefinition): OperationBinding =>
197197
parameters: [...def.operation.parameters],
198198
requestBody: def.operation.requestBody,
199199
responseBody: def.operation.responseBody,
200+
...(def.operation.requiredScopeAlternatives
201+
? { requiredScopeAlternatives: def.operation.requiredScopeAlternatives }
202+
: {}),
200203
});
201204

202205
const descriptionFor = (def: ToolDefinition): string => {
@@ -731,11 +734,24 @@ export const invokeOpenApiBackedTool = (input: {
731734
? detectInsufficientScope({ body: result.error, headers: result.headers })
732735
: null;
733736
if (insufficientScope) {
734-
const required = insufficientScope.requiredScopes;
737+
// Name the shortfall as precisely as the data allows: the scopes
738+
// the upstream challenge asked for, else the operation's declared
739+
// requirement (from the binding; alternatives joined with "or",
740+
// since each Security Requirement Object is one acceptable set),
741+
// plus what the connection's grant actually holds. Advisory only —
742+
// the upstream made the call; this annotation tells the agent/user
743+
// what to reconnect with.
744+
const required =
745+
insufficientScope.requiredScopes.length > 0
746+
? insufficientScope.requiredScopes.join(" ")
747+
: (binding.requiredScopeAlternatives ?? [])
748+
.map((alternative) => alternative.join(" "))
749+
.join(", or ");
750+
const granted = input.credential.grantedScopes;
735751
return openApiAuthToolFailure({
736752
code: "oauth_scope_insufficient",
737753
status: result.status,
738-
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.`,
754+
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.`,
739755
owner: input.credential.owner,
740756
integration,
741757
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
@@ -88,3 +88,69 @@ describe("OpenAPI extract response bodies", () => {
8888
}),
8989
);
9090
});
91+
92+
describe("OpenAPI extract required scopes", () => {
93+
it.effect("preserves requirement alternatives and applies document-level inheritance", () =>
94+
Effect.gen(function* () {
95+
const doc = yield* parse(
96+
JSON.stringify({
97+
openapi: "3.0.3",
98+
info: { title: "Scoped", version: "1.0.0" },
99+
servers: [{ url: "https://api.example.com" }],
100+
// Document default: everything needs base.read unless overridden.
101+
security: [{ oauth: ["base.read"] }],
102+
paths: {
103+
"/files": {
104+
get: {
105+
operationId: "listFiles",
106+
// Two ALTERNATIVE requirement objects (an OR): a caller needs
107+
// files.read, OR files.admin — never both at once. Alternatives
108+
// must survive extraction separately, not as a union.
109+
security: [{ oauth: ["files.read"] }, { oauth: ["files.admin"] }],
110+
responses: { "200": { description: "ok" } },
111+
},
112+
},
113+
"/mixed": {
114+
get: {
115+
operationId: "mixedSchemes",
116+
// One requirement object spanning two schemes: an AND — its
117+
// scopes union into a single alternative.
118+
security: [{ oauth: ["a.read"], other: ["b.read"] }],
119+
responses: { "200": { description: "ok" } },
120+
},
121+
},
122+
"/inherited": {
123+
get: {
124+
operationId: "inheritedOp",
125+
// No security key: inherits the document default.
126+
responses: { "200": { description: "ok" } },
127+
},
128+
},
129+
"/public": {
130+
get: {
131+
operationId: "publicPing",
132+
// Explicit []: auth disabled — no scopes, despite the default.
133+
security: [],
134+
responses: { "200": { description: "ok" } },
135+
},
136+
},
137+
},
138+
}),
139+
);
140+
141+
const result = yield* extract(doc);
142+
const byId = (id: string) => result.operations.find((op) => op.operationId === id);
143+
144+
expect(byId("listFiles")?.requiredScopeAlternatives).toEqual([
145+
["files.read"],
146+
["files.admin"],
147+
]);
148+
expect(byId("mixedSchemes")?.requiredScopeAlternatives).toEqual([["a.read", "b.read"]]);
149+
expect(byId("inheritedOp")?.requiredScopeAlternatives).toEqual([["base.read"]]);
150+
expect(
151+
byId("publicPing")?.requiredScopeAlternatives,
152+
"explicit security: [] disables auth, so no scopes",
153+
).toBeUndefined();
154+
}),
155+
);
156+
});

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,49 @@ const operationServers = (
477477
return docServers;
478478
};
479479

480+
/** OAuth scope requirements an operation declares via `security`, with the
481+
* spec's semantics preserved (OpenAPI 3.x Security Requirement Objects):
482+
*
483+
* - Each requirement object is one acceptable ALTERNATIVE; the array is an
484+
* OR. Alternatives stay separate — unioning them would tell a user to
485+
* grant scopes from mutually alternative schemes at once.
486+
* - Within one requirement object the schemes are ANDed, so their scopes
487+
* union into that alternative's set (sorted, deduped).
488+
* - An ABSENT operation `security` inherits the document-level default;
489+
* an explicit `security: []` disables auth. Both yield `undefined` only
490+
* when nothing (or nothing scoped) is genuinely declared. */
491+
const securityScopeAlternatives = (
492+
operation: OperationObject,
493+
documentSecurity: unknown,
494+
): readonly (readonly string[])[] | undefined => {
495+
const security = operation.security !== undefined ? operation.security : documentSecurity;
496+
if (!Array.isArray(security) || security.length === 0) return undefined;
497+
const alternatives: (readonly string[])[] = [];
498+
const seen = new Set<string>();
499+
for (const requirement of security) {
500+
if (requirement === null || typeof requirement !== "object") continue;
501+
const scopes = new Set<string>();
502+
for (const schemeScopes of Object.values(requirement)) {
503+
if (!Array.isArray(schemeScopes)) continue;
504+
for (const scope of schemeScopes) {
505+
if (typeof scope === "string" && scope.trim().length > 0) scopes.add(scope);
506+
}
507+
}
508+
if (scopes.size === 0) continue;
509+
const alternative = [...scopes].sort();
510+
const key = alternative.join(" ");
511+
if (seen.has(key)) continue;
512+
seen.add(key);
513+
alternatives.push(alternative);
514+
}
515+
return alternatives.length > 0 ? alternatives : undefined;
516+
};
517+
518+
const documentSecurityOf = (doc: unknown): unknown =>
519+
doc !== null && typeof doc === "object" && !Array.isArray(doc)
520+
? (doc as Record<string, unknown>).security
521+
: undefined;
522+
480523
// ---------------------------------------------------------------------------
481524
// Main extraction
482525
// ---------------------------------------------------------------------------
@@ -512,6 +555,10 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume
512555
const tags = (operation.tags ?? []).filter((t) => t.trim().length > 0);
513556
const operationPathTemplate = explicitPathTemplate(operation) ?? pathTemplate;
514557

558+
const requiredScopeAlternatives = securityScopeAlternatives(
559+
operation,
560+
documentSecurityOf(doc),
561+
);
515562
operations.push(
516563
ExtractedOperation.make({
517564
operationId: OperationId.make(deriveOperationId(method, pathTemplate, operation)),
@@ -528,6 +575,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume
528575
inputSchema: Option.fromNullishOr(inputSchema),
529576
outputSchema: Option.fromNullishOr(outputSchema),
530577
deprecated: operation.deprecated === true,
578+
...(requiredScopeAlternatives ? { requiredScopeAlternatives } : {}),
531579
}),
532580
);
533581
}
@@ -643,6 +691,10 @@ export const streamOperationBindings = <E, R>(
643691
const requestBody = extractRequestBody(ref.operation, r);
644692
const responseBody = extractResponseBody(ref.operation, r);
645693
const servers = operationServers(ref.pathItem, ref.operation, docServers);
694+
const requiredScopeAlternatives = securityScopeAlternatives(
695+
ref.operation,
696+
documentSecurityOf(doc),
697+
);
646698
chunk.push({
647699
toolName: plan.toolPath,
648700
description:
@@ -656,6 +708,7 @@ export const streamOperationBindings = <E, R>(
656708
parameters,
657709
requestBody: Option.fromNullishOr(requestBody),
658710
responseBody: Option.fromNullishOr(responseBody),
711+
...(requiredScopeAlternatives ? { requiredScopeAlternatives } : {}),
659712
}),
660713
});
661714
if (chunk.length >= chunkSize) {
@@ -773,6 +826,10 @@ export const streamOperationBindingsFromStructure = <E, R>(
773826
const requestBody = extractRequestBody(operation, r);
774827
const responseBody = extractResponseBody(operation, r);
775828
const servers = operationServers(pathItem, operation, docServers);
829+
const requiredScopeAlternatives = securityScopeAlternatives(
830+
operation,
831+
documentSecurityOf(resolverDoc),
832+
);
776833
chunk.push({
777834
toolName: plan.toolPath,
778835
description:
@@ -786,6 +843,7 @@ export const streamOperationBindingsFromStructure = <E, R>(
786843
parameters,
787844
requestBody: Option.fromNullishOr(requestBody),
788845
responseBody: Option.fromNullishOr(responseBody),
846+
...(requiredScopeAlternatives ? { requiredScopeAlternatives } : {}),
789847
}),
790848
});
791849
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)