Skip to content
Merged
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 @@ -581,6 +581,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 @@ -2832,6 +2843,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 @@ -2840,6 +2852,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 @@ -3768,6 +3781,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 @@ -3776,6 +3790,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
20 changes: 18 additions & 2 deletions packages/plugins/openapi/src/sdk/backing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,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 => {
Expand Down Expand Up @@ -732,11 +735,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),
Expand Down
66 changes: 66 additions & 0 deletions packages/plugins/openapi/src/sdk/extract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,3 +177,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();
}),
);
});
58 changes: 58 additions & 0 deletions packages/plugins/openapi/src/sdk/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,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<string>();
for (const requirement of security) {
if (requirement === null || typeof requirement !== "object") continue;
const scopes = new Set<string>();
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<string, unknown>).security
: undefined;

// ---------------------------------------------------------------------------
// Main extraction
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -530,6 +573,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)),
Expand All @@ -546,6 +593,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 } : {}),
}),
);
}
Expand Down Expand Up @@ -661,6 +709,10 @@ 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 requiredScopeAlternatives = securityScopeAlternatives(
ref.operation,
documentSecurityOf(doc),
);
chunk.push({
toolName: plan.toolPath,
description:
Expand All @@ -674,6 +726,7 @@ export const streamOperationBindings = <E, R>(
parameters,
requestBody: Option.fromNullishOr(requestBody),
responseBody: Option.fromNullishOr(responseBody),
...(requiredScopeAlternatives ? { requiredScopeAlternatives } : {}),
}),
});
if (chunk.length >= chunkSize) {
Expand Down Expand Up @@ -791,6 +844,10 @@ export const streamOperationBindingsFromStructure = <E, R>(
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:
Expand All @@ -804,6 +861,7 @@ export const streamOperationBindingsFromStructure = <E, R>(
parameters,
requestBody: Option.fromNullishOr(requestBody),
responseBody: Option.fromNullishOr(responseBody),
...(requiredScopeAlternatives ? { requiredScopeAlternatives } : {}),
}),
});
if (chunk.length >= chunkSize) {
Expand Down
13 changes: 13 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,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;

Expand All @@ -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;

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