Skip to content

Commit f7a1dcd

Browse files
committed
Advertise refresh-token grant in OAuth client metadata
1 parent 5e4da2a commit f7a1dcd

4 files changed

Lines changed: 44 additions & 7 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: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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,12 +69,26 @@ 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$/);
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+
});
7892
await popup.close();
7993
});
8094
});
@@ -105,5 +119,12 @@ scenario(
105119
),
106120
);
107121
}),
108-
).pipe(Effect.provide(OAuthTestServer.layer({ clientIdMetadataDocumentSupported: true }))),
122+
).pipe(
123+
Effect.provide(
124+
OAuthTestServer.layer({
125+
clientIdMetadataDocumentSupported: true,
126+
scopes: ["read", "offline_access"],
127+
}),
128+
),
129+
),
109130
);

‎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)