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/4] 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/4] 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/4] 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/4] 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