From 541611f46524c66080c5028aa9cc4e8fccbcae60 Mon Sep 17 00:00:00 2001 From: The-AarushiSingh <175547726+The-AarushiSingh@users.noreply.github.com> Date: Mon, 31 Aug 2026 01:22:27 +0530 Subject: [PATCH 1/8] fix(oauth): reject user-scoped clients on single-workspace hosts - Reject owner: 'user' when deps.subject === 'local' - Keep user-owned OAuth clients working for other subjects - Update mismatch error to avoid 'Workspace' terminology Closes #1850 --- packages/core/sdk/src/oauth-flow.test.ts | 2 +- packages/core/sdk/src/oauth-service.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index b9860574bb..feb539f85e 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -999,7 +999,7 @@ describe("oauth.start / oauth.complete", () => { ); expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true); const startError = error as OAuthStartError; - expect(startError.message).toContain("must use a Workspace app"); + expect(startError.message).toContain("must use an org-owned OAuth client"); }), ), ); diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 802a7342af..0706787f83 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -878,6 +878,13 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { input: CreateOAuthClientInput, ): Effect.Effect => Effect.gen(function* () { + if (input.owner === "user" && deps.subject === "local") { + return yield* new StorageError({ + message: + 'User-owned OAuth clients are not supported on single-workspace hosts. Use owner "org" instead.', + cause: undefined, + }); + } // The `first-party:` namespace is reserved for config-declared apps — a // stored row under it would be shadowed by (or worse, impersonate) the // host's own app. @@ -1665,7 +1672,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); if (!firstPartyFlow && input.owner === "org" && input.clientOwner === "user") { return yield* new OAuthStartError({ - message: "A Workspace connection must use a Workspace app.", + message: "An org connection must use an org-owned OAuth client.", }); } // Load the app by its EXPLICIT owner (the caller knows it — no derivation). From 26e10d2b66ec4074be4338131e925de779517d7c Mon Sep 17 00:00:00 2001 From: The-AarushiSingh <175547726+The-AarushiSingh@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:16:53 +0530 Subject: [PATCH 2/8] fix(oauth): reject local user clients before DCR and cover with a test --- .changeset/oauth-local-reject-user-client.md | 5 +++++ packages/core/sdk/src/oauth-flow.test.ts | 19 +++++++++++++++++++ packages/core/sdk/src/oauth-service.ts | 10 ++++++++-- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 .changeset/oauth-local-reject-user-client.md diff --git a/.changeset/oauth-local-reject-user-client.md b/.changeset/oauth-local-reject-user-client.md new file mode 100644 index 0000000000..4d08df3cf7 --- /dev/null +++ b/.changeset/oauth-local-reject-user-client.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Reject user-owned OAuth clients when the subject is local, including before DCR. diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index feb539f85e..11944d55df 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -263,6 +263,25 @@ describe("oauth.start / oauth.complete", () => { }), ), ); + it.effect("createClient rejects owner user when subject is local", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins, subject: "local" }), + ); + const error = yield* Effect.flip( + executor.oauth.createClient({ + owner: "user", + slug: "personal", + authorizationUrl: "https://example.com/authorize", + tokenUrl: "https://example.com/token", + grant: "authorization_code", + clientId: "id", + clientSecret: "secret", + }), + ); + expect(String(error)).toContain("User-owned OAuth clients are not supported"); + }), + ); it.effect("persists HTTP Basic client auth for code exchange and refresh", () => Effect.scoped( diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 0706787f83..9a8e585b66 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -1393,14 +1393,20 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { return { existingSlug: null, registrationSlug: slug }; }); - const registerDynamicClient = ( + const registerDynamicClient = ( input: RegisterDynamicClientInput, ): Effect.Effect< OAuthClientSlug, OAuthRegisterDynamicError | OrgWriteDeniedError | StorageFailure > => Effect.gen(function* () { - yield* deps.guardOrgWrite(input.owner); + if (input.owner === "user" && deps.subject === "local") { + return yield* new StorageError({ + message: + 'User-owned OAuth clients are not supported on single-workspace hosts. Use owner "org" instead.', + cause: undefined, + }); + } const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint); // Resolved before the reuse decision: a persisted client registered with // a DIFFERENT callback must not be reused (strict servers 400 the From 1b59283f86f1531fdb15c0a82e59e6ba493ad529 Mon Sep 17 00:00:00 2001 From: The-AarushiSingh <175547726+The-AarushiSingh@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:52:06 +0530 Subject: [PATCH 3/8] test(oauth): assert local user-client rejection via catchTag --- packages/core/sdk/src/oauth-flow.test.ts | 23 ++++++++++++++--------- packages/core/sdk/src/oauth-service.ts | 2 +- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index 11944d55df..345e6985c7 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -265,21 +265,26 @@ describe("oauth.start / oauth.complete", () => { ); it.effect("createClient rejects owner user when subject is local", () => Effect.gen(function* () { - const executor = yield* createExecutor( - makeTestConfig({ plugins, subject: "local" }), - ); - const error = yield* Effect.flip( - executor.oauth.createClient({ + const executor = yield* createExecutor(makeTestConfig({ plugins, subject: "local" })); + let seen = false; + yield* executor.oauth + .createClient({ owner: "user", - slug: "personal", + slug: OAuthClientSlug.make("personal"), authorizationUrl: "https://example.com/authorize", tokenUrl: "https://example.com/token", grant: "authorization_code", clientId: "id", clientSecret: "secret", - }), - ); - expect(String(error)).toContain("User-owned OAuth clients are not supported"); + }) + .pipe( + Effect.catchTag("StorageError", (err) => { + seen = true; + expect(err.message).toContain("User-owned OAuth clients are not supported"); + return Effect.void; + }), + ); + expect(seen).toBe(true); }), ); diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 9a8e585b66..6bf57583d1 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -1393,7 +1393,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { return { existingSlug: null, registrationSlug: slug }; }); - const registerDynamicClient = ( + const registerDynamicClient = ( input: RegisterDynamicClientInput, ): Effect.Effect< OAuthClientSlug, From 8918f966c9596579598478c24fa7587e2cdc4118 Mon Sep 17 00:00:00 2001 From: The-AarushiSingh <175547726+The-AarushiSingh@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:11:31 +0530 Subject: [PATCH 4/8] fix(oauth): keep org-write guard before DCR network call --- packages/core/sdk/src/oauth-service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 6bf57583d1..c8c571c77d 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -1400,13 +1400,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { OAuthRegisterDynamicError | OrgWriteDeniedError | StorageFailure > => Effect.gen(function* () { - if (input.owner === "user" && deps.subject === "local") { + if (input.owner === "user" && deps.subject === "local") { return yield* new StorageError({ message: 'User-owned OAuth clients are not supported on single-workspace hosts. Use owner "org" instead.', cause: undefined, }); } + yield* deps.guardOrgWrite(input.owner); const issuer = canonicalDcrIssuer(input.issuer, input.registrationEndpoint); // Resolved before the reuse decision: a persisted client registered with // a DIFFERENT callback must not be reused (strict servers 400 the From 9cd1573895c938dea4417833e08d6fcc6ef5c33d Mon Sep 17 00:00:00 2001 From: The-AarushiSingh <175547726+The-AarushiSingh@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:22:53 +0530 Subject: [PATCH 5/8] style(oauth): oxfmt local-user client guard --- packages/core/sdk/src/oauth-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index c8c571c77d..7645d59092 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -1400,7 +1400,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { OAuthRegisterDynamicError | OrgWriteDeniedError | StorageFailure > => Effect.gen(function* () { - if (input.owner === "user" && deps.subject === "local") { + if (input.owner === "user" && deps.subject === "local") { return yield* new StorageError({ message: 'User-owned OAuth clients are not supported on single-workspace hosts. Use owner "org" instead.', From a7d4c6d8e2a525c973d51dc9fc5719ce80e327e1 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:12:06 -0700 Subject: [PATCH 6/8] Test queue timeout with a controlled clock --- apps/cloud/src/mcp/session-build-semaphore.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/cloud/src/mcp/session-build-semaphore.test.ts b/apps/cloud/src/mcp/session-build-semaphore.test.ts index 3d4ad76343..584b65ee0e 100644 --- a/apps/cloud/src/mcp/session-build-semaphore.test.ts +++ b/apps/cloud/src/mcp/session-build-semaphore.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach } from "@effect/vitest"; +import { describe, expect, it, beforeEach, afterEach, vi } from "@effect/vitest"; import { acquireBuildSlot, @@ -13,6 +13,10 @@ describe("session-build-semaphore", () => { resetBuildSlotsForTest(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("grants up to the cap immediately, with no wait", async () => { const results = await Promise.all([ acquireBuildSlot().promise, @@ -214,6 +218,7 @@ describe("session-build-semaphore", () => { }); it("proceeds without a slot when the queue wait exceeds the timeout, and does not count it as active", async () => { + vi.useFakeTimers(); await Promise.all([ acquireBuildSlot().promise, acquireBuildSlot().promise, @@ -223,6 +228,10 @@ describe("session-build-semaphore", () => { expect(currentActiveBuildsForTest()).toBe(4); const timedOutHandle = acquireBuildSlot(10); + await vi.advanceTimersByTimeAsync(9); + expect(currentQueueLengthForTest()).toBe(1); + expect(currentActiveBuildsForTest()).toBe(4); + await vi.advanceTimersByTimeAsync(1); const result = await timedOutHandle.promise; expect(result).toEqual({ acquired: false, waitMs: expect.any(Number), timedOut: true }); From db3436a2835b097875ae0b343a68efb6a4cea0ce Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:46:47 -0700 Subject: [PATCH 7/8] Verify local OAuth ownership before provider registration --- .changeset/oauth-local-reject-user-client.md | 2 +- e2e/local/oauth-client-ownership.test.ts | 151 +++++++++++++++++++ packages/core/sdk/src/oauth-flow.test.ts | 24 +++ 3 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 e2e/local/oauth-client-ownership.test.ts diff --git a/.changeset/oauth-local-reject-user-client.md b/.changeset/oauth-local-reject-user-client.md index 4d08df3cf7..f83e7a7ab4 100644 --- a/.changeset/oauth-local-reject-user-client.md +++ b/.changeset/oauth-local-reject-user-client.md @@ -1,5 +1,5 @@ --- -"executor": patch +"@executor-js/sdk": patch --- Reject user-owned OAuth clients when the subject is local, including before DCR. diff --git a/e2e/local/oauth-client-ownership.test.ts b/e2e/local/oauth-client-ownership.test.ts new file mode 100644 index 0000000000..c9dd99803d --- /dev/null +++ b/e2e/local/oauth-client-ownership.test.ts @@ -0,0 +1,151 @@ +import { expect } from "@effect/vitest"; +import { connectEmulator } from "@executor-js/emulate"; +import { Effect } from "effect"; +import { HttpApiClient } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; + +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Browser, Cli, RunDir } from "../src/services"; +import { withLocalServer } from "./local-server"; + +const api = composePluginApi([mcpHttpPlugin()] as const); + +scenario( + "Local OAuth · reject user clients before registration while org clients connect", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const cli = yield* Cli; + const runDir = yield* RunDir; + const browser = yield* Browser; + const base = yield* createEmulatorInstance("mcp", "local-ownership"); + const emulator = yield* Effect.promise(() => + connectEmulator({ baseUrl: base, service: "mcp" }), + ); + yield* Effect.promise(() => emulator.seed({ users: [{ login: "local-oauth-user" }] })); + yield* withLocalServer(cli, runDir, (server) => + Effect.gen(function* () { + const client = yield* HttpApiClient.make(api, { + baseUrl: new URL("/api", server.origin).toString(), + transformClient: HttpClient.mapRequest((request) => + HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); + const endpoints = { authorizationUrl: `${base}/authorize`, tokenUrl: `${base}/token` }; + const slug = IntegrationSlug.make("local-oauth-owner"); + const registration = { + slug: OAuthClientSlug.make("local-oauth-app"), + issuer: base, + registrationEndpoint: `${base}/register`, + ...endpoints, + resource: `${base}/mcp`, + scopes: ["repo", "read:user"], + tokenEndpointAuthMethodsSupported: ["none"], + originIntegration: slug, + }; + const rejected = yield* client.oauth + .createClient({ + payload: { + owner: "user", + slug: OAuthClientSlug.make("forbidden-personal"), + grant: "authorization_code", + ...endpoints, + clientId: "unused-client", + clientSecret: "unused-secret", + }, + }) + .pipe(Effect.flip); + expect(rejected._tag).toBe("InternalError"); + const deniedDcr = yield* client.oauth + .registerDynamic({ payload: { ...registration, owner: "user" } }) + .pipe(Effect.flip); + expect(deniedDcr._tag).toBe("InternalError"); + const before = yield* Effect.promise(() => emulator.ledger.list()); + expect(before.some((entry) => entry.path === "/register")).toBe(false); + expect((yield* client.oauth.listClients()).some((entry) => entry.owner === "user")).toBe( + false, + ); + yield* client.mcp.addServer({ + payload: { + slug, + name: "Local OAuth", + endpoint: `${base}/mcp`, + transport: "remote", + authenticationTemplate: [{ kind: "oauth2" }], + }, + }); + yield* Effect.addFinalizer(() => + client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore), + ); + const registered = yield* client.oauth.registerDynamic({ + payload: { ...registration, owner: "org" }, + }); + yield* Effect.addFinalizer(() => + client.oauth + .removeClient({ params: { slug: registered.client }, payload: { owner: "org" } }) + .pipe(Effect.ignore), + ); + const started = yield* client.oauth.start({ + payload: { + owner: "org", + client: registered.client, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: slug, + template: AuthTemplateSlug.make("oauth2"), + }, + }); + if (started.status !== "redirect") return yield* Effect.die("Expected OAuth redirect"); + yield* browser.session({ label: "local" }, async ({ page, step }) => { + await step("Sign in to the local console", async () => { + await page.goto(server.url); + await page + .getByTestId("integration-entry-executor") + .first() + .waitFor({ timeout: 30_000 }); + }); + await step("Authorize an org-owned OAuth client", async () => { + await page.goto(started.authorizationUrl); + await page.getByText("Authorize MCP client", { exact: true }).waitFor(); + const authorize = new URL(started.authorizationUrl); + const approved = await page.request.post(`${base}/authorize/approve`, { + form: { ...Object.fromEntries(authorize.searchParams), login: "local-oauth-user" }, + maxRedirects: 0, + }); + expect(approved.status()).toBe(302); + const callback = approved.headers().location; + if (!callback) throw new Error("Missing OAuth callback"); + await page.goto(callback); + await page.getByText("Connected", { exact: true }).waitFor({ timeout: 30_000 }); + }); + }); + const catalog = yield* client.tools.list({ query: { integration: slug } }); + expect(catalog.some((entry) => entry.name === "get_me" && entry.owner === "org")).toBe( + true, + ); + const ledger = yield* Effect.promise(() => emulator.ledger.list()); + expect( + ledger.some((entry) => entry.path === "/token" && entry.response.status === 200), + ).toBe(true); + expect( + ledger.some( + (entry) => + entry.path === "/mcp" && + entry.response.status === 200 && + entry.identity.user?.login === "local-oauth-user", + ), + ).toBe(true); + }), + ); + }), + ), +); diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index 345e6985c7..1857a6e18e 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -288,6 +288,30 @@ describe("oauth.start / oauth.complete", () => { }), ); + it.effect("local user DCR is rejected before contacting the provider", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer(); + const executor = yield* createExecutor(makeTestConfig({ plugins, subject: "local" })); + const error = yield* executor.oauth + .registerDynamicClient({ + owner: "user", + slug: CLIENT, + issuer: server.issuerUrl, + registrationEndpoint: server.registrationEndpoint, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + scopes: ["read"], + redirectUri: "http://localhost/callback", + }) + .pipe(Effect.flip); + expect(Predicate.isTagged("StorageError")(error)).toBe(true); + expect(yield* server.requests).toEqual([]); + expect(yield* executor.oauth.listClients()).toEqual([]); + }), + ), + ); + it.effect("persists HTTP Basic client auth for code exchange and refresh", () => Effect.scoped( Effect.gen(function* () { From 928c5483ea8de57d1f124f0ae709653d29182c56 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:47:40 -0700 Subject: [PATCH 8/8] Use the bound local port for OAuth callbacks --- e2e/local/oauth-client-ownership.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/e2e/local/oauth-client-ownership.test.ts b/e2e/local/oauth-client-ownership.test.ts index c9dd99803d..5792851968 100644 --- a/e2e/local/oauth-client-ownership.test.ts +++ b/e2e/local/oauth-client-ownership.test.ts @@ -40,6 +40,9 @@ scenario( HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`), ), }).pipe(Effect.provide(FetchHttpClient.layer)); + // The CLI fixture binds port0; pass its actual callback through the + // public override instead of its pre-bind default port. + const redirectUri = new URL("/api/oauth/callback", server.origin).toString(); const endpoints = { authorizationUrl: `${base}/authorize`, tokenUrl: `${base}/token` }; const slug = IntegrationSlug.make("local-oauth-owner"); const registration = { @@ -50,6 +53,7 @@ scenario( resource: `${base}/mcp`, scopes: ["repo", "read:user"], tokenEndpointAuthMethodsSupported: ["none"], + redirectUri, originIntegration: slug, }; const rejected = yield* client.oauth @@ -102,6 +106,7 @@ scenario( name: ConnectionName.make("main"), integration: slug, template: AuthTemplateSlug.make("oauth2"), + redirectUri, }, }); if (started.status !== "redirect") return yield* Effect.die("Expected OAuth redirect");