diff --git a/.changeset/oauth-dcr-application-type.md b/.changeset/oauth-dcr-application-type.md new file mode 100644 index 000000000..5581d2601 --- /dev/null +++ b/.changeset/oauth-dcr-application-type.md @@ -0,0 +1,9 @@ +--- +"executor": patch +--- + +**Fix: declare the OAuth application type during dynamic client registration** + +Dynamic OAuth registrations now identify HTTPS callbacks as web applications +and loopback HTTP callbacks as native applications. This lets strict OAuth +servers validate Executor's redirect URI against the correct client type. diff --git a/packages/core/sdk/src/oauth-register-dynamic.test.ts b/packages/core/sdk/src/oauth-register-dynamic.test.ts index 266c7cc4d..182c031c1 100644 --- a/packages/core/sdk/src/oauth-register-dynamic.test.ts +++ b/packages/core/sdk/src/oauth-register-dynamic.test.ts @@ -145,6 +145,7 @@ describe("oauth.registerDynamicClient", () => { expect(registerRequest!.body).toContain(FLOW_REDIRECT_URI); expect(registerRequest!.body).toContain("authorization_code"); expect(registerRequest!.body).toContain("refresh_token"); + expect(registerRequest!.body).toContain('"application_type":"web"'); const authorizationRequest = requests.find( (r) => r.path === "/authorize" && r.method === "GET", ); @@ -160,6 +161,39 @@ describe("oauth.registerDynamicClient", () => { ), ); + it.effect("registers loopback callbacks as native applications", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); + + yield* executor.oauth.registerDynamicClient({ + owner: "org", + slug: CLIENT, + issuer: probe.issuer, + registrationEndpoint: probe.registrationEndpoint!, + authorizationUrl: probe.authorizationUrl, + tokenUrl: probe.tokenUrl, + resource: probe.resource, + scopes: ["read"], + tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, + clientName: "Acme DCR", + redirectUri: "http://127.0.0.1:5394/api/oauth/callback", + originIntegration: INTEG, + }); + + const requests = yield* server.requests; + const registerRequest = requests.find( + (request) => request.path === "/register" && request.method === "POST", + ); + expect(registerRequest).toBeDefined(); + expect(registerRequest!.body).toContain('"application_type":"native"'); + }), + ), + ); + it.effect("reuses an existing DCR client for the same owner and authorization server", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 742cf65ad..f7956b4ed 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -897,6 +897,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: authMethod, + application_type: isLoopbackHttpUrl(flowRedirectUri) ? "native" : "web", scope: input.scopes.length > 0 ? input.scopes.join(" ") : undefined, }, },