Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions e2e/scenarios/oauth-scope-insufficient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -2833,6 +2844,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
integrationRow,
describeAuthMethodsForRow(integrationRow),
);
const grantedScopes = grantedScopesFromRow(connectionRow);
const credential: ToolInvocationCredential = {
owner: connectionRow.owner as Owner,
integration: ref.integration,
Expand All @@ -2841,6 +2853,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
value: values[PRIMARY_INPUT_VARIABLE] ?? null,
values,
config: record.config,
...(grantedScopes ? { grantedScopes } : {}),
};
// Core resolves the declared spec (its own column) and hands it to the
// plugin; plugins no longer read it out of their config.
Expand Down Expand Up @@ -3769,6 +3782,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
// the primary `token` for single-input + OAuth callers.
const values = yield* resolveConnectionValues(connectionRow);
const integrationRow = yield* findIntegrationRow(parsed.integration);
const grantedScopes = grantedScopesFromRow(connectionRow);
const credential: ToolInvocationCredential = {
owner: parsed.owner,
integration: parsed.integration,
Expand All @@ -3777,6 +3791,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
value: values[PRIMARY_INPUT_VARIABLE] ?? null,
values,
config: integrationRow ? decodeJsonColumn(integrationRow.config) : undefined,
...(grantedScopes ? { grantedScopes } : {}),
};

return yield* wrapInvocationError(
Expand Down
6 changes: 6 additions & 0 deletions packages/core/sdk/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,12 @@ export interface ToolInvocationCredential {
readonly values: Record<string, string | null>;
/** 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[];
}

// ---------------------------------------------------------------------------
Expand Down
15 changes: 13 additions & 2 deletions packages/plugins/openapi/src/sdk/backing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ const toBinding = (def: ToolDefinition): OperationBinding =>
parameters: [...def.operation.parameters],
requestBody: def.operation.requestBody,
responseBody: def.operation.responseBody,
...(def.operation.requiredScopes ? { requiredScopes: def.operation.requiredScopes } : {}),
});

const descriptionFor = (def: ToolDefinition): string => {
Expand Down Expand Up @@ -731,11 +732,21 @@ 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 scopes the operation
// declared in its spec (carried on the binding), 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
: (binding.requiredScopes ?? []);
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.join(" ")})` : ""}. 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),
Expand Down
38 changes: 38 additions & 0 deletions packages/plugins/openapi/src/sdk/extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,41 @@ describe("OpenAPI extract response bodies", () => {
}),
);
});

describe("OpenAPI extract required scopes", () => {
it.effect("unions per-operation security scopes and omits the field when none are declared", () =>
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" }],
paths: {
"/files": {
get: {
operationId: "listFiles",
// Two requirement objects (alternative scheme combinations);
// the extracted field is their union.
security: [{ oauth: ["files.read"] }, { oauth: ["files.admin", "files.read"] }],
responses: { "200": { description: "ok" } },
},
},
"/public": {
get: {
operationId: "publicPing",
responses: { "200": { description: "ok" } },
},
},
},
}),
);

const result = yield* extract(doc);
const scoped = result.operations.find((op) => op.operationId === "listFiles");
expect(scoped?.requiredScopes).toEqual(["files.admin", "files.read"]);

const unscoped = result.operations.find((op) => op.operationId === "publicPing");
expect(unscoped?.requiredScopes, "no security declared, no field").toBeUndefined();
}),
);
});
29 changes: 29 additions & 0 deletions packages/plugins/openapi/src/sdk/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,29 @@ const operationServers = (
return docServers;
};

/** OAuth scopes an operation declares via `security`, unioned across its
* requirement objects (each object is one acceptable scheme combination; the
* union is what "some grant satisfies this operation" can be checked
* against). Operation-level `security` overrides the document default per
* the spec, so no doc-level fallback is read here — the callers that need it
* pass the operation as-is. Returns `undefined` when nothing is declared,
* keeping the field absent for unauthenticated or scope-less operations. */
const operationRequiredScopes = (operation: OperationObject): readonly string[] | undefined => {
const security = operation.security;
if (!Array.isArray(security) || security.length === 0) return undefined;
const scopes = new Set<string>();
for (const requirement of security) {
if (requirement === null || typeof requirement !== "object") continue;
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);
}
}
}
return scopes.size > 0 ? [...scopes].sort() : undefined;
};

// ---------------------------------------------------------------------------
// Main extraction
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -512,6 +535,7 @@ 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 requiredScopes = operationRequiredScopes(operation);
operations.push(
ExtractedOperation.make({
operationId: OperationId.make(deriveOperationId(method, pathTemplate, operation)),
Expand All @@ -528,6 +552,7 @@ export const extract = Effect.fn("OpenApi.extract")(function* (doc: ParsedDocume
inputSchema: Option.fromNullishOr(inputSchema),
outputSchema: Option.fromNullishOr(outputSchema),
deprecated: operation.deprecated === true,
...(requiredScopes ? { requiredScopes } : {}),
}),
);
}
Expand Down Expand Up @@ -643,6 +668,7 @@ export const streamOperationBindings = <E, R>(
const requestBody = extractRequestBody(ref.operation, r);
const responseBody = extractResponseBody(ref.operation, r);
const servers = operationServers(ref.pathItem, ref.operation, docServers);
const requiredScopes = operationRequiredScopes(ref.operation);
chunk.push({
toolName: plan.toolPath,
description:
Expand All @@ -656,6 +682,7 @@ export const streamOperationBindings = <E, R>(
parameters,
requestBody: Option.fromNullishOr(requestBody),
responseBody: Option.fromNullishOr(responseBody),
...(requiredScopes ? { requiredScopes } : {}),
}),
});
if (chunk.length >= chunkSize) {
Expand Down Expand Up @@ -773,6 +800,7 @@ export const streamOperationBindingsFromStructure = <E, R>(
const requestBody = extractRequestBody(operation, r);
const responseBody = extractResponseBody(operation, r);
const servers = operationServers(pathItem, operation, docServers);
const requiredScopes = operationRequiredScopes(operation);
chunk.push({
toolName: plan.toolPath,
description:
Expand All @@ -786,6 +814,7 @@ export const streamOperationBindingsFromStructure = <E, R>(
parameters,
requestBody: Option.fromNullishOr(requestBody),
responseBody: Option.fromNullishOr(responseBody),
...(requiredScopes ? { requiredScopes } : {}),
}),
});
if (chunk.length >= chunkSize) {
Expand Down
9 changes: 9 additions & 0 deletions packages/plugins/openapi/src/sdk/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ export const ExtractedOperation = Schema.Struct({
inputSchema: Schema.OptionFromOptional(Schema.Unknown),
outputSchema: Schema.OptionFromOptional(Schema.Unknown),
deprecated: Schema.Boolean,
/** OAuth scopes the operation declares via `security` (union across
* requirement objects), so a connection's granted scope can be compared to
* what the operation needs. Omitted when the spec declares none. */
requiredScopes: Schema.optional(Schema.Array(Schema.String)),
});
export type ExtractedOperation = typeof ExtractedOperation.Type;

Expand All @@ -204,6 +208,11 @@ export const OperationBinding = Schema.Struct({
parameters: Schema.Array(OperationParameter),
requestBody: Schema.OptionFromOptional(OperationRequestBody),
responseBody: Schema.OptionFromOptional(OperationResponseBody),
/** Declared OAuth scopes (see ExtractedOperation.requiredScopes), 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. */
requiredScopes: Schema.optional(Schema.Array(Schema.String)),
});
export type OperationBinding = typeof OperationBinding.Type;

Expand Down
46 changes: 46 additions & 0 deletions packages/plugins/openapi/src/sdk/upstream-failures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Record<string, Record<string, unknown>>>;
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(() => ({
Expand Down
Loading