From 55e20555620d40d3314ce2ef4bf68e7d711d7b68 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Wed, 10 Jun 2026 06:41:16 -0700 Subject: [PATCH] Surface the OAuth callback URL when registering an app; derive it from mountPrefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connect flow's OAuth app form now shows the callback URL a user must allow-list on their provider (authorization-code grant), with a copy button. The value comes from the same helper handed to oauth.start and DCR registration, so what's shown is exactly what the flow sends. Make the callback path a single source of truth: ExecutorApp.make derives `${mountPrefix}/oauth/callback` from the same mountPrefix that prefixes the API router and injects it into the scoped executor's HostConfig. Hosts no longer set the callback path separately, so it can't drift from the route that serves it. This fixes self-host and the Cloudflare host, which mount the API under /api but never set the callback path — their redirect URI dropped the prefix and would 404 on return, never matching the provider's registered redirect. Add an e2e scenario (cloud + self-host) asserting the authorization-code flow redirects to ${baseUrl}/api/oauth/callback and that the registration form displays that exact URL. --- apps/cloud/src/engine/execution-stack.ts | 13 +- e2e/scenarios/oauth-callback-url.test.ts | 169 ++++++++++++++++++ packages/core/api/src/server/executor-app.ts | 26 ++- .../core/api/src/server/scoped-executor.ts | 10 +- .../src/components/oauth-client-form.tsx | 31 ++++ 5 files changed, 234 insertions(+), 15 deletions(-) create mode 100644 e2e/scenarios/oauth-callback-url.test.ts diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index e9e977e03b..af33be5c96 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -69,9 +69,9 @@ export const CloudPluginsProvider: Layer.Layer = Layer.succeed( /** * The path prefix the cloud mounts its typed API under. SINGLE SOURCE OF TRUTH: * `app.ts` passes this as `ExecutorApp.make({ config: { mountPrefix } })`, and - * `CloudHostConfig.oauthCallbackPath` derives the OAuth callback from it so the - * redirect URI the host sends to providers (`${webBaseUrl}${CLOUD_MOUNT_PREFIX}/oauth/callback`) - * always matches the route that actually serves the callback. + * `make` derives the OAuth callback (`${webBaseUrl}${CLOUD_MOUNT_PREFIX}/oauth/callback`) + * from that same `mountPrefix`, so the redirect URI the host sends to providers + * always matches the route that actually serves the callback — no second knob. */ export const CLOUD_MOUNT_PREFIX = "/api" as const; @@ -82,10 +82,9 @@ export const CloudHostConfig: Layer.Layer = Layer.sync(HostConfig, ( // with `"true"` so fixtures can reach localhost. See `hosted-http-client.ts`. allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", - // The cloud serves the API (incl. the global `/oauth/callback`) under - // `${CLOUD_MOUNT_PREFIX}`, so the OAuth redirect URI MUST carry that prefix or - // it 404s on return and won't match the provider's registered redirect URI. - oauthCallbackPath: `${CLOUD_MOUNT_PREFIX}/oauth/callback`, + // `oauthCallbackPath` is NOT set here — `ExecutorApp.make` derives it from the + // `mountPrefix` cloud passes (`CLOUD_MOUNT_PREFIX`), so the callback can't drift + // from the route that serves it. // WorkOS Vault is cloud's credential storage implementation detail, not a // user-selectable provider surface. exposeCredentialProviders: false, diff --git a/e2e/scenarios/oauth-callback-url.test.ts b/e2e/scenarios/oauth-callback-url.test.ts new file mode 100644 index 0000000000..b6e5f3d721 --- /dev/null +++ b/e2e/scenarios/oauth-callback-url.test.ts @@ -0,0 +1,169 @@ +// The OAuth callback URL a user must allow-list on their provider, surfaced when +// they register an OAuth app. Two guarantees: +// +// 1. Accuracy (every target): the callback the authorization-code flow sends +// to the provider is `${origin}/api/oauth/callback` — the URL the form +// shows. Run on cloud + self-host so the per-platform mount prefix is +// proven, not assumed. `ExecutorApp.make` derives this path from the same +// `mountPrefix` that mounts the API, so omitting a per-host knob can no +// longer drift it (it previously 404'd on prefix-mounted self-host). +// 2. Existence (cloud, browser): registering an OAuth app in the connect +// modal renders that exact URL with a copy affordance, so a user can find +// and use it. +// +// The form's value and the flow's `redirect_uri` come from the SAME helper +// (`oauthCallbackUrl()` → `${window.location.origin}/api/oauth/callback`), so +// asserting the flow uses `${baseUrl}/api/oauth/callback` is asserting the +// displayed URL is the real one. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; +import { serveOAuthTestServer } from "@executor-js/sdk/testing"; + +import { scenario } from "../src/scenario"; + +const api = composePluginApi([openApiHttpPlugin()] as const); + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +/** An OpenAPI integration that declares an OAuth (authorization-code) method + * pointed at `oauth`, so the connect modal treats it as an OAuth integration + * and a `start` flow has something to attach its connection to. */ +const oauthIntegrationSpec = (oauth: { + readonly authorizationEndpoint: string; + readonly tokenEndpoint: string; +}) => + ({ + spec: { + kind: "blob" as const, + value: JSON.stringify({ + openapi: "3.0.3", + info: { title: "OAuth-protected API", version: "1.0.0" }, + paths: { + "/me": { + get: { + operationId: "getMe", + tags: ["default"], + responses: { "200": { description: "the caller" } }, + }, + }, + }, + }), + }, + baseUrl: "http://127.0.0.1:59999", + authenticationTemplate: [ + { + slug: "oauth", + type: "oauth" as const, + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + scopes: ["read"], + }, + ], + }) as const; + +scenario( + "OAuth · the authorization-code flow redirects to this platform's /api/oauth/callback", + { needs: ["api"] }, + (ctx) => + Effect.scoped( + Effect.gen(function* () { + const oauth = yield* serveOAuthTestServer(); + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(api, identity); + + // What the registration form shows for THIS target — the same value the + // React `oauthCallbackUrl()` helper resolves from `window.location`. + const expectedCallback = new URL("/api/oauth/callback", ctx.target.baseUrl).toString(); + + const integration = IntegrationSlug.make(unique("cburlint")); + yield* client.openapi.addSpec({ + payload: { ...oauthIntegrationSpec(oauth), slug: integration }, + }); + + const clientSlug = OAuthClientSlug.make(unique("cburlc")); + yield* client.oauth.createClient({ + payload: { + owner: "org", + slug: clientSlug, + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }, + }); + + // start WITHOUT a redirectUri — the platform falls back to its OWN + // configured callback, which is exactly what the form would have shown. + const started = yield* client.oauth.start({ + payload: { + client: clientSlug, + clientOwner: "org", + owner: "org", + name: ConnectionName.make("main"), + integration, + template: AuthTemplateSlug.make("oauth"), + }, + }); + expect( + started.status, + "oauth.start hands back a redirect to the authorization server", + ).toBe("redirect"); + const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : ""; + + const redirectUri = new URL(authorizationUrl).searchParams.get("redirect_uri"); + expect( + redirectUri, + "the authorization request redirects to this platform's served callback", + ).toBe(expectedCallback); + }), + ), +); + +scenario( + "OAuth · registering an app in the connect modal shows the callback URL to allow-list", + { needs: ["browser"] }, + (ctx) => + Effect.gen(function* () { + const oauth = yield* serveOAuthTestServer(); + const identity = yield* ctx.target.newIdentity(); + const client = yield* ctx.api.client(api, identity); + const expectedCallback = new URL("/api/oauth/callback", ctx.target.baseUrl).toString(); + + // An OAuth integration with no registered app yet, so the connect modal + // offers the "Register app" CTA (no automatic registration to short-circuit + // it). + const integration = IntegrationSlug.make(unique("cburlui")); + yield* client.openapi.addSpec({ + payload: { ...oauthIntegrationSpec(oauth), slug: integration }, + }); + + yield* ctx.browser.session(identity, async ({ page, step }) => { + await step("Open the connect modal for an OAuth integration", async () => { + await page.goto(`/integrations/${String(integration)}?addAccount=1`, { + waitUntil: "networkidle", + }); + await page.getByRole("button", { name: "Register app", exact: true }).click(); + }); + + await step("The OAuth app form shows this platform's callback URL", async () => { + const callback = page.locator("#oauth-callback-url"); + await callback.waitFor(); + const shown = (await callback.textContent())?.trim(); + expect(shown, "the displayed callback URL matches the platform's served callback").toBe( + expectedCallback, + ); + }); + }); + }).pipe(Effect.scoped), +); diff --git a/packages/core/api/src/server/executor-app.ts b/packages/core/api/src/server/executor-app.ts index 622412df21..68dbabccf2 100644 --- a/packages/core/api/src/server/executor-app.ts +++ b/packages/core/api/src/server/executor-app.ts @@ -44,7 +44,8 @@ import { Effect, Layer } from "effect"; import type { AnyPlugin } from "@executor-js/sdk"; import type { DbProvider } from "./executor-fuma-db"; -import type { HostConfig, PluginsProvider } from "./scoped-executor"; +import { HostConfig } from "./scoped-executor"; +import type { PluginsProvider } from "./scoped-executor"; import { requestScopedMiddleware } from "./request-scoped"; import { McpServingRoutes, @@ -386,6 +387,18 @@ export const make = < ) : undefined; + // ---- the OAuth callback path, derived from the SAME `mountPrefix` ------ + // The redirect URI the host serves and registers with providers is + // `${webBaseUrl}${mountPrefix}/oauth/callback` — the prefix that mounts the + // API (above) joined with the global `/oauth/callback` route. Deriving it here, + // the one place `mountPrefix` is known, makes the prefix a single source of + // truth: a host states it once via `config.mountPrefix` and cannot drift it + // out of sync with a second hand-written `oauthCallbackPath` (the omission that + // silently 404'd the redirect on every prefix-mounted host). Injected into the + // scoped executor's `HostConfig` below; the fixed model (local) sets its own + // `redirectUri` and is unaffected. + const oauthCallbackPath = `${prefix ?? ""}/oauth/callback`; + // ---- (2) the ExecutionStackMiddleware --------------------------------- // The identity seam authenticates; the failure strategy renders; the stack // Layer + plugin tuple build the per-request executor. The facade ALWAYS builds @@ -459,11 +472,18 @@ export const make = < strategy: config.failure, // db + plugins.provider + plugins.config + engine.codeExecutor + // engine.decorator (default no-op). The merged Layer leaves the - // boot-scoped `RDb` residual, satisfied by `boot` below. + // boot-scoped `RDb` residual, satisfied by `boot` below. The host's + // `HostConfig` is decorated with the derived `oauthCallbackPath` so the + // callback always tracks `mountPrefix`. stackLayer: Layer.mergeAll( providers.db, providers.plugins.provider, - providers.plugins.config, + Layer.effect(HostConfig)( + Effect.gen(function* () { + const hostConfig = yield* HostConfig; + return { ...hostConfig, oauthCallbackPath }; + }), + ).pipe(Layer.provide(providers.plugins.config)), providers.engine.codeExecutor, providers.engine.decorator ?? EngineDecoratorNoop, ) as Layer.Layer< diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 25fd5558db..d9c6806264 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -72,11 +72,11 @@ export interface HostConfigShape { * (packages/core/api/src/oauth/api.ts). The redirect URI sent to providers is * `${webBaseUrl}${oauthCallbackPath}`. * - * Defaults to `/oauth/callback` (correct for a host that serves the typed API - * at root, e.g. local). A host that mounts the API under a prefix MUST set this - * to `${mountPrefix}/oauth/callback` (cloud: `/api/oauth/callback`) — otherwise - * the redirect URI omits the prefix, so it 404s on return and never matches - * what the provider has registered. + * Hosts do NOT set this: `ExecutorApp.make` derives it from the same + * `config.mountPrefix` that prefixes the API router and injects it here, so the + * callback can't drift from the route that serves it. It stays optional only + * for the low-level `makeScopedExecutor` test seam (constructed without + * `make`), where it defaults to `/oauth/callback` (the root-mount path). */ readonly oauthCallbackPath?: string; /** diff --git a/packages/react/src/components/oauth-client-form.tsx b/packages/react/src/components/oauth-client-form.tsx index a7c2cab73f..5a2b3f7cc4 100644 --- a/packages/react/src/components/oauth-client-form.tsx +++ b/packages/react/src/components/oauth-client-form.tsx @@ -16,6 +16,7 @@ import { normalizeConnectionOwner, } from "../plugins/connection-owner"; import { Button } from "./button"; +import { CopyButton } from "./copy-button"; import { Input } from "./input"; import { Label } from "./label"; import { RadioGroup, RadioGroupItem } from "./radio-group"; @@ -85,6 +86,13 @@ export function OAuthClientForm(props: { [organizationId], ); + // The browser-facing callback the OAuth flow uses (this host's + // `${origin}/api/oauth/callback`). It is the SAME value handed to `oauth.start` + // and to DCR registration below, so showing it here is exactly the redirect a + // user must allow-list on their OAuth app. Resolved from `window.location` so + // it is automatically correct per platform (cloud / self-host / local). + const callbackUrl = useMemo(() => oauthCallbackUrl(), []); + // Explicit create-time choice (no ambient owner). Default Workspace (`org`) on // an org host, Local (`org`) on a non-org host, or the locked owner when // editing. @@ -325,6 +333,29 @@ export function OAuthClientForm(props: { ) : null} + {/* callback URL — the redirect the authorization-code flow uses. Show it + so the user can allow-list it on their OAuth app. Client-credentials + has no browser redirect, so it is hidden for that grant. */} + {grant === "authorization_code" ? ( +
+ +
+ + {callbackUrl} + + +
+
+ ) : null} + {/* client id / secret */}