From 47f5acbfb2eab8cb72f4212bf3adec1f6ea11caf Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:37:28 -0700 Subject: [PATCH] Gate Google OAuth listings by user flag --- .../first-party-oauth-listing-policy.md | 5 + .../analytics/google-oauth-listing.test.ts | 89 ++++++++++++++ .../src/analytics/google-oauth-listing.ts | 44 +++++++ .../engine/first-party-oauth-clients.test.ts | 13 +- .../src/engine/first-party-oauth-clients.ts | 18 ++- packages/core/sdk/src/oauth-client.ts | 8 ++ .../core/sdk/src/oauth-first-party.test.ts | 47 ++++++- packages/core/sdk/src/oauth-service.ts | 116 +++++++++--------- 8 files changed, 274 insertions(+), 66 deletions(-) create mode 100644 .changeset/first-party-oauth-listing-policy.md create mode 100644 apps/cloud/src/analytics/google-oauth-listing.test.ts create mode 100644 apps/cloud/src/analytics/google-oauth-listing.ts diff --git a/.changeset/first-party-oauth-listing-policy.md b/.changeset/first-party-oauth-listing-policy.md new file mode 100644 index 0000000000..fe346656f5 --- /dev/null +++ b/.changeset/first-party-oauth-listing-policy.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Allow hosts to control first-party OAuth app listings for each acting user without disrupting existing connections. diff --git a/apps/cloud/src/analytics/google-oauth-listing.test.ts b/apps/cloud/src/analytics/google-oauth-listing.test.ts new file mode 100644 index 0000000000..8b85ec0a49 --- /dev/null +++ b/apps/cloud/src/analytics/google-oauth-listing.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { GOOGLE_OAUTH_REVIEW_FLAG, makeGoogleOAuthListing } from "./google-oauth-listing"; + +const context = { userId: "review-user", organizationId: "review-org" }; + +describe("Google OAuth review listing", () => { + it.effect("targets the authenticated user and re-evaluates after revocation", () => + Effect.gen(function* () { + let enabled = true; + const policy = makeGoogleOAuthListing({ + projectKey: "public-project-key", + host: "https://flags.example.com", + fetch: async (url, init) => { + expect(url).toBe("https://flags.example.com/flags?v=2"); + expect(await new Response(String(init?.body)).json()).toMatchObject({ + distinct_id: context.userId, + person_properties: { executor_user_id: context.userId }, + flag_keys_to_evaluate: [GOOGLE_OAUTH_REVIEW_FLAG], + }); + expect(init?.signal).toBeInstanceOf(AbortSignal); + return Response.json({ flags: { [GOOGLE_OAUTH_REVIEW_FLAG]: { enabled } } }); + }, + }); + expect(yield* policy(context)).toBe(true); + enabled = false; + expect(yield* policy(context)).toBe(false); + }), + ); + + for (const body of [ + { flags: {} }, + { flags: { [GOOGLE_OAUTH_REVIEW_FLAG]: { enabled: "true" } } }, + {}, + ]) { + it.effect(`withholds an absent or invalid flag: ${JSON.stringify(body)}`, () => + Effect.gen(function* () { + const policy = makeGoogleOAuthListing({ + projectKey: "key", + host: "https://flags.example.com", + fetch: async () => Response.json(body), + }); + expect(yield* policy(context)).toBe(false); + }), + ); + } + + it.effect("withholds on HTTP and transport failures", () => + Effect.gen(function* () { + const unavailable = makeGoogleOAuthListing({ + projectKey: "key", + host: "https://flags.example.com", + fetch: async () => new Response(null, { status: 503 }), + }); + const failed = makeGoogleOAuthListing({ + projectKey: "key", + host: "https://flags.example.com", + fetch: () => + Effect.runPromise(Effect.fail(new DOMException("Network unavailable", "NetworkError"))), + }); + expect(yield* unavailable(context)).toBe(false); + expect(yield* failed(context)).toBe(false); + }), + ); + + it.effect("does not evaluate without a user or project key", () => + Effect.gen(function* () { + let requests = 0; + const fetch: typeof globalThis.fetch = async () => { + requests += 1; + return Response.json({ flags: {} }); + }; + const configured = makeGoogleOAuthListing({ + projectKey: "key", + host: "https://flags.example.com", + fetch, + }); + const unconfigured = makeGoogleOAuthListing({ + projectKey: undefined, + host: "https://flags.example.com", + fetch, + }); + expect(yield* configured({ ...context, userId: null })).toBe(false); + expect(yield* unconfigured(context)).toBe(false); + expect(requests).toBe(0); + }), + ); +}); diff --git a/apps/cloud/src/analytics/google-oauth-listing.ts b/apps/cloud/src/analytics/google-oauth-listing.ts new file mode 100644 index 0000000000..abe775a613 --- /dev/null +++ b/apps/cloud/src/analytics/google-oauth-listing.ts @@ -0,0 +1,44 @@ +import { Effect, Schema } from "effect"; + +import type { FirstPartyOAuthClientConfig } from "@executor-js/sdk"; + +/** Server-side rollout for offering the built-in Google OAuth app. Configure + * the PostHog flag's runtime as "all": direct HTTP clients are not recognized + * as server SDKs. The caller identity still comes only from the server. */ +export const GOOGLE_OAUTH_REVIEW_FLAG = "google-oauth-review"; + +const FlagsResponse = Schema.Struct({ + flags: Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })), +}); + +/** Evaluate each listing against the authenticated user. Missing configuration, + * timeouts, and invalid responses withhold the app. No verdict is cached. */ +export const makeGoogleOAuthListing = + (config: { + readonly projectKey: string | undefined; + readonly host: string; + readonly fetch: typeof globalThis.fetch; + }): NonNullable => + (context) => + Effect.gen(function* () { + if (!config.projectKey || context.userId === null) return false; + const response = yield* Effect.tryPromise(() => + config.fetch(`${config.host}/flags?v=2`, { + method: "POST", + headers: { "content-type": "application/json", "user-agent": "executor-cloud" }, + body: JSON.stringify({ + api_key: config.projectKey, + distinct_id: context.userId, + // Supplied by the server so targeting needs no prior browser identify. + person_properties: { executor_user_id: context.userId }, + groups: { organization: context.organizationId }, + flag_keys_to_evaluate: [GOOGLE_OAUTH_REVIEW_FLAG], + }), + signal: AbortSignal.timeout(1_000), + }), + ); + if (!response.ok) return false; + const body: unknown = yield* Effect.tryPromise(() => response.json()); + const parsed = yield* Schema.decodeUnknownEffect(FlagsResponse)(body); + return parsed.flags[GOOGLE_OAUTH_REVIEW_FLAG]?.enabled === true; + }).pipe(Effect.catch(() => Effect.succeed(false))); diff --git a/apps/cloud/src/engine/first-party-oauth-clients.test.ts b/apps/cloud/src/engine/first-party-oauth-clients.test.ts index fe8c22056b..1ca3962d6b 100644 --- a/apps/cloud/src/engine/first-party-oauth-clients.test.ts +++ b/apps/cloud/src/engine/first-party-oauth-clients.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; import { firstPartyOAuthClientsFor, @@ -99,7 +100,7 @@ describe("cloud first-party OAuth clients", () => { // The reviewed consumer scope boundary of the Executor-owned Google app. // // These assertions used to live in `e2e/scenarios/first-party-oauth.test.ts`, -// read off `listClients`. The app is now `unlisted`, so it has no read surface +// read off `listClients`. The app is now gated, so it has no public read surface // to introspect — the bundle is only observable on the config it is built from, // which is here. The e2e still owns the BEHAVIOUR the boundary produces (which // scopes an `oauth.start` requests, and that admin scopes are refused). @@ -109,13 +110,17 @@ describe("cloud first-party Google app", () => { const google = () => firstPartyOAuthClientsFor(completeEnv).find((client) => client.name === "google"); - it("declares the Google app but withholds it from every listing", () => { + it("declares the Google app but withholds it without a configured rollout", async () => { const client = google(); expect(client, "the env-declared first-party Google app is configured").toBeDefined(); // The entry MUST stay declared: `loadClient` resolves it by slug for every - // existing connection's refresh and reconnect. `unlisted` is what stops it + // existing connection's refresh and reconnect. The listing policy stops it // being offered for new connections. - expect(client?.unlisted).toBe(true); + expect(client?.isListed).toBeDefined(); + if (client?.isListed === undefined) return; + expect( + await Effect.runPromise(client.isListed({ userId: "test-user", organizationId: "test-org" })), + ).toBe(false); }); it("covers the reviewed consumer bundle", () => { diff --git a/apps/cloud/src/engine/first-party-oauth-clients.ts b/apps/cloud/src/engine/first-party-oauth-clients.ts index 8b8ba02b73..9c646e5d58 100644 --- a/apps/cloud/src/engine/first-party-oauth-clients.ts +++ b/apps/cloud/src/engine/first-party-oauth-clients.ts @@ -7,9 +7,14 @@ import { import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth"; import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk"; +import { makeGoogleOAuthListing } from "../analytics/google-oauth-listing"; +import { POSTHOG_INGEST_HOST } from "../edge/passthrough"; + /** Cloud secret bindings that enable host-operated OAuth clients. A provider * is absent unless both values in its pair are present. */ export interface FirstPartyOAuthClientEnv { + readonly VITE_PUBLIC_POSTHOG_KEY?: string; + readonly VITE_PUBLIC_POSTHOG_HOST?: string; readonly FIRST_PARTY_AIRTABLE_CLIENT_ID?: string; readonly FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string; readonly FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string; @@ -286,12 +291,13 @@ export const firstPartyOAuthClientsFor = ( authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", allowedScopes: GOOGLE_ALLOWED_SCOPES, - // Withdrawn from the connect picker: no new connection is offered the - // Executor-owned Google app. The entry stays declared on purpose — every - // connection already minted against it keeps refreshing and reconnecting - // through it. Deleting this block, or unsetting the env vars, would strand - // those connections instead. - unlisted: true, + // Offer the app only to the review rollout. Resolution remains available + // for existing connections regardless of the current listing decision. + isListed: makeGoogleOAuthListing({ + projectKey: env.VITE_PUBLIC_POSTHOG_KEY, + host: env.VITE_PUBLIC_POSTHOG_HOST ?? `https://${POSTHOG_INGEST_HOST}`, + fetch: (input, init) => globalThis.fetch(input, init), + }), }), ...client(env.FIRST_PARTY_HUBSPOT_CLIENT_ID, env.FIRST_PARTY_HUBSPOT_CLIENT_SECRET, { name: "hubspot", diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 355a141aee..7330ad753b 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -243,6 +243,14 @@ export interface FirstPartyOAuthClientConfig { * instead removes the config entry itself, which strands every existing * connection on a client the host can no longer resolve. */ readonly unlisted?: boolean; + /** Optional host policy for offering this app to the acting user. Evaluated + * on each listing; false withholds the app without disrupting existing + * connections. `unlisted: true` always withholds it. This controls discovery, + * not authorization to resolve an already-known first-party client slug. */ + readonly isListed?: (context: { + readonly userId: string | null; + readonly organizationId: string; + }) => Effect.Effect; /** OAuth scopes this deployment permits the app to request. Omit to allow * every scope declared by a matching integration. For declared scopes, * start and completion fail unless every requested scope belongs to this diff --git a/packages/core/sdk/src/oauth-first-party.test.ts b/packages/core/sdk/src/oauth-first-party.test.ts index c1ba29274d..8a2be1cc3d 100644 --- a/packages/core/sdk/src/oauth-first-party.test.ts +++ b/packages/core/sdk/src/oauth-first-party.test.ts @@ -324,13 +324,58 @@ describe("first-party oauth clients", () => { ), ); + it.effect("listing policies use the acting identity and re-evaluate within an executor", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + let enabled = true; + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + subject: "review-user", + tenant: "review-org", + firstPartyOAuthClients: [ + { + ...firstPartyClientFor(server), + isListed: (context) => + Effect.sync(() => { + expect(context).toEqual({ userId: "review-user", organizationId: "review-org" }); + return enabled; + }), + }, + ], + }); + expect((yield* executor.oauth.listClients()).map((client) => String(client.slug))).toEqual([ + "first-party:acme", + ]); + enabled = false; + expect(yield* executor.oauth.listClients()).toEqual([]); + yield* executor.acme.seed(); + const started = yield* executor.oauth.start({ + owner: "org", + client: FIRST_PARTY, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + }), + ), + ); + it.effect("an unlisted first-party app is withheld from listings but still refreshes", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: ["read"] }); const harness = yield* makeTestWorkspaceHarness({ plugins, - firstPartyOAuthClients: [{ ...firstPartyClientFor(server), unlisted: true }], + firstPartyOAuthClients: [ + { + ...firstPartyClientFor(server), + unlisted: true, + isListed: () => Effect.succeed(true), + }, + ], }); const { executor, config } = harness; yield* executor.acme.seed(); diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 802a7342af..dd443144bd 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -1477,19 +1477,25 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // tenant's org rows + this subject's own user rows, so no explicit filter is // needed. The `client_secret` column is deliberately never projected. // ----------------------------------------------------------------------- - const listClients = (): Effect.Effect => { - // First-party apps lead the list: config-resolved, visible to every caller, - // and projected exactly like stored rows — clientId only, never the secret. - // Owner is reported as "org" (the widest visibility the summary shape can - // express); the flow itself ignores owner for first-party slugs. - // - // `unlisted` apps are withheld here and ONLY here: listing is what offers an - // app for a NEW connection, so this is the whole of "stop offering it". - // `loadClient` still resolves them, keeping every existing connection's - // refresh and reconnect intact. - const firstPartySummaries: readonly OAuthClientSummary[] = [...firstPartyBySlug.values()] - .filter((config) => config.unlisted !== true) - .map((config) => ({ + const listClients = (): Effect.Effect => + Effect.gen(function* () { + // First-party apps lead the list: config-resolved, filtered by host policy, + // and projected exactly like stored rows — clientId only, never the secret. + // Owner is reported as "org" (the widest visibility the summary shape can + // express); the flow itself ignores owner for first-party slugs. + // + // `unlisted` apps are withheld here and ONLY here: listing is what offers an + // app for a NEW connection, so this is the whole of "stop offering it". + // `loadClient` still resolves them, keeping every existing connection's + // refresh and reconnect intact. + const listed = yield* Effect.filter([...firstPartyBySlug.values()], (config) => + config.unlisted === true + ? Effect.succeed(false) + : config.isListed === undefined + ? Effect.succeed(true) + : config.isListed({ userId: deps.subject, organizationId: deps.tenant }), + ); + const firstPartySummaries: readonly OAuthClientSummary[] = listed.map((config) => ({ owner: "org", slug: firstPartyOAuthClientSlug(config.name), grant: "authorization_code", @@ -1506,49 +1512,49 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ...(config.allowedScopes !== undefined ? { allowedScopes: config.allowedScopes } : {}), }, })); - return deps.fuma - .use("oauth_client.findMany", (db) => looseDb(db).findMany("oauth_client", {})) - .pipe( - Effect.flatMap((rows) => - Effect.forEach(rows, (row) => { - const grant = parseGrant(row.grant); - // EXPLICIT — a row with an unknown grant is corrupt; surface it - // loudly rather than silently displaying it as authorization_code. - if (grant === null) { - return Effect.fail( - new StorageError({ - message: `oauth_client ${String(row.slug)} has an unknown grant: ${String(row.grant)}`, - cause: undefined, - }), - ); - } - const tokenEndpointAuthMethod = parseStoredTokenEndpointAuthMethod( - row.token_endpoint_auth_method, - ); - if (tokenEndpointAuthMethod === null) { - return Effect.fail( - new StorageError({ - message: `oauth_client ${String(row.slug)} has an unknown token endpoint auth method: ${String(row.token_endpoint_auth_method)}`, - cause: undefined, - }), + return yield* deps.fuma + .use("oauth_client.findMany", (db) => looseDb(db).findMany("oauth_client", {})) + .pipe( + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => { + const grant = parseGrant(row.grant); + // EXPLICIT — a row with an unknown grant is corrupt; surface it + // loudly rather than silently displaying it as authorization_code. + if (grant === null) { + return Effect.fail( + new StorageError({ + message: `oauth_client ${String(row.slug)} has an unknown grant: ${String(row.grant)}`, + cause: undefined, + }), + ); + } + const tokenEndpointAuthMethod = parseStoredTokenEndpointAuthMethod( + row.token_endpoint_auth_method, ); - } - return Effect.succeed({ - owner: String(row.owner) as Owner, - slug: OAuthClientSlug.make(String(row.slug)), - grant, - authorizationUrl: String(row.authorization_url), - tokenUrl: String(row.token_url), - resource: row.resource == null ? null : String(row.resource), - clientId: String(row.client_id), - ...(tokenEndpointAuthMethod === undefined ? {} : { tokenEndpointAuthMethod }), - origin: parseOAuthClientOrigin(row), - } satisfies OAuthClientSummary); - }), - ), - Effect.map((stored) => [...firstPartySummaries, ...stored]), - ); - }; + if (tokenEndpointAuthMethod === null) { + return Effect.fail( + new StorageError({ + message: `oauth_client ${String(row.slug)} has an unknown token endpoint auth method: ${String(row.token_endpoint_auth_method)}`, + cause: undefined, + }), + ); + } + return Effect.succeed({ + owner: String(row.owner) as Owner, + slug: OAuthClientSlug.make(String(row.slug)), + grant, + authorizationUrl: String(row.authorization_url), + tokenUrl: String(row.token_url), + resource: row.resource == null ? null : String(row.resource), + clientId: String(row.client_id), + ...(tokenEndpointAuthMethod === undefined ? {} : { tokenEndpointAuthMethod }), + origin: parseOAuthClientOrigin(row), + } satisfies OAuthClientSummary); + }), + ), + Effect.map((stored) => [...firstPartySummaries, ...stored]), + ); + }); // ----------------------------------------------------------------------- // Load an oauth_client row by (owner, slug).