diff --git a/.changeset/cimd-refresh-token.md b/.changeset/cimd-refresh-token.md new file mode 100644 index 000000000..22408ee8d --- /dev/null +++ b/.changeset/cimd-refresh-token.md @@ -0,0 +1,10 @@ +--- +"@executor-js/api": patch +--- + +Advertise refresh-token support in OAuth client ID metadata documents. + +OAuth providers may reject the `offline_access` scope when the client's +metadata declares only the authorization-code grant. Hosted and local client +metadata now declare both `authorization_code` and `refresh_token`, matching +Executor's dynamic client registration behavior. diff --git a/e2e/selfhost/mcp-oauth-cimd-connect.test.ts b/e2e/selfhost/mcp-oauth-cimd-connect.test.ts index 94f9d5fa1..95b76d55f 100644 --- a/e2e/selfhost/mcp-oauth-cimd-connect.test.ts +++ b/e2e/selfhost/mcp-oauth-cimd-connect.test.ts @@ -30,7 +30,7 @@ scenario( const oauth = yield* OAuthTestServer; const server = yield* serveMcpServerWithOAuth( () => makeGreetingMcpServer({ name: "cimd-connect-mcp" }), - { path: "/mcp" }, + { path: "/mcp", scopes: ["read", "offline_access"] }, ); const identity = yield* target.newIdentity(); const client = yield* makeApiClient(api, identity); @@ -69,12 +69,26 @@ scenario( authorize, "the popup reached the discovered authorization endpoint", ).toBeDefined(); - const clientId = authorize?.query["client_id"]; - createdClientId = clientId; + expect( + (authorize?.query["scope"] ?? "").split(" "), + "authorization requests the resource's offline access scope", + ).toContain("offline_access"); + const clientId = authorize?.query["client_id"] ?? ""; + createdClientId = clientId || undefined; expect( clientId, "authorization uses Executor's metadata document as client_id", ).toMatch(/^https?:\/\/[^/]+\/api\/oauth\/client-id-metadata\/.+\.json$/); + const metadataResponse = await page.request.get(clientId); + expect(metadataResponse.status(), "the client metadata document is reachable").toBe( + 200, + ); + expect( + await metadataResponse.json(), + "the client declares the grant required by offline_access", + ).toMatchObject({ + grant_types: ["authorization_code", "refresh_token"], + }); await popup.close(); }); }); @@ -105,5 +119,12 @@ scenario( ), ); }), - ).pipe(Effect.provide(OAuthTestServer.layer({ clientIdMetadataDocumentSupported: true }))), + ).pipe( + Effect.provide( + OAuthTestServer.layer({ + clientIdMetadataDocumentSupported: true, + scopes: ["read", "offline_access"], + }), + ), + ), ); diff --git a/packages/core/api/src/server/oauth-client-metadata.test.ts b/packages/core/api/src/server/oauth-client-metadata.test.ts index fd730dabe..384c0e318 100644 --- a/packages/core/api/src/server/oauth-client-metadata.test.ts +++ b/packages/core/api/src/server/oauth-client-metadata.test.ts @@ -19,6 +19,7 @@ describe("OAuth client ID metadata document", () => { "http://100.81.219.45:42384/api/oauth/client-id-metadata/acme.json", ); expect(metadata.redirect_uris).toEqual(["http://100.81.219.45:42384/api/oauth/callback"]); + expect(metadata.grant_types).toEqual(["authorization_code", "refresh_token"]); expect(metadata.token_endpoint_auth_method).toBe("none"); expect(metadata.application_type).toBe("web"); }); @@ -63,6 +64,7 @@ describe("OAuth client ID metadata document", () => { "http://localhost/api/oauth/callback", "http://[::1]/api/oauth/callback", ]); + expect(metadata.grant_types).toEqual(["authorization_code", "refresh_token"]); expect(metadata.application_type).toBe("native"); }); diff --git a/packages/core/api/src/server/oauth-client-metadata.ts b/packages/core/api/src/server/oauth-client-metadata.ts index c597745d5..d2e30d729 100644 --- a/packages/core/api/src/server/oauth-client-metadata.ts +++ b/packages/core/api/src/server/oauth-client-metadata.ts @@ -9,6 +9,10 @@ export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_TARGET_PATH_PREFIX = export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET = "default" as const; export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET = "local" as const; +// Keep CIMD aligned with DCR: providers may reject `offline_access` unless the +// client declares that it can use the refresh-token grant. +const OAUTH_CLIENT_GRANT_TYPES = ["authorization_code", "refresh_token"] as const; + type MetadataTarget = | typeof OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET | typeof OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET @@ -19,7 +23,7 @@ interface OAuthClientIdMetadataDocument { readonly client_name: string; readonly client_uri: string; readonly redirect_uris: readonly string[]; - readonly grant_types: readonly ["authorization_code"]; + readonly grant_types: typeof OAUTH_CLIENT_GRANT_TYPES; readonly response_types: readonly ["code"]; readonly token_endpoint_auth_method: "none"; readonly application_type: "web" | "native"; @@ -129,7 +133,7 @@ export const oauthClientIdMetadataDocumentFromRequest = ({ client_name: "Executor Local", client_uri: url.origin, redirect_uris: localLoopbackRedirectUris(mountPrefix), - grant_types: ["authorization_code"], + grant_types: OAUTH_CLIENT_GRANT_TYPES, response_types: ["code"], token_endpoint_auth_method: "none", application_type: "native", @@ -150,7 +154,7 @@ export const oauthClientIdMetadataDocumentFromRequest = ({ client_name: "Executor", client_uri: url.origin, redirect_uris: [redirectUri.toString()], - grant_types: ["authorization_code"], + grant_types: OAUTH_CLIENT_GRANT_TYPES, response_types: ["code"], token_endpoint_auth_method: "none", application_type: "web",