From 9e334c7261a69fa48f985c376809dd913e0d83d2 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:24:38 -0700 Subject: [PATCH] Add first-party OAuth clients (host-operated GitHub/Google apps) --- apps/cloud/src/engine/execution-stack.ts | 32 +- apps/cloud/src/env-augment.d.ts | 10 + e2e/scenarios/first-party-oauth.test.ts | 135 ++++++++ e2e/setup/cloud.boot.ts | 5 + packages/core/api/src/oauth/api.ts | 7 + .../core/api/src/server/scoped-executor.ts | 10 + packages/core/sdk/src/executor.ts | 86 ++++-- packages/core/sdk/src/index.ts | 5 + packages/core/sdk/src/oauth-client.ts | 49 ++- .../core/sdk/src/oauth-first-party.test.ts | 290 ++++++++++++++++++ packages/core/sdk/src/oauth-service.ts | 99 +++++- packages/core/sdk/src/shared.ts | 4 + packages/core/sdk/src/test-config.ts | 2 + packages/plugins/openapi/src/sdk/presets.ts | 16 + .../src/components/add-account-modal.tsx | 15 +- .../use-effective-oauth-client.test.ts | 59 ++++ .../plugins/use-effective-oauth-client.tsx | 43 ++- 17 files changed, 826 insertions(+), 41 deletions(-) create mode 100644 e2e/scenarios/first-party-oauth.test.ts create mode 100644 packages/core/sdk/src/oauth-first-party.test.ts diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index 869bf5816..b5cb11e05 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -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"; @@ -88,6 +88,35 @@ export const CloudPluginsProvider: Layer.Layer = 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 = 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`); @@ -99,6 +128,7 @@ export const CloudHostConfig: Layer.Layer = 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 = Layer.sync( diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 4ef130bfa..9c8dee7b7 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -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:`; 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). */ diff --git a/e2e/scenarios/first-party-oauth.test.ts b/e2e/scenarios/first-party-oauth.test.ts new file mode 100644 index 000000000..36541f7d1 --- /dev/null +++ b/e2e/scenarios/first-party-oauth.test.ts @@ -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", + ); + }), + ), +); diff --git a/e2e/setup/cloud.boot.ts b/e2e/setup/cloud.boot.ts index 458e0f368..026ef6887 100644 --- a/e2e/setup/cloud.boot.ts +++ b/e2e/setup/cloud.boot.ts @@ -93,6 +93,11 @@ export const bootCloud = async (options: CloudBootOptions): Promise 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 diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index dcb71ccd9..24be6cb2d 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -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)), + }), ]), }); diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 8f884760a..b89d10e3d 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -40,6 +40,7 @@ import { Tenant, type AnyPlugin, type Executor, + type FirstPartyOAuthClientConfig, type StorageFailure, } from "@executor-js/sdk"; import { @@ -90,6 +91,14 @@ export interface HostConfigShape { * detail of credential storage. */ readonly exposeCredentialProviders?: boolean; + /** + * Host-operated OAuth apps (`first-party:`), 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()( @@ -279,6 +288,7 @@ export const makeScopedExecutor = < onElicitation: "accept-all", redirectUri, oauthCallbackStateOrgSlug: orgSlug, + firstPartyOAuthClients: config.firstPartyOAuthClients, coreTools: { webBaseUrl, orgSlug, diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 7ba62b5bb..251893699 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -92,7 +92,8 @@ import { type MintOAuthConnectionInput, type OAuthScopePolicy, } from "./oauth-service"; -import type { OAuthService } from "./oauth-client"; +import { isFirstPartyOAuthClientSlug, type OAuthService } from "./oauth-client"; +import type { FirstPartyOAuthClientConfig } from "./oauth-client"; import { comparePolicyRow, isValidPattern, @@ -581,6 +582,14 @@ export interface ExecutorConfig`. Users connect through them with + * nothing to paste. Config-resolved — never persisted; secrets stay in host + * env and are never written to a credential provider or returned over any + * read surface. Minted connections and their tokens remain per-owner. + */ + readonly firstPartyOAuthClients?: readonly FirstPartyOAuthClientConfig[]; /** * Enable the built-in `core-tools` plugin which contributes agent-facing * static tools over the v2 surface (integrations / connections / policies). @@ -1687,6 +1696,23 @@ export const createExecutor = b.and(byOwner(owner)(b), b("slug", "=", slug)), }); + // Config-declared first-party apps, keyed by prefixed slug — the refresh + // path's counterpart to the OAuth service's config-first resolution. + const firstPartyOAuthBySlug = new Map( + (config.firstPartyOAuthClients ?? []).map((client) => [`first-party:${client.name}`, client]), + ); + + /** The app identity a refresh runs against, uniformly resolved: a stored + * row's secret comes out of the credential provider by item id; a + * first-party app's comes from host config and never touches a provider. */ + interface RefreshClient { + readonly clientId: string; + readonly clientSecret: string; + readonly tokenUrl: string; + readonly grant: string; + readonly resource: string | null; + } + /** What drove a refresh: the pre-call expiry check (`proactive`), or an * upstream 401 on a token we believed was still valid (`reactive`). */ type RefreshTrigger = "proactive" | "reactive"; @@ -1708,19 +1734,42 @@ export const createExecutor = + slug.startsWith(FIRST_PARTY_OAUTH_CLIENT_PREFIX); + +export const firstPartyOAuthClientSlug = (name: string): OAuthClientSlug => + OAuthClientSlug.make(`${FIRST_PARTY_OAUTH_CLIENT_PREFIX}${name}`); + +/** A first-party OAuth app the HOST declares at composition time — the + * deployment operator's own registered app for a provider. The secret comes + * from host env/config and stays in memory: it is never written to a + * credential provider and never surfaced over any read surface. Minted + * connections (and their tokens) remain per-owner exactly as with BYO apps; + * only the app identity is shared. */ +export interface FirstPartyOAuthClientConfig { + /** Unprefixed name, e.g. `"github"`; addressed as `first-party:github`. */ + readonly name: string; + readonly authorizationUrl: string; + readonly tokenUrl: string; + readonly clientId: string; + /** Literal secret from host env. Empty string for a public/PKCE client. */ + readonly clientSecret: string; + /** Integrations this app is intended for, used by pickers to rank it as the + * exact-match default for those integrations. Endpoint-host matching still + * applies when omitted. */ + readonly integrations?: readonly IntegrationSlug[]; +} + export type CreateOAuthClientInput = OAuthClient & { - readonly origin?: OAuthClientOrigin; + /** Stored-row origins only — `first_party` is config-declared, never created + * through this surface (the service also rejects the slug namespace). */ + readonly origin?: Exclude; readonly originIssuer?: string | null; /** The redirect URI a DCR registration sent as the client's `redirect_uris` * entry. Persisted so reuse can detect a changed callback (strict servers diff --git a/packages/core/sdk/src/oauth-first-party.test.ts b/packages/core/sdk/src/oauth-first-party.test.ts new file mode 100644 index 000000000..3ce934c07 --- /dev/null +++ b/packages/core/sdk/src/oauth-first-party.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + ToolAddress, + ToolName, +} from "./ids"; +import { + firstPartyOAuthClientSlug, + type FirstPartyOAuthClientConfig, + type OAuthStartError, +} from "./oauth-client"; +import { definePlugin } from "./plugin"; +import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; +import { serveOAuthTestServer } from "./testing/oauth-test-server"; + +// First-party OAuth clients: host-operated apps declared in executor config +// (`firstPartyOAuthClients`), addressed as `first-party:`. Resolved from +// config, never storage — these tests prove the whole lifecycle (start → +// complete → execute → refresh) runs off the config-declared identity, that the +// client CRUD surface rejects the reserved namespace, and that listings project +// the app without ever having written its secret to a credential provider. + +const INTEG = IntegrationSlug.make("acme"); +const TEMPLATE = AuthTemplateSlug.make("oauth"); +const FIRST_PARTY = firstPartyOAuthClientSlug("acme"); + +const oauthPlugin = definePlugin(() => ({ + id: "acme" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [{ name: ToolName.make("whoami"), description: "whoami" }], + }), + describeAuthMethods: (record) => { + const config = record.config as { readonly scopes?: readonly string[] } | null; + return [ + { + id: "oauth", + label: "OAuth2", + kind: "oauth" as const, + template: String(TEMPLATE), + oauth: { scopes: config?.scopes ?? [] }, + }, + ]; + }, + invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), + checkHealth: ({ credential }) => + Effect.succeed({ + status: credential.value === null ? "expired" : "healthy", + checkedAt: Date.now(), + }), + extension: (ctx) => ({ + seed: (scopes: readonly string[] = []) => + ctx.core.integrations.register({ + slug: INTEG, + description: "Acme", + config: { scopes }, + }), + }), +}))(); + +const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; + +const firstPartyClientFor = (server: { + readonly authorizationEndpoint: string; + readonly tokenEndpoint: string; +}): FirstPartyOAuthClientConfig => ({ + name: "acme", + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + clientId: "test-client", + clientSecret: "test-secret", + integrations: [INTEG], +}); + +describe("first-party oauth clients", () => { + it.effect( + "start → complete through a config-declared client mints an executable connection", + () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [firstPartyClientFor(server)], + }); + yield* executor.acme.seed(); + + // No createClient call — the app exists purely in config. + const started = yield* executor.oauth.start({ + owner: "org", + client: FIRST_PARTY, + clientOwner: "org", + name: ConnectionName.make("main-account"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + const connection = yield* executor.oauth.complete({ + state: started.state, + code: callback.code, + }); + expect(String(connection.address)).toBe("tools.acme.org.mainAccount"); + + const out = (yield* executor.execute( + ToolAddress.make("tools.acme.org.mainAccount.whoami"), + {}, + )) as { token: string }; + expect(out.token).toMatch(/^at_/); + expect(yield* server.acceptsAccessToken(out.token)).toBe(true); + }), + ), + ); + + it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const harness = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [firstPartyClientFor(server)], + }); + const { executor, config } = harness; + 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, + }); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + yield* executor.oauth.complete({ state: started.state, code: callback.code }); + + const firstToken = (yield* executor.execute( + ToolAddress.make("tools.acme.org.main.whoami"), + {}, + )) as { token: string }; + + // Force expiry so the next resolve refreshes through the config client. + yield* Effect.promise(() => + config.db.updateMany("connection", { + where: (b) => b("name", "=", "main"), + set: { expires_at: Date.now() - 60_000 }, + }), + ); + + const refreshedToken = (yield* executor.execute( + ToolAddress.make("tools.acme.org.main.whoami"), + {}, + )) as { token: string }; + expect(refreshedToken.token).not.toBe(firstToken.token); + expect(yield* server.acceptsAccessToken(refreshedToken.token)).toBe(true); + }), + ), + ); + + it.effect("listClients projects the first-party app ahead of stored rows, secretless", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [firstPartyClientFor(server)], + }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: OAuthClientSlug.make("byo-app"), + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "byo-client", + clientSecret: "byo-secret", + }); + + const clients = yield* executor.oauth.listClients(); + expect(clients.map((c) => String(c.slug))).toEqual(["first-party:acme", "byo-app"]); + const firstParty = clients[0]!; + expect(firstParty.origin).toEqual({ kind: "first_party", integrations: [INTEG] }); + expect(firstParty.clientId).toBe("test-client"); + expect("clientSecret" in firstParty).toBe(false); + }), + ), + ); + + it.effect("createClient and removeClient reject the reserved first-party namespace", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [firstPartyClientFor(server)], + }); + + const createError = yield* executor.oauth + .createClient({ + owner: "org", + slug: FIRST_PARTY, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "impostor", + clientSecret: "impostor-secret", + }) + .pipe(Effect.flip); + expect(createError.message).toContain("reserved first-party namespace"); + + const removeError = yield* executor.oauth + .removeClient("org", FIRST_PARTY) + .pipe(Effect.flip); + expect(removeError.message).toContain("cannot be removed"); + }), + ), + ); + + it.effect("start with an undeclared first-party slug fails as client-not-found", () => + Effect.scoped( + Effect.gen(function* () { + yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + const error = yield* executor.oauth + .start({ + owner: "org", + client: firstPartyOAuthClientSlug("nope"), + clientOwner: "org", + name: ConnectionName.make("main"), + integration: INTEG, + template: TEMPLATE, + }) + .pipe(Effect.flip); + // `OAuthStartError` carries a typed `message`; the `Predicate.isTagged` + // guard narrows the union so this read is on a typed failure. + expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true); + const startError = error as OAuthStartError; + expect(startError.message).toContain("not found"); + }), + ), + ); + + it.effect("a Personal connection can mint through a first-party app", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + firstPartyOAuthClients: [firstPartyClientFor(server)], + }); + yield* executor.acme.seed(); + + const started = yield* executor.oauth.start({ + owner: "user", + client: FIRST_PARTY, + clientOwner: "org", + name: ConnectionName.make("mine"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + const connection = yield* executor.oauth.complete({ + state: started.state, + code: callback.code, + }); + expect(String(connection.address)).toBe("tools.acme.user.mine"); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 1db71cadf..adaa1f310 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -35,8 +35,11 @@ import { OAuthRegisterDynamicError, OAuthSessionNotFoundError, OAuthStartError, + firstPartyOAuthClientSlug, + isFirstPartyOAuthClientSlug, type ConnectResult, type CreateOAuthClientInput, + type FirstPartyOAuthClientConfig, type OAuthClientOrigin, type OAuthClientSummary, type OAuthCompleteInput, @@ -173,6 +176,12 @@ export interface OAuthServiceDeps { readonly redirectUri: string | null; /** URL selected organization slug to round-trip through OAuth `state`. */ readonly callbackStateOrgSlug?: string | null; + /** Host-operated apps declared at composition time (`first-party:` + * slugs). Resolved from config, never from storage: `loadClient` intercepts + * the prefix ahead of the DB, `listClients` appends their summaries, and the + * client CRUD surface rejects the namespace. Empty/omitted on hosts that + * ship no first-party apps. */ + readonly firstPartyClients?: readonly FirstPartyOAuthClientConfig[]; } type LooseDb = { @@ -489,9 +498,42 @@ const validateClientEndpoints = ( } }); +/** Resolve a config-declared first-party app to the loaded-client shape the + * flow/refresh paths consume. First-party apps are authorization_code only: + * client_credentials mints machine tokens under the OPERATOR's app identity, + * which must never be shared across tenants. */ +export const loadedFirstPartyClient = ( + config: FirstPartyOAuthClientConfig, +): { + readonly slug: string; + readonly authorizationUrl: string; + readonly tokenUrl: string; + readonly grant: OAuthGrant; + readonly clientId: string; + readonly clientSecret: string; + readonly resource: null; +} => ({ + slug: String(firstPartyOAuthClientSlug(config.name)), + authorizationUrl: config.authorizationUrl, + tokenUrl: config.tokenUrl, + grant: "authorization_code", + clientId: config.clientId, + clientSecret: config.clientSecret, + resource: null, +}); + export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer; const fetch = deps.fetch; + // Config-declared first-party apps, keyed by their prefixed slug. Config is + // the source of truth — no row exists, so every stored-row path (CRUD, GC) + // is bypassed by construction, and rotating a secret is an env change. + const firstPartyBySlug = new Map( + (deps.firstPartyClients ?? []).map((client) => [ + String(firstPartyOAuthClientSlug(client.name)), + client, + ]), + ); // EXPLICIT — no localhost default. `null` means this executor has no OAuth // callback; redirect-requiring flows fail loudly via `requireRedirectUri`. const redirectUri = deps.redirectUri; @@ -596,6 +638,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { input: CreateOAuthClientInput, ): Effect.Effect => Effect.gen(function* () { + // 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. + if (isFirstPartyOAuthClientSlug(String(input.slug))) { + return yield* new StorageError({ + message: `OAuth client slug "${String(input.slug)}" uses the reserved first-party namespace.`, + cause: undefined, + }); + } yield* validateClientEndpoints(input, deps.endpointUrlPolicy); const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), @@ -681,6 +732,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // ----------------------------------------------------------------------- const removeClient = (owner: Owner, slug: OAuthClientSlug): Effect.Effect => Effect.gen(function* () { + // Config-declared apps have no row to remove; removing one is an env + // change on the host, not a storage operation. Fail loudly rather than + // returning a success that changed nothing. + if (isFirstPartyOAuthClientSlug(String(slug))) { + return yield* new StorageError({ + message: `OAuth client "${String(slug)}" is a first-party app declared in host config; it cannot be removed through this surface.`, + cause: undefined, + }); + } yield* deps.fuma .use("oauth_client.delete", (db) => looseDb(db).deleteMany("oauth_client", { @@ -952,8 +1012,24 @@ 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 => - deps.fuma + 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. + const firstPartySummaries: readonly OAuthClientSummary[] = [...firstPartyBySlug.values()].map( + (config) => ({ + owner: "org", + slug: firstPartyOAuthClientSlug(config.name), + grant: "authorization_code", + authorizationUrl: config.authorizationUrl, + tokenUrl: config.tokenUrl, + resource: null, + clientId: config.clientId, + origin: { kind: "first_party", integrations: config.integrations ?? [] }, + }), + ); + return deps.fuma .use("oauth_client.findMany", (db) => looseDb(db).findMany("oauth_client", {})) .pipe( Effect.flatMap((rows) => @@ -981,7 +1057,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { } satisfies OAuthClientSummary); }), ), + Effect.map((stored) => [...firstPartySummaries, ...stored]), ); + }; // ----------------------------------------------------------------------- // Load an oauth_client row by (owner, slug). @@ -989,8 +1067,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const loadClient = ( owner: Owner, slug: OAuthClientSlug, - ): Effect.Effect => - deps.fuma + ): Effect.Effect => { + // First-party apps resolve from config, never storage. Owner is irrelevant: + // the app belongs to the DEPLOYMENT, and visibility policy has nothing to + // narrow — only the minted connection (and its tokens) is owner-scoped. + if (isFirstPartyOAuthClientSlug(String(slug))) { + const config = firstPartyBySlug.get(String(slug)); + return Effect.succeed(config ? loadedFirstPartyClient(config) : null); + } + return deps.fuma .use("oauth_client.findFirst", (db) => looseDb(db).findFirst("oauth_client", { where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))), @@ -1037,6 +1122,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); }), ); + }; // ----------------------------------------------------------------------- // start — begin a flow through a client to mint a connection. @@ -1057,7 +1143,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // cannot be backed by a member's private (user) app. The connection owner // and the app owner are otherwise independent — a Personal connection // through a shared Workspace app is the supported cross-owner case. - if (input.owner === "org" && input.clientOwner === "user") { + // First-party apps are deployment-owned, outside the owner lattice + // entirely, so the rule does not apply to them. + const firstPartyFlow = isFirstPartyOAuthClientSlug(String(input.client)); + if (!firstPartyFlow && input.owner === "org" && input.clientOwner === "user") { return yield* new OAuthStartError({ message: "A Workspace connection must use a Workspace app.", }); diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index e78b56577..3bd81fbf2 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -128,6 +128,10 @@ export { // OAuth wire contracts (data + tagged errors; the flow impl is server-only). export { + FIRST_PARTY_OAUTH_CLIENT_PREFIX, + firstPartyOAuthClientSlug, + isFirstPartyOAuthClientSlug, + type FirstPartyOAuthClientConfig, type OAuthGrant, type OAuthAuthentication, type OAuthClient, diff --git a/packages/core/sdk/src/test-config.ts b/packages/core/sdk/src/test-config.ts index bce9392b4..dbf3b226f 100644 --- a/packages/core/sdk/src/test-config.ts +++ b/packages/core/sdk/src/test-config.ts @@ -122,6 +122,7 @@ export type TestConfigOptions["firstPartyOAuthClients"]; }; export const makeTestConfig = ( @@ -161,6 +162,7 @@ export const makeTestConfig = {clientDisplayName(String(app.slug))} - {clientHost(app.tokenUrl)} ·{" "} - {app.grant === "client_credentials" ? "app-to-app" : "you'll sign in"} + {app.origin.kind === "first_party" + ? "No setup needed · you'll sign in" + : `${clientHost(app.tokenUrl)} · ${ + app.grant === "client_credentials" ? "app-to-app" : "you'll sign in" + }`} - {showOwnerLabel ? {ownerLabel(app.owner)} : null} + {app.origin.kind === "first_party" ? ( + Built-in + ) : showOwnerLabel ? ( + {ownerLabel(app.owner)} + ) : null} {onManage ? ( @@ -1584,6 +1591,8 @@ function AddAccountModalView(props: AddAccountModalProps) { const manageHandlersFor = ( appOption: OAuthClientOption, ): { readonly onEdit: () => void; readonly onRemove: () => void } | undefined => { + // First-party apps are host config, not rows: nothing to edit or remove. + if (appOption.origin.kind === "first_party") return undefined; const summary = clientSummaries.find( (c: OAuthClientSummary) => c.owner === appOption.owner && String(c.slug) === String(appOption.slug), diff --git a/packages/react/src/plugins/use-effective-oauth-client.test.ts b/packages/react/src/plugins/use-effective-oauth-client.test.ts index 6bdbf612f..7b3f6ace8 100644 --- a/packages/react/src/plugins/use-effective-oauth-client.test.ts +++ b/packages/react/src/plugins/use-effective-oauth-client.test.ts @@ -267,6 +267,65 @@ describe("selectClientsForEndpoints", () => { "spotify-app-2", ]); }); + + it("ranks a first-party app above BYO apps when both match", () => { + const integration = IntegrationSlug.make("github_rest"); + const byo = app("my-github-app", { + authorizationUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + }); + const firstParty = app("first-party:github", { + owner: "org", + authorizationUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + origin: { kind: "first_party", integrations: [integration] }, + }); + const result = selectClientsForEndpoints([byo, firstParty], { + authorizationUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + integration, + }); + expect(result.endpointMatched).toBe(true); + // First-party leads despite being org-owned (user-owned normally sorts first). + expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([ + "first-party:github", + "my-github-app", + ]); + }); + + it("intent-matches a first-party app to its declared integrations even without endpoints", () => { + const integration = IntegrationSlug.make("github_rest"); + const firstParty = app("first-party:github", { + owner: "org", + authorizationUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + origin: { kind: "first_party", integrations: [integration] }, + }); + const result = selectClientsForEndpoints([firstParty], { + requireEndpointMatch: true, + integration, + }); + expect(result.endpointMatched).toBe(true); + expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([ + "first-party:github", + ]); + }); + + it("does not surface a first-party app for an unrelated integration", () => { + const firstParty = app("first-party:github", { + owner: "org", + authorizationUrl: "https://github.com/login/oauth/authorize", + tokenUrl: "https://github.com/login/oauth/access_token", + origin: { kind: "first_party", integrations: [IntegrationSlug.make("github_rest")] }, + }); + const result = selectClientsForEndpoints([firstParty], { + authorizationUrl: "https://accounts.spotify.com/authorize", + tokenUrl: "https://accounts.spotify.com/api/token", + integration: IntegrationSlug.make("spotify"), + }); + expect(result.endpointMatched).toBe(false); + expect(result.matched).toEqual([]); + }); }); describe("selectDcrClientsForIntegration", () => { diff --git a/packages/react/src/plugins/use-effective-oauth-client.tsx b/packages/react/src/plugins/use-effective-oauth-client.tsx index 0c0467254..87b17a49c 100644 --- a/packages/react/src/plugins/use-effective-oauth-client.tsx +++ b/packages/react/src/plugins/use-effective-oauth-client.tsx @@ -47,6 +47,12 @@ export interface OAuthClientOption { export const isDcrClient = (app: OAuthClientOption): boolean => app.origin.kind === "dynamic_client_registration"; +/** True for host-operated first-party apps (config-declared, `first-party:` + * slugs). They rank ABOVE user/workspace apps when they match an integration: + * the one-click "nothing to paste" path is the default, BYO the escape hatch. */ +export const isFirstPartyClient = (app: OAuthClientOption): boolean => + app.origin.kind === "first_party"; + const hostOf = (url: string): string | undefined => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host" try { @@ -115,12 +121,14 @@ const EMPTY_CLIENTS: readonly OAuthClientOption[] = []; const hostEq = (a: string | undefined, b: string | undefined): boolean => a !== undefined && b !== undefined && a === b; -/** Sort apps user-owned first (so the user's own apps surface before shared - * workspace apps). */ +/** Sort first-party apps first (the one-click default), then user-owned before + * shared workspace apps. */ const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] => - [...apps].sort((a: OAuthClientOption, b: OAuthClientOption) => - a.owner === b.owner ? 0 : a.owner === "user" ? -1 : 1, - ); + [...apps].sort((a: OAuthClientOption, b: OAuthClientOption) => { + const aFirstParty = isFirstPartyClient(a); + if (aFirstParty !== isFirstPartyClient(b)) return aFirstParty ? -1 : 1; + return a.owner === b.owner ? 0 : a.owner === "user" ? -1 : 1; + }); /** * Pure matcher (no React/atoms) — split owner-visible apps into three honest @@ -170,11 +178,19 @@ export function selectClientsForEndpoints( const manual = all.filter((app) => !isDcrClient(app)); const intent = endpoints.integration; - const matchesIntent = (app: OAuthClientOption): boolean => - intent != null && - app.origin.kind === "manual" && - app.origin.integration != null && - app.origin.integration === intent; + const matchesIntent = (app: OAuthClientOption): boolean => { + if (intent == null) return false; + // A first-party app declaring this integration is intent-matched the same + // way a BYO app registered from this dialog is. + if (app.origin.kind === "first_party") { + return (app.origin.integrations ?? []).includes(intent); + } + return ( + app.origin.kind === "manual" && + app.origin.integration != null && + app.origin.integration === intent + ); + }; const wantedTokenHost = endpoints.tokenUrl ? hostOf(endpoints.tokenUrl) : undefined; const wantedAuthorizationHost = endpoints.authorizationUrl @@ -377,9 +393,12 @@ export function optimisticDcrClientSlug(issuerOrEndpoint: string): OAuthClientSl return OAuthClientSlug.make(`dcr-${base || "authorization-server"}`); } -/** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). */ +/** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). + * First-party slugs drop their namespace prefix ("first-party:github" → + * "Github") — the row's badge already says it's the built-in app. */ export function clientDisplayName(slug: string): string { - const text = slug.replace(/[-_]/g, " ").trim(); + const bare = slug.startsWith("first-party:") ? slug.slice("first-party:".length) : slug; + const text = bare.replace(/[-_]/g, " ").trim(); return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : slug; }