Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion apps/cloud/src/engine/execution-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
collectTables,
} from "@executor-js/api/server";
import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
import type { AnyPlugin } from "@executor-js/sdk";
import type { AnyPlugin, FirstPartyOAuthClientConfig } from "@executor-js/sdk";

import executorConfig from "../../executor.config";
import { DbService } from "../db/db";
Expand Down Expand Up @@ -88,6 +88,35 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed(
*/
export const CLOUD_MOUNT_PREFIX = "/api" as const;

// Executor-owned provider apps, enabled per provider by setting BOTH env vars
// (id + secret). Each provider-side registration must list
// `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug
// travels inside OAuth `state`, so the single static callback serves every org.
const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [
...(env.FIRST_PARTY_GITHUB_CLIENT_ID && env.FIRST_PARTY_GITHUB_CLIENT_SECRET
? [
{
name: "github",
authorizationUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID,
clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET,
},
]
: []),
...(env.FIRST_PARTY_GOOGLE_CLIENT_ID && env.FIRST_PARTY_GOOGLE_CLIENT_SECRET
? [
{
name: "google",
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID,
clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET,
},
]
: []),
];

export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({
// SSRF / private-network egress guard. Config-driven, NOT a test flag:
// production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);
Expand All @@ -99,6 +128,7 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, (
// WorkOS Vault is cloud's credential storage implementation detail, not a
// user-selectable provider surface.
exposeCredentialProviders: false,
firstPartyOAuthClients: cloudFirstPartyOAuthClients(),
}));

export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync(
Expand Down
10 changes: 10 additions & 0 deletions apps/cloud/src/env-augment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ declare global {
// number to drive the backstop. Production leaves it unset.
EXECUTION_RATE_LIMIT_PER_HOUR?: string;

// First-party OAuth apps (executor-owned provider registrations). Each
// pair enables one-click connect through `first-party:<provider>`; an
// unset pair simply ships no first-party app for that provider. The
// registered callback on the provider side must be
// `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`.
FIRST_PARTY_GITHUB_CLIENT_ID?: string;
FIRST_PARTY_GITHUB_CLIENT_SECRET?: string;
FIRST_PARTY_GOOGLE_CLIENT_ID?: string;
FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;

// Billing
AUTUMN_SECRET_KEY?: string;
/** Optional Autumn base-URL override (Autumn emulator in tests/dev). */
Expand Down
135 changes: 135 additions & 0 deletions e2e/scenarios/first-party-oauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// First-party OAuth clients: the cloud host declares executor-owned apps via
// env (`FIRST_PARTY_GITHUB_CLIENT_ID/SECRET`, set by the e2e cloud boot), and
// every org can connect through them with nothing to paste. Three guarantees:
//
// 1. Listing: `oauth.listClients` surfaces `first-party:github` with a
// `first_party` origin and its public client id — no create call ever ran.
// 2. Flow: `oauth.start` through the first-party slug redirects to the
// provider's authorize endpoint carrying the env-configured client id and
// this platform's `/api/oauth/callback` — proof the config-resolved
// identity (not a stored row) drives the flow. The redirect is asserted,
// never followed: github.com is not visited.
// 3. Guardrails: the reserved `first-party:` namespace is rejected by
// createClient, so no org can shadow the host's app with its own row.
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 { scenario } from "../src/scenario";
import { Api, Target } from "../src/services";

const api = composePluginApi([openApiHttpPlugin()] as const);

const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;

/** A minimal integration whose OAuth template points at GitHub's endpoints, so
* the first-party `first-party:github` app is the matching client for it. */
const githubShapedIntegrationSpec = {
spec: {
kind: "blob" as const,
value: JSON.stringify({
openapi: "3.0.3",
info: { title: "GitHub-shaped API", version: "1.0.0" },
paths: {
"/user": {
get: {
operationId: "getUser",
tags: ["default"],
responses: { "200": { description: "the caller" } },
},
},
},
}),
},
baseUrl: "https://api.github.com",
authenticationTemplate: [
{
slug: "oauth",
kind: "oauth2" as const,
authorizationUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
scopes: ["repo", "read:org"],
},
],
} as const;

scenario(
"First-party OAuth · the host-declared GitHub app is listed and drives the authorize redirect",
{},
Effect.scoped(
Effect.gen(function* () {
const target = yield* Target;
const { client: makeApiClient } = yield* Api;
const identity = yield* target.newIdentity();
const client = yield* makeApiClient(api, identity);

// 1. The config-declared app appears in listings with its public id.
const clients = yield* client.oauth.listClients();
const firstParty = clients.find((c) => String(c.slug) === "first-party:github");
expect(firstParty, "the env-declared first-party GitHub app is listed").toBeDefined();
expect(firstParty?.origin.kind).toBe("first_party");
expect(firstParty?.clientId).toBe("e2e-first-party-github");

// 2. A start through the first-party slug builds GitHub's authorize URL
// from the config identity and this platform's served callback.
const integration = IntegrationSlug.make(unique("fpgh"));
yield* client.openapi.addSpec({
payload: { ...githubShapedIntegrationSpec, slug: integration },
});
const started = yield* client.oauth.start({
payload: {
client: OAuthClientSlug.make("first-party:github"),
clientOwner: "org",
owner: "org",
name: ConnectionName.make("main"),
integration,
template: AuthTemplateSlug.make("oauth"),
},
});
expect(started.status, "oauth.start redirects to the provider").toBe("redirect");
const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : "";
const authorize = new URL(authorizationUrl);
expect(authorize.origin + authorize.pathname).toBe(
"https://github.com/login/oauth/authorize",
);
expect(authorize.searchParams.get("client_id")).toBe("e2e-first-party-github");
expect(authorize.searchParams.get("redirect_uri")).toBe(
new URL("/api/oauth/callback", target.baseUrl).toString(),
);

// 3. The reserved namespace cannot be shadowed by a stored row. The
// server rejects with a StorageError, which the HTTP edge scrubs to an
// opaque InternalError — assert the rejection, then prove the listed
// app is still the config-declared one (same public id, same origin).
yield* client.oauth
.createClient({
payload: {
owner: "org",
slug: OAuthClientSlug.make("first-party:github"),
authorizationUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
grant: "authorization_code",
clientId: "impostor",
clientSecret: "impostor-secret",
},
})
.pipe(Effect.flip);
const after = yield* client.oauth.listClients();
const survivors = after.filter((c) => String(c.slug) === "first-party:github");
expect(survivors, "exactly one first-party:github remains listed").toHaveLength(1);
expect(survivors[0]?.origin.kind).toBe("first_party");
expect(survivors[0]?.clientId, "the impostor never shadowed the host's app").toBe(
"e2e-first-party-github",
);
}),
),
);
5 changes: 5 additions & 0 deletions e2e/setup/cloud.boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted>
MCP_SESSION_TIMEOUT_MS: process.env.MCP_SESSION_TIMEOUT_MS,
MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: process.env.MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS,
ALLOW_LOCAL_NETWORK: "true",
// A first-party GitHub app for the first-party-oauth scenario: proves the
// env → HostConfig → executor plumbing end to end. The scenario asserts the
// authorize REDIRECT only (client id + callback), never visits github.com.
FIRST_PARTY_GITHUB_CLIENT_ID: "e2e-first-party-github",
FIRST_PARTY_GITHUB_CLIENT_SECRET: "e2e-first-party-github-secret",
// Shrink the per-org hourly execution cap (prod default 1000) to a number
// the rate-limit-backstop scenario can actually exhaust with real
// executions — but see execution-limits.ts: it must stay above every other
Expand Down
7 changes: 7 additions & 0 deletions packages/core/api/src/oauth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ const OAuthClientSummaryResponse = Schema.Struct({
kind: Schema.Literal("dynamic_client_registration"),
integration: Schema.optional(Schema.NullOr(IntegrationSlug)),
}),
/** Host-operated app declared in executor config — every org connects
* through it; nothing to paste. `integrations` ranks it as the default
* for those integrations in the picker. */
Schema.Struct({
kind: Schema.Literal("first_party"),
integrations: Schema.optional(Schema.Array(IntegrationSlug)),
}),
]),
});

Expand Down
10 changes: 10 additions & 0 deletions packages/core/api/src/server/scoped-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
Tenant,
type AnyPlugin,
type Executor,
type FirstPartyOAuthClientConfig,
type StorageFailure,
} from "@executor-js/sdk";
import {
Expand Down Expand Up @@ -90,6 +91,14 @@ export interface HostConfigShape {
* detail of credential storage.
*/
readonly exposeCredentialProviders?: boolean;
/**
* Host-operated OAuth apps (`first-party:<name>`), threaded verbatim into
* `createExecutor`. Declared here — not per-request — because the registered
* redirect URI on the provider side is fixed per deployment, and both request
* planes (HTTP API, MCP session DO) must resolve the same apps. Hosts that
* ship none simply omit it.
*/
readonly firstPartyOAuthClients?: readonly FirstPartyOAuthClientConfig[];
}

export class HostConfig extends Context.Service<HostConfig, HostConfigShape>()(
Expand Down Expand Up @@ -279,6 +288,7 @@ export const makeScopedExecutor = <
onElicitation: "accept-all",
redirectUri,
oauthCallbackStateOrgSlug: orgSlug,
firstPartyOAuthClients: config.firstPartyOAuthClients,
coreTools: {
webBaseUrl,
orgSlug,
Expand Down
Loading