Skip to content

Commit d873caf

Browse files
Advertise refresh-token grant in OAuth client metadata (#1974)
* Advertise refresh-token grant in OAuth client metadata * Test OAuth metadata through connection and tool use * Test queue timeout with a controlled clock --------- Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent 55a8b5e commit d873caf

4 files changed

Lines changed: 83 additions & 9 deletions

File tree

‎.changeset/cimd-refresh-token.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@executor-js/api": patch
3+
---
4+
5+
Advertise refresh-token support in OAuth client ID metadata documents.
6+
7+
OAuth providers may reject the `offline_access` scope when the client's
8+
metadata declares only the authorization-code grant. Hosted and local client
9+
metadata now declare both `authorization_code` and `refresh_token`, matching
10+
Executor's dynamic client registration behavior.

‎e2e/selfhost/mcp-oauth-cimd-connect.test.ts‎

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { visit } from "../src/surfaces/browser";
2020
const api = composePluginApi([mcpHttpPlugin()] as const);
2121

2222
scenario(
23-
"MCP OAuth · advertised CIMD starts authorization without dynamic registration",
23+
"MCP OAuth · CIMD advertises refresh support and completes connection without dynamic registration",
2424
{ timeout: 180_000 },
2525
Effect.scoped(
2626
Effect.gen(function* () {
@@ -30,7 +30,7 @@ scenario(
3030
const oauth = yield* OAuthTestServer;
3131
const server = yield* serveMcpServerWithOAuth(
3232
() => makeGreetingMcpServer({ name: "cimd-connect-mcp" }),
33-
{ path: "/mcp" },
33+
{ path: "/mcp", scopes: ["read", "offline_access"] },
3434
);
3535
const identity = yield* target.newIdentity();
3636
const client = yield* makeApiClient(api, identity);
@@ -69,17 +69,68 @@ scenario(
6969
authorize,
7070
"the popup reached the discovered authorization endpoint",
7171
).toBeDefined();
72-
const clientId = authorize?.query["client_id"];
73-
createdClientId = clientId;
72+
expect(
73+
(authorize?.query["scope"] ?? "").split(" "),
74+
"authorization requests the resource's offline access scope",
75+
).toContain("offline_access");
76+
const clientId = authorize?.query["client_id"] ?? "";
77+
createdClientId = clientId || undefined;
7478
expect(
7579
clientId,
7680
"authorization uses Executor's metadata document as client_id",
7781
).toMatch(/^https?:\/\/[^/]+\/api\/oauth\/client-id-metadata\/.+\.json$/);
78-
await popup.close();
82+
const metadataResponse = await page.request.get(clientId);
83+
expect(metadataResponse.status(), "the client metadata document is reachable").toBe(
84+
200,
85+
);
86+
expect(
87+
await metadataResponse.json(),
88+
"the client declares the grant required by offline_access",
89+
).toMatchObject({
90+
grant_types: ["authorization_code", "refresh_token"],
91+
});
92+
expect(authorize).toBeDefined();
93+
// oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: authorization must exist before completing the flow
94+
if (authorize === undefined) throw new Error("Missing authorization request");
95+
const completed = await Effect.runPromise(
96+
oauth.completeAuthorizationCodeFlow({ authorizationUrl: authorize.url }),
97+
);
98+
await popup.goto(completed.callbackUrl);
99+
await page
100+
.getByRole("heading", { name: /Add connection/ })
101+
.waitFor({ state: "hidden" });
102+
await popup.close().catch(() => undefined);
79103
});
80104
});
81105

106+
const connections = yield* client.connections.list({ query: { integration: slug } });
107+
expect(connections, "the OAuth callback saved the connection").toHaveLength(1);
108+
const tools = yield* client.tools.list({ query: { integration: slug } });
109+
expect(
110+
tools.some((tool) => tool.name === "simple_echo"),
111+
"authenticated discovery finds the upstream tool",
112+
).toBe(true);
113+
114+
const invoked = yield* client.executions.execute({
115+
payload: {
116+
code: `return await ${tools[0]?.address}({});`,
117+
autoApprove: true,
118+
},
119+
});
120+
expect(invoked.status).toBe("completed");
121+
expect(invoked.text, "the connected tool runs through authenticated MCP").toContain(
122+
"mcp-ok",
123+
);
124+
82125
const requests = yield* oauth.requests;
126+
expect(
127+
requests.some(
128+
(request) =>
129+
request.path === "/token" &&
130+
new URLSearchParams(request.body).get("grant_type") === "authorization_code",
131+
),
132+
"the callback exchanged the code using the advertised client",
133+
).toBe(true);
83134
expect(
84135
requests.filter((request) => request.method === "POST" && request.path === "/register"),
85136
"CIMD wins when the server also advertises DCR",
@@ -105,5 +156,12 @@ scenario(
105156
),
106157
);
107158
}),
108-
).pipe(Effect.provide(OAuthTestServer.layer({ clientIdMetadataDocumentSupported: true }))),
159+
).pipe(
160+
Effect.provide(
161+
OAuthTestServer.layer({
162+
clientIdMetadataDocumentSupported: true,
163+
scopes: ["read", "offline_access"],
164+
}),
165+
),
166+
),
109167
);

‎packages/core/api/src/server/oauth-client-metadata.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ describe("OAuth client ID metadata document", () => {
1919
"http://100.81.219.45:42384/api/oauth/client-id-metadata/acme.json",
2020
);
2121
expect(metadata.redirect_uris).toEqual(["http://100.81.219.45:42384/api/oauth/callback"]);
22+
expect(metadata.grant_types).toEqual(["authorization_code", "refresh_token"]);
2223
expect(metadata.token_endpoint_auth_method).toBe("none");
2324
expect(metadata.application_type).toBe("web");
2425
});
@@ -63,6 +64,7 @@ describe("OAuth client ID metadata document", () => {
6364
"http://localhost/api/oauth/callback",
6465
"http://[::1]/api/oauth/callback",
6566
]);
67+
expect(metadata.grant_types).toEqual(["authorization_code", "refresh_token"]);
6668
expect(metadata.application_type).toBe("native");
6769
});
6870

‎packages/core/api/src/server/oauth-client-metadata.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_TARGET_PATH_PREFIX =
99
export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET = "default" as const;
1010
export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET = "local" as const;
1111

12+
// Keep CIMD aligned with DCR: providers may reject `offline_access` unless the
13+
// client declares that it can use the refresh-token grant.
14+
const OAUTH_CLIENT_GRANT_TYPES = ["authorization_code", "refresh_token"] as const;
15+
1216
type MetadataTarget =
1317
| typeof OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET
1418
| typeof OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET
@@ -19,7 +23,7 @@ interface OAuthClientIdMetadataDocument {
1923
readonly client_name: string;
2024
readonly client_uri: string;
2125
readonly redirect_uris: readonly string[];
22-
readonly grant_types: readonly ["authorization_code"];
26+
readonly grant_types: typeof OAUTH_CLIENT_GRANT_TYPES;
2327
readonly response_types: readonly ["code"];
2428
readonly token_endpoint_auth_method: "none";
2529
readonly application_type: "web" | "native";
@@ -129,7 +133,7 @@ export const oauthClientIdMetadataDocumentFromRequest = ({
129133
client_name: "Executor Local",
130134
client_uri: url.origin,
131135
redirect_uris: localLoopbackRedirectUris(mountPrefix),
132-
grant_types: ["authorization_code"],
136+
grant_types: OAUTH_CLIENT_GRANT_TYPES,
133137
response_types: ["code"],
134138
token_endpoint_auth_method: "none",
135139
application_type: "native",
@@ -150,7 +154,7 @@ export const oauthClientIdMetadataDocumentFromRequest = ({
150154
client_name: "Executor",
151155
client_uri: url.origin,
152156
redirect_uris: [redirectUri.toString()],
153-
grant_types: ["authorization_code"],
157+
grant_types: OAUTH_CLIENT_GRANT_TYPES,
154158
response_types: ["code"],
155159
token_endpoint_auth_method: "none",
156160
application_type: "web",

0 commit comments

Comments
 (0)