Skip to content

Commit eabb67a

Browse files
authored
Classify scope-insufficient 403s as oauth_scope_insufficient (#1385)
A 403 whose body or WWW-Authenticate challenge names a scope shortfall (RFC 6750 insufficient_scope, Google ACCESS_TOKEN_SCOPE_INSUFFICIENT) cannot be fixed by re-running the same OAuth grant, but all three protocol plugins collapsed it into connection_rejected, whose recovery block tells the agent to call oauth.start and retry: an infinite loop through identical consent screens. Add an oauth_scope_insufficient failure code with its own recovery guidance (reconnect with broader access; deliberately no startOAuthTool hint), a shared detector in the core SDK, and detection at the three collapse sites: openapi (body + headers), graphql (body), mcp (the transport error message, the only place the 403 body survives). A 403 without a recognised scope signal keeps the existing classification. Fixes #1381
1 parent d90d8be commit eabb67a

18 files changed

Lines changed: 1594 additions & 38 deletions
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
// Cross-target: a scope-insufficient upstream 403 is a distinct, actionable
2+
// failure — not a re-authenticate loop. When a connection's OAuth grant does
3+
// not cover the scope an operation requires, the upstream rejects the call
4+
// with a scope signal (Google's ACCESS_TOKEN_SCOPE_INSUFFICIENT, RFC 6750's
5+
// insufficient_scope). Re-running the same grant returns the identical 403,
6+
// so the tool failure must say so: code `oauth_scope_insufficient`, guidance
7+
// to reconnect with broader access, and NO `oauth.start` recovery hint (an
8+
// agent following one would loop through identical consent forever).
9+
//
10+
// The journey: an OpenAPI integration with two operations under one OAuth
11+
// method; the connection completes a real authorization-code flow granting
12+
// only `mail.read`. Calling the `files.read` operation dispatches upstream
13+
// (catalog projection by scope is #1384, tracked separately), which answers
14+
// with a Google-shaped scope-insufficient 403 — and the failure the sandbox
15+
// sees carries the new code, while the ordinary revoked-token 403 stays
16+
// `connection_rejected`.
17+
import { randomBytes } from "node:crypto";
18+
import { createServer } from "node:http";
19+
20+
import { expect } from "@effect/vitest";
21+
import { Effect } from "effect";
22+
import { composePluginApi } from "@executor-js/api/server";
23+
import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
24+
import {
25+
AuthTemplateSlug,
26+
ConnectionName,
27+
IntegrationSlug,
28+
OAuthClientSlug,
29+
} from "@executor-js/sdk/shared";
30+
import { serveOAuthTestServer } from "@executor-js/sdk/testing";
31+
32+
import { scenario } from "../src/scenario";
33+
import { Api, Target } from "../src/services";
34+
35+
const api = composePluginApi([openApiHttpPlugin()] as const);
36+
37+
const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
38+
39+
/** Upstream on 127.0.0.1: `GET /inbox` is 200 for any bearer; `GET /files`
40+
* answers a Google-shaped scope-insufficient 403; `GET /admin` answers an
41+
* ordinary 403 with no scope signal. */
42+
const serveScopedUpstream = () =>
43+
Effect.acquireRelease(
44+
Effect.callback<{ readonly url: string; readonly close: () => void }>((resume) => {
45+
const server = createServer((request, response) => {
46+
const path = request.url ?? "";
47+
if (request.method === "GET" && path.startsWith("/inbox")) {
48+
response.writeHead(200, { "content-type": "application/json" });
49+
response.end(JSON.stringify({ messages: [] }));
50+
return;
51+
}
52+
if (request.method === "GET" && path.startsWith("/files")) {
53+
response.writeHead(403, { "content-type": "application/json" });
54+
response.end(
55+
JSON.stringify({
56+
error: {
57+
code: 403,
58+
message: "Request had insufficient authentication scopes.",
59+
status: "PERMISSION_DENIED",
60+
details: [
61+
{
62+
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
63+
reason: "ACCESS_TOKEN_SCOPE_INSUFFICIENT",
64+
domain: "googleapis.com",
65+
metadata: { service: "drive.googleapis.com" },
66+
},
67+
],
68+
},
69+
}),
70+
);
71+
return;
72+
}
73+
if (request.method === "GET" && path.startsWith("/admin")) {
74+
response.writeHead(403, { "content-type": "application/json" });
75+
response.end(
76+
JSON.stringify({ error: { status: "PERMISSION_DENIED", message: "not allowed" } }),
77+
);
78+
return;
79+
}
80+
response.writeHead(404, { "content-type": "application/json" });
81+
response.end(JSON.stringify({ error: "not_found" }));
82+
});
83+
server.listen(0, "127.0.0.1", () => {
84+
const address = server.address();
85+
const port = typeof address === "object" && address ? address.port : 0;
86+
resume(
87+
Effect.succeed({
88+
url: `http://127.0.0.1:${port}`,
89+
close: () => {
90+
server.close();
91+
server.closeAllConnections();
92+
},
93+
}),
94+
);
95+
});
96+
}),
97+
(server) => Effect.sync(server.close),
98+
);
99+
100+
const spec = (
101+
baseUrl: string,
102+
oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string },
103+
): string =>
104+
JSON.stringify({
105+
openapi: "3.0.3",
106+
info: { title: "Scoped API", version: "1.0.0" },
107+
servers: [{ url: baseUrl }],
108+
paths: {
109+
"/inbox": {
110+
get: {
111+
operationId: "readInbox",
112+
summary: "List inbox messages",
113+
security: [{ oauth: ["mail.read"] }],
114+
responses: { "200": { description: "messages" } },
115+
},
116+
},
117+
"/files": {
118+
get: {
119+
operationId: "listFiles",
120+
summary: "List drive files",
121+
security: [{ oauth: ["files.read"] }],
122+
responses: { "200": { description: "files" } },
123+
},
124+
},
125+
"/admin": {
126+
get: {
127+
operationId: "adminAction",
128+
summary: "An admin-only action",
129+
security: [{ oauth: ["mail.read"] }],
130+
responses: { "200": { description: "ok" } },
131+
},
132+
},
133+
},
134+
components: {
135+
securitySchemes: {
136+
oauth: {
137+
type: "oauth2",
138+
flows: {
139+
authorizationCode: {
140+
authorizationUrl: oauth.authorizationEndpoint,
141+
tokenUrl: oauth.tokenEndpoint,
142+
scopes: {
143+
"mail.read": "Read mail",
144+
"files.read": "Read files",
145+
},
146+
},
147+
},
148+
},
149+
},
150+
},
151+
});
152+
153+
const invokeByAddressCode = (address: string, args: unknown) => `
154+
const segments = ${JSON.stringify(address)}.split(".").slice(1);
155+
let node = tools;
156+
for (const segment of segments) node = node[segment];
157+
const result = await node(${JSON.stringify(args)});
158+
return JSON.stringify(result);
159+
`;
160+
161+
type ToolEnvelope = {
162+
readonly ok: boolean;
163+
readonly error?: {
164+
readonly code?: string;
165+
readonly message?: string;
166+
readonly details?: { readonly recovery?: Record<string, string> };
167+
};
168+
};
169+
170+
scenario(
171+
"Auth failures · a scope-insufficient 403 tells the agent to reconnect with broader access, not to re-authenticate",
172+
{},
173+
Effect.scoped(
174+
Effect.gen(function* () {
175+
const target = yield* Target;
176+
const { client: makeClient } = yield* Api;
177+
const identity = yield* target.newIdentity();
178+
const client = yield* makeClient(api, identity);
179+
const upstream = yield* serveScopedUpstream();
180+
const oauth = yield* serveOAuthTestServer({ scopes: ["mail.read", "files.read"] });
181+
const slug = unique("scopeins");
182+
const clientSlug = OAuthClientSlug.make(unique("scopeinsc"));
183+
184+
yield* Effect.ensuring(
185+
Effect.gen(function* () {
186+
// The integration's OAuth method requests only mail.read — the
187+
// whole grant the connection will hold.
188+
yield* client.openapi.addSpec({
189+
payload: {
190+
spec: { kind: "blob", value: spec(upstream.url, oauth) },
191+
slug,
192+
baseUrl: upstream.url,
193+
authenticationTemplate: [
194+
{
195+
slug: "oauth",
196+
kind: "oauth2",
197+
authorizationUrl: oauth.authorizationEndpoint,
198+
tokenUrl: oauth.tokenEndpoint,
199+
scopes: ["mail.read"],
200+
},
201+
],
202+
},
203+
});
204+
yield* client.oauth.createClient({
205+
payload: {
206+
owner: "org",
207+
slug: clientSlug,
208+
grant: "authorization_code",
209+
authorizationUrl: oauth.authorizationEndpoint,
210+
tokenUrl: oauth.tokenEndpoint,
211+
clientId: "test-client",
212+
clientSecret: "test-secret",
213+
originIntegration: IntegrationSlug.make(slug),
214+
},
215+
});
216+
217+
const started = yield* client.oauth.start({
218+
payload: {
219+
client: clientSlug,
220+
clientOwner: "org",
221+
owner: "org",
222+
name: ConnectionName.make("main"),
223+
integration: IntegrationSlug.make(slug),
224+
template: AuthTemplateSlug.make("oauth"),
225+
},
226+
});
227+
expect(started.status, "oauth.start redirects to the authorization server").toBe(
228+
"redirect",
229+
);
230+
if (started.status !== "redirect") return yield* Effect.die("no redirect");
231+
232+
// Drive the test IdP's consent by hand (authorize → login → code).
233+
const code = yield* Effect.promise(async () => {
234+
const authorize = await fetch(started.authorizationUrl, { redirect: "manual" });
235+
const loginUrl = authorize.headers.get("location");
236+
if (!loginUrl) throw new Error(`authorize did not redirect: ${authorize.status}`);
237+
const login = await fetch(loginUrl, {
238+
method: "POST",
239+
headers: {
240+
authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`,
241+
},
242+
redirect: "manual",
243+
});
244+
const callbackUrl = login.headers.get("location");
245+
if (!callbackUrl) throw new Error(`login did not redirect: ${login.status}`);
246+
const minted = new URL(callbackUrl).searchParams.get("code");
247+
if (!minted) throw new Error("callback carried no authorization code");
248+
return minted;
249+
});
250+
yield* client.oauth.complete({ payload: { state: started.state, code } });
251+
252+
const tools = yield* client.tools.list({ query: {} });
253+
const addresses = tools
254+
.filter((tool) => String(tool.integration) === slug)
255+
.map((tool) => String(tool.address));
256+
const addressOf = (suffix: string) => {
257+
const found = addresses.find((addr) => addr.endsWith(suffix));
258+
expect(found, `the ${suffix} tool is in the catalog`).toBeDefined();
259+
return found!;
260+
};
261+
262+
const invoke = (address: string) =>
263+
Effect.gen(function* () {
264+
const executed = yield* client.executions.execute({
265+
payload: { code: invokeByAddressCode(address, {}), autoApprove: true },
266+
});
267+
expect(executed.status, "the sandbox execution completed").toBe("completed");
268+
return JSON.parse(executed.text) as ToolEnvelope;
269+
});
270+
271+
// THE guarantee: the scope-insufficient 403 carries its own code
272+
// and reconnect guidance — and no oauth.start hint, because
273+
// re-running the identical grant cannot satisfy the scope.
274+
const scopeFailure = yield* invoke(addressOf("listFiles"));
275+
expect(scopeFailure.ok, "the out-of-scope call failed").toBe(false);
276+
expect(
277+
scopeFailure.error?.code,
278+
"the failure names the scope shortfall, not a rejected credential",
279+
).toBe("oauth_scope_insufficient");
280+
expect(
281+
scopeFailure.error?.message ?? "",
282+
"the message says re-authenticating will not help",
283+
).toContain("Re-authenticating with the same grant");
284+
expect(
285+
scopeFailure.error?.details?.recovery?.startOAuthTool,
286+
"no oauth.start recovery hint",
287+
).toBeUndefined();
288+
expect(
289+
scopeFailure.error?.details?.recovery?.scopeInstructions ?? "",
290+
"the recovery tells the agent to reconnect with broader access",
291+
).toContain("broader access");
292+
293+
// An ordinary 403 with no scope signal keeps the existing
294+
// classification — the new code is additive, not a re-label.
295+
const plainForbidden = yield* invoke(addressOf("adminAction"));
296+
expect(plainForbidden.ok, "the plain-403 call failed").toBe(false);
297+
expect(
298+
plainForbidden.error?.code,
299+
"a 403 without a scope signal stays connection_rejected",
300+
).toBe("connection_rejected");
301+
302+
// And the in-scope operation works, proving the connection is fine.
303+
const inScope = yield* invoke(addressOf("readInbox"));
304+
expect(inScope.ok, "the in-scope call succeeds with the same token").toBe(true);
305+
}),
306+
Effect.gen(function* () {
307+
yield* client.connections
308+
.remove({
309+
params: {
310+
owner: "org",
311+
integration: IntegrationSlug.make(slug),
312+
name: ConnectionName.make("main"),
313+
},
314+
})
315+
.pipe(Effect.ignore);
316+
yield* client.oauth
317+
.removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } })
318+
.pipe(Effect.ignore);
319+
yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
320+
}),
321+
);
322+
}),
323+
),
324+
);

packages/core/sdk/src/auth-tool-failure.ts

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ export type AuthToolFailureCode =
55
| "connection_rejected"
66
| "oauth_connection_missing"
77
| "oauth_refresh_failed"
8-
| "oauth_reauth_required";
8+
| "oauth_reauth_required"
9+
| "oauth_scope_insufficient";
910

1011
export type AuthToolFailureInput = {
1112
readonly code: AuthToolFailureCode;
@@ -37,18 +38,35 @@ export type AuthToolFailureInput = {
3738
// creates the bound connection in one step); OAuth credentials are minted by
3839
// the OAuth start flow. These strings are read by the agent resolving the
3940
// failure, so they must name tools that actually exist on the executor.
40-
const authRecovery = (input?: AuthToolFailureInput["recovery"]) => ({
41-
createConnectionTool: "executor.coreTools.connections.createHandoff",
42-
startOAuthTool: "executor.coreTools.oauth.start",
43-
listConnectionsTool: "executor.coreTools.connections.list",
44-
...(input?.configureIntegrationTool
45-
? { configureIntegrationTool: input.configureIntegrationTool }
46-
: {}),
47-
connectionInstructions:
48-
"For API keys and tokens, call createConnectionTool for the integration to get a browser URL; the user enters the credential there, which creates the bound connection. Do not ask the user to paste secrets into chat. Then call listConnectionsTool to confirm the connection exists before retrying this tool.",
49-
oauthInstructions:
50-
"For OAuth credentials, call startOAuthTool and give the returned authorizationUrl to the user. The completed connection binds automatically, then retry the tool.",
51-
});
41+
const authRecovery = (code: AuthToolFailureCode, input?: AuthToolFailureInput["recovery"]) => {
42+
// A scope-insufficient rejection cannot be fixed by re-running the same
43+
// grant, so this branch deliberately omits startOAuthTool/oauthInstructions:
44+
// an agent following the hints would loop through an identical consent and
45+
// land on the identical 403. The connection must be reconnected with a
46+
// broader scope, which is a user decision, not a retryable tool call.
47+
if (code === "oauth_scope_insufficient") {
48+
return {
49+
listConnectionsTool: "executor.coreTools.connections.list",
50+
...(input?.configureIntegrationTool
51+
? { configureIntegrationTool: input.configureIntegrationTool }
52+
: {}),
53+
scopeInstructions:
54+
"The connection's OAuth grant does not cover the scope this operation requires; re-authenticating with the same grant will return the same error. Tell the user which operation was denied and ask them to reconnect the integration with broader access (or use a connection that already has it). Call listConnectionsTool to see the available connections and their scopes.",
55+
};
56+
}
57+
return {
58+
createConnectionTool: "executor.coreTools.connections.createHandoff",
59+
startOAuthTool: "executor.coreTools.oauth.start",
60+
listConnectionsTool: "executor.coreTools.connections.list",
61+
...(input?.configureIntegrationTool
62+
? { configureIntegrationTool: input.configureIntegrationTool }
63+
: {}),
64+
connectionInstructions:
65+
"For API keys and tokens, call createConnectionTool for the integration to get a browser URL; the user enters the credential there, which creates the bound connection. Do not ask the user to paste secrets into chat. Then call listConnectionsTool to confirm the connection exists before retrying this tool.",
66+
oauthInstructions:
67+
"For OAuth credentials, call startOAuthTool and give the returned authorizationUrl to the user. The completed connection binds automatically, then retry the tool.",
68+
};
69+
};
5270

5371
export const authToolFailure = <T = never>(input: AuthToolFailureInput): ToolResult<T> => {
5472
const error: ToolError = {
@@ -61,7 +79,7 @@ export const authToolFailure = <T = never>(input: AuthToolFailureInput): ToolRes
6179
...(input.integration ? { integration: input.integration } : {}),
6280
...(input.credential ? { credential: input.credential } : {}),
6381
...(input.upstream ? { upstream: input.upstream } : {}),
64-
recovery: authRecovery(input.recovery),
82+
recovery: authRecovery(input.code, input.recovery),
6583
},
6684
};
6785
return ToolResult.fail(error);

0 commit comments

Comments
 (0)