History

Add first-party OAuth clients (host-operated GitHub/Google apps)

Rhys Sullivan committed 3 months ago

Commit 8d46cc51 parent 4263f3317 files · +826 −41Browse files

Unified · Split

17 files
  1. apps/cloud/src/engine/execution-stack.ts +31 −1
  2. apps/cloud/src/env-augment.d.ts +10 −0
  3. e2e/scenarios/first-party-oauth.test.ts +135 −0
  4. e2e/setup/cloud.boot.ts +5 −0
  5. packages/core/api/src/oauth/api.ts +7 −0
  6. packages/core/api/src/server/scoped-executor.ts +10 −0
  7. packages/core/sdk/src/executor.ts +67 −19
  8. packages/core/sdk/src/index.ts +5 −0
  9. packages/core/sdk/src/oauth-client.ts +48 −1
  10. packages/core/sdk/src/oauth-first-party.test.ts +290 −0
  11. packages/core/sdk/src/oauth-service.ts +94 −5
  12. packages/core/sdk/src/shared.ts +4 −0
  13. packages/core/sdk/src/test-config.ts +2 −0
  14. packages/plugins/openapi/src/sdk/presets.ts +16 −0
  15. packages/react/src/components/add-account-modal.tsx +12 −3
  16. packages/react/src/plugins/use-effective-oauth-client.test.ts +59 −0
  17. packages/react/src/plugins/use-effective-oauth-client.tsx +31 −12
apps/cloud/src/engine/execution-stack.ts+31−1
@@ -43,7 +43,7 @@ import { · 42 unmodified lines
43 collectTables,43 collectTables,
44} from "@executor-js/api/server";44} from "@executor-js/api/server";
45import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";45import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
46import type { AnyPlugin } from "@executor-js/sdk";46import type { AnyPlugin, FirstPartyOAuthClientConfig } from "@executor-js/sdk";
47 47
48import executorConfig from "../../executor.config";48import executorConfig from "../../executor.config";
49import { DbService } from "../db/db";49import { DbService } from "../db/db";
@@ -88,6 +88,35 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 38 unmodified lines
88 */88 */
89export const CLOUD_MOUNT_PREFIX = "/api" as const;89export const CLOUD_MOUNT_PREFIX = "/api" as const;
90 90
91// Executor-owned provider apps, enabled per provider by setting BOTH env vars
92// (id + secret). Each provider-side registration must list
93// `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug
94// travels inside OAuth `state`, so the single static callback serves every org.
95const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [
96 ...(env.FIRST_PARTY_GITHUB_CLIENT_ID && env.FIRST_PARTY_GITHUB_CLIENT_SECRET
97 ? [
98 {
99 name: "github",
100 authorizationUrl: "https://github.com/login/oauth/authorize",
101 tokenUrl: "https://github.com/login/oauth/access_token",
102 clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID,
103 clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET,
104 },
105 ]
106 : []),
107 ...(env.FIRST_PARTY_GOOGLE_CLIENT_ID && env.FIRST_PARTY_GOOGLE_CLIENT_SECRET
108 ? [
109 {
110 name: "google",
111 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
112 tokenUrl: "https://oauth2.googleapis.com/token",
113 clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID,
114 clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET,
115 },
116 ]
117 : []),
118];
119
91export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({120export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({
92 // SSRF / private-network egress guard. Config-driven, NOT a test flag:121 // SSRF / private-network egress guard. Config-driven, NOT a test flag:
93 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);122 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);
@@ -99,6 +128,7 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, ( · 5 unmodified lines
99 // WorkOS Vault is cloud's credential storage implementation detail, not a128 // WorkOS Vault is cloud's credential storage implementation detail, not a
100 // user-selectable provider surface.129 // user-selectable provider surface.
101 exposeCredentialProviders: false,130 exposeCredentialProviders: false,
131 firstPartyOAuthClients: cloudFirstPartyOAuthClients(),
102}));132}));
103 133
104export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync(134export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync(
apps/cloud/src/env-augment.d.ts+10−0
@@ -53,6 +53,16 @@ declare global { · 52 unmodified lines
53 // number to drive the backstop. Production leaves it unset.53 // number to drive the backstop. Production leaves it unset.
54 EXECUTION_RATE_LIMIT_PER_HOUR?: string;54 EXECUTION_RATE_LIMIT_PER_HOUR?: string;
55 55
56 // First-party OAuth apps (executor-owned provider registrations). Each
57 // pair enables one-click connect through `first-party:<provider>`; an
58 // unset pair simply ships no first-party app for that provider. The
59 // registered callback on the provider side must be
60 // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`.
61 FIRST_PARTY_GITHUB_CLIENT_ID?: string;
62 FIRST_PARTY_GITHUB_CLIENT_SECRET?: string;
63 FIRST_PARTY_GOOGLE_CLIENT_ID?: string;
64 FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;
65
56 // Billing66 // Billing
57 AUTUMN_SECRET_KEY?: string;67 AUTUMN_SECRET_KEY?: string;
58 /** Optional Autumn base-URL override (Autumn emulator in tests/dev). */68 /** Optional Autumn base-URL override (Autumn emulator in tests/dev). */
e2e/scenarios/first-party-oauth.test.ts+135−0
@@ -0,0 +1,135 @@
1// First-party OAuth clients: the cloud host declares executor-owned apps via
2// env (`FIRST_PARTY_GITHUB_CLIENT_ID/SECRET`, set by the e2e cloud boot), and
3// every org can connect through them with nothing to paste. Three guarantees:
4//
5// 1. Listing: `oauth.listClients` surfaces `first-party:github` with a
6// `first_party` origin and its public client id — no create call ever ran.
7// 2. Flow: `oauth.start` through the first-party slug redirects to the
8// provider's authorize endpoint carrying the env-configured client id and
9// this platform's `/api/oauth/callback` — proof the config-resolved
10// identity (not a stored row) drives the flow. The redirect is asserted,
11// never followed: github.com is not visited.
12// 3. Guardrails: the reserved `first-party:` namespace is rejected by
13// createClient, so no org can shadow the host's app with its own row.
14import { randomBytes } from "node:crypto";
15
16import { expect } from "@effect/vitest";
17import { Effect } from "effect";
18import { composePluginApi } from "@executor-js/api/server";
19import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
20import {
21 AuthTemplateSlug,
22 ConnectionName,
23 IntegrationSlug,
24 OAuthClientSlug,
25} from "@executor-js/sdk/shared";
26
27import { scenario } from "../src/scenario";
28import { Api, Target } from "../src/services";
29
30const api = composePluginApi([openApiHttpPlugin()] as const);
31
32const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
33
34/** A minimal integration whose OAuth template points at GitHub's endpoints, so
35 * the first-party `first-party:github` app is the matching client for it. */
36const githubShapedIntegrationSpec = {
37 spec: {
38 kind: "blob" as const,
39 value: JSON.stringify({
40 openapi: "3.0.3",
41 info: { title: "GitHub-shaped API", version: "1.0.0" },
42 paths: {
43 "/user": {
44 get: {
45 operationId: "getUser",
46 tags: ["default"],
47 responses: { "200": { description: "the caller" } },
48 },
49 },
50 },
51 }),
52 },
53 baseUrl: "https://api.github.com",
54 authenticationTemplate: [
55 {
56 slug: "oauth",
57 kind: "oauth2" as const,
58 authorizationUrl: "https://github.com/login/oauth/authorize",
59 tokenUrl: "https://github.com/login/oauth/access_token",
60 scopes: ["repo", "read:org"],
61 },
62 ],
63} as const;
64
65scenario(
66 "First-party OAuth · the host-declared GitHub app is listed and drives the authorize redirect",
67 {},
68 Effect.scoped(
69 Effect.gen(function* () {
70 const target = yield* Target;
71 const { client: makeApiClient } = yield* Api;
72 const identity = yield* target.newIdentity();
73 const client = yield* makeApiClient(api, identity);
74
75 // 1. The config-declared app appears in listings with its public id.
76 const clients = yield* client.oauth.listClients();
77 const firstParty = clients.find((c) => String(c.slug) === "first-party:github");
78 expect(firstParty, "the env-declared first-party GitHub app is listed").toBeDefined();
79 expect(firstParty?.origin.kind).toBe("first_party");
80 expect(firstParty?.clientId).toBe("e2e-first-party-github");
81
82 // 2. A start through the first-party slug builds GitHub's authorize URL
83 // from the config identity and this platform's served callback.
84 const integration = IntegrationSlug.make(unique("fpgh"));
85 yield* client.openapi.addSpec({
86 payload: { ...githubShapedIntegrationSpec, slug: integration },
87 });
88 const started = yield* client.oauth.start({
89 payload: {
90 client: OAuthClientSlug.make("first-party:github"),
91 clientOwner: "org",
92 owner: "org",
93 name: ConnectionName.make("main"),
94 integration,
95 template: AuthTemplateSlug.make("oauth"),
96 },
97 });
98 expect(started.status, "oauth.start redirects to the provider").toBe("redirect");
99 const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : "";
100 const authorize = new URL(authorizationUrl);
101 expect(authorize.origin + authorize.pathname).toBe(
102 "https://github.com/login/oauth/authorize",
103 );
104 expect(authorize.searchParams.get("client_id")).toBe("e2e-first-party-github");
105 expect(authorize.searchParams.get("redirect_uri")).toBe(
106 new URL("/api/oauth/callback", target.baseUrl).toString(),
107 );
108
109 // 3. The reserved namespace cannot be shadowed by a stored row. The
110 // server rejects with a StorageError, which the HTTP edge scrubs to an
111 // opaque InternalError — assert the rejection, then prove the listed
112 // app is still the config-declared one (same public id, same origin).
113 yield* client.oauth
114 .createClient({
115 payload: {
116 owner: "org",
117 slug: OAuthClientSlug.make("first-party:github"),
118 authorizationUrl: "https://github.com/login/oauth/authorize",
119 tokenUrl: "https://github.com/login/oauth/access_token",
120 grant: "authorization_code",
121 clientId: "impostor",
122 clientSecret: "impostor-secret",
123 },
124 })
125 .pipe(Effect.flip);
126 const after = yield* client.oauth.listClients();
127 const survivors = after.filter((c) => String(c.slug) === "first-party:github");
128 expect(survivors, "exactly one first-party:github remains listed").toHaveLength(1);
129 expect(survivors[0]?.origin.kind).toBe("first_party");
130 expect(survivors[0]?.clientId, "the impostor never shadowed the host's app").toBe(
131 "e2e-first-party-github",
132 );
133 }),
134 ),
135);
e2e/setup/cloud.boot.ts+5−0
@@ -94,6 +94,11 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted> · 93 unmodified lines
94 MCP_SESSION_TIMEOUT_MS: process.env.MCP_SESSION_TIMEOUT_MS,94 MCP_SESSION_TIMEOUT_MS: process.env.MCP_SESSION_TIMEOUT_MS,
95 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: process.env.MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS,95 MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: process.env.MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS,
96 ALLOW_LOCAL_NETWORK: "true",96 ALLOW_LOCAL_NETWORK: "true",
97 // A first-party GitHub app for the first-party-oauth scenario: proves the
98 // env → HostConfig → executor plumbing end to end. The scenario asserts the
99 // authorize REDIRECT only (client id + callback), never visits github.com.
100 FIRST_PARTY_GITHUB_CLIENT_ID: "e2e-first-party-github",
101 FIRST_PARTY_GITHUB_CLIENT_SECRET: "e2e-first-party-github-secret",
97 // Shrink the per-org hourly execution cap (prod default 1000) to a number102 // Shrink the per-org hourly execution cap (prod default 1000) to a number
98 // the rate-limit-backstop scenario can actually exhaust with real103 // the rate-limit-backstop scenario can actually exhaust with real
99 // executions — but see execution-limits.ts: it must stay above every other104 // executions — but see execution-limits.ts: it must stay above every other
packages/core/api/src/oauth/api.ts+7−0
@@ -121,6 +121,13 @@ const OAuthClientSummaryResponse = Schema.Struct({ · 120 unmodified lines
121 kind: Schema.Literal("dynamic_client_registration"),121 kind: Schema.Literal("dynamic_client_registration"),
122 integration: Schema.optional(Schema.NullOr(IntegrationSlug)),122 integration: Schema.optional(Schema.NullOr(IntegrationSlug)),
123 }),123 }),
124 /** Host-operated app declared in executor config — every org connects
125 * through it; nothing to paste. `integrations` ranks it as the default
126 * for those integrations in the picker. */
127 Schema.Struct({
128 kind: Schema.Literal("first_party"),
129 integrations: Schema.optional(Schema.Array(IntegrationSlug)),
130 }),
124 ]),131 ]),
125});132});
126 133
packages/core/api/src/server/scoped-executor.ts+10−0
@@ -41,6 +41,7 @@ import { · 40 unmodified lines
41 type AnyPlugin,41 type AnyPlugin,
42 type Executor,42 type Executor,
43 type ExecutorConfig,43 type ExecutorConfig,
44 type FirstPartyOAuthClientConfig,
44 type StorageFailure,45 type StorageFailure,
45} from "@executor-js/sdk";46} from "@executor-js/sdk";
46import {47import {
@@ -97,6 +98,14 @@ export interface HostConfigShape { · 50 unmodified lines
97 * Hosts that record product analytics supply it; omitted -> no observation.98 * Hosts that record product analytics supply it; omitted -> no observation.
98 */99 */
99 readonly onIntegrationChange?: ExecutorConfig["onIntegrationChange"];100 readonly onIntegrationChange?: ExecutorConfig["onIntegrationChange"];
101 /**
102 * Host-operated OAuth apps (`first-party:<name>`), threaded verbatim into
103 * `createExecutor`. Declared here — not per-request — because the registered
104 * redirect URI on the provider side is fixed per deployment, and both request
105 * planes (HTTP API, MCP session DO) must resolve the same apps. Hosts that
106 * ship none simply omit it.
107 */
108 readonly firstPartyOAuthClients?: readonly FirstPartyOAuthClientConfig[];
100}109}
101 110
102export class HostConfig extends Context.Service<HostConfig, HostConfigShape>()(111export class HostConfig extends Context.Service<HostConfig, HostConfigShape>()(
@@ -281,6 +290,7 @@ export const makeScopedExecutor = < · 178 unmodified lines
281 onElicitation: "accept-all",290 onElicitation: "accept-all",
282 redirectUri,291 redirectUri,
283 oauthCallbackStateOrgSlug: orgSlug,292 oauthCallbackStateOrgSlug: orgSlug,
293 firstPartyOAuthClients: config.firstPartyOAuthClients,
284 coreTools: {294 coreTools: {
285 webBaseUrl,295 webBaseUrl,
286 orgSlug,296 orgSlug,
packages/core/sdk/src/executor.ts+67−19
@@ -108,7 +108,8 @@ import { · 107 unmodified lines
108 type MintOAuthConnectionInput,108 type MintOAuthConnectionInput,
109 type OAuthScopePolicy,109 type OAuthScopePolicy,
110} from "./oauth-service";110} from "./oauth-service";
111import type { OAuthService } from "./oauth-client";111import { isFirstPartyOAuthClientSlug, type OAuthService } from "./oauth-client";
112import type { FirstPartyOAuthClientConfig } from "./oauth-client";
112import {113import {
113 comparePolicyRow,114 comparePolicyRow,
114 isValidPattern,115 isValidPattern,
@@ -625,6 +626,14 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = readonly · 510 unmodified lines
625 /** Optional URL selected organization slug to carry inside OAuth `state`. */626 /** Optional URL selected organization slug to carry inside OAuth `state`. */
626 readonly oauthCallbackStateOrgSlug?: string;627 readonly oauthCallbackStateOrgSlug?: string;
627 readonly oauthEndpointUrlPolicy?: OAuthEndpointUrlPolicy;628 readonly oauthEndpointUrlPolicy?: OAuthEndpointUrlPolicy;
629 /**
630 * Host-operated OAuth apps (the deployment's own registered GitHub/Google/…
631 * apps), addressed as `first-party:<name>`. Users connect through them with
632 * nothing to paste. Config-resolved — never persisted; secrets stay in host
633 * env and are never written to a credential provider or returned over any
634 * read surface. Minted connections and their tokens remain per-owner.
635 */
636 readonly firstPartyOAuthClients?: readonly FirstPartyOAuthClientConfig[];
628 /**637 /**
629 * Enable the built-in `core-tools` plugin which contributes agent-facing638 * Enable the built-in `core-tools` plugin which contributes agent-facing
630 * static tools over the v2 surface (integrations / connections / policies).639 * static tools over the v2 surface (integrations / connections / policies).
@@ -1753,6 +1762,23 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1122 unmodified lines
1753 where: (b: AnyCb) => b.and(byOwner(owner)(b), b("slug", "=", slug)),1762 where: (b: AnyCb) => b.and(byOwner(owner)(b), b("slug", "=", slug)),
1754 });1763 });
1755 1764
1765 // Config-declared first-party apps, keyed by prefixed slug — the refresh
1766 // path's counterpart to the OAuth service's config-first resolution.
1767 const firstPartyOAuthBySlug = new Map(
1768 (config.firstPartyOAuthClients ?? []).map((client) => [`first-party:${client.name}`, client]),
1769 );
1770
1771 /** The app identity a refresh runs against, uniformly resolved: a stored
1772 * row's secret comes out of the credential provider by item id; a
1773 * first-party app's comes from host config and never touches a provider. */
1774 interface RefreshClient {
1775 readonly clientId: string;
1776 readonly clientSecret: string;
1777 readonly tokenUrl: string;
1778 readonly grant: string;
1779 readonly resource: string | null;
1780 }
1781
1756 /** What drove a refresh: the pre-call expiry check (`proactive`), or an1782 /** What drove a refresh: the pre-call expiry check (`proactive`), or an
1757 * upstream 401 on a token we believed was still valid (`reactive`). */1783 * upstream 401 on a token we believed was still valid (`reactive`). */
1758 type RefreshTrigger = "proactive" | "reactive";1784 type RefreshTrigger = "proactive" | "reactive";
@@ -1839,19 +1865,42 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 80 unmodified lines
1839 return yield* reauth(detail);1865 return yield* reauth(detail);
1840 }1866 }
1841 1867
1842 // Load the backing app by the owner STORED on the connection (a Personal1868 // Load the backing app. A `first-party:` slug resolves from host config
1843 // connection may be backed by a shared Workspace app) — no derivation.1869 // (deployment-owned identity, in-memory secret); a stored slug loads by
1844 const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;1870 // the owner STORED on the connection (a Personal connection may be
1845 const clientRow = yield* loadOAuthClientRow(clientOwner, String(row.oauth_client));1871 // backed by a shared Workspace app) — no derivation — with its secret
1872 // resolved out of the credential provider by item id.
1873 const clientSlug = String(row.oauth_client);
1874 const clientRow: RefreshClient | null = yield* Effect.gen(function* () {
1875 if (isFirstPartyOAuthClientSlug(clientSlug)) {
1876 const firstParty = firstPartyOAuthBySlug.get(clientSlug);
1877 if (!firstParty) return null;
1878 return {
1879 clientId: firstParty.clientId,
1880 clientSecret: firstParty.clientSecret,
1881 tokenUrl: firstParty.tokenUrl,
1882 grant: "authorization_code",
1883 resource: null,
1884 } satisfies RefreshClient;
1885 }
1886 const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;
1887 const stored = yield* loadOAuthClientRow(clientOwner, clientSlug);
1888 if (!stored) return null;
1889 return {
1890 clientId: String(stored.client_id),
1891 clientSecret: stored.client_secret_item_id
1892 ? ((yield* provider.get(ProviderItemId.make(String(stored.client_secret_item_id)))) ??
1893 "")
1894 : "",
1895 tokenUrl: String(stored.token_url),
1896 grant: String(stored.grant),
1897 resource: stored.resource ? String(stored.resource) : null,
1898 } satisfies RefreshClient;
1899 });
1846 if (!clientRow) {1900 if (!clientRow) {
1847 return yield* reauth(`OAuth client "${row.oauth_client}" is no longer registered.`);1901 return yield* reauth(`OAuth client "${row.oauth_client}" is no longer registered.`);
1848 }1902 }
1849 1903 const clientSecret = clientRow.clientSecret;
1850 // The secret is stored in the provider (a vault item id), not inline.
1851 const clientSecret = clientRow.client_secret_item_id
1852 ? ((yield* provider.get(ProviderItemId.make(String(clientRow.client_secret_item_id)))) ??
1853 "")
1854 : "";
1855 // Re-request the scopes this connection was GRANTED (RFC 6749 §6: a1904 // Re-request the scopes this connection was GRANTED (RFC 6749 §6: a
1856 // refresh must not exceed the originally-granted scope). Empty → omit1905 // refresh must not exceed the originally-granted scope). Empty → omit
1857 // the param, which the AS treats as "same scopes as granted".1906 // the param, which the AS treats as "same scopes as granted".
@@ -1862,9 +1911,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 4 unmodified lines
1862 // Refresh against the region the code was redeemed at when one was1911 // Refresh against the region the code was redeemed at when one was
1863 // recorded at connect time (multi-site providers like Datadog), else1912 // recorded at connect time (multi-site providers like Datadog), else
1864 // the oauth_client's configured token endpoint.1913 // the oauth_client's configured token endpoint.
1865 const tokenUrl = row.oauth_token_url1914 const tokenUrl = row.oauth_token_url ? String(row.oauth_token_url) : clientRow.tokenUrl;
1866 ? String(row.oauth_token_url)
1867 : String(clientRow.token_url);
1868 1915
1869 // client_credentials (machine-to-machine) has NO refresh token — the1916 // client_credentials (machine-to-machine) has NO refresh token — the
1870 // token is RE-MINTED from the client id/secret. The authorization_code1917 // token is RE-MINTED from the client id/secret. The authorization_code
@@ -1872,13 +1919,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1 unmodified line
1872 // what keeps a client_credentials connection (e.g. DealCloud) from1919 // what keeps a client_credentials connection (e.g. DealCloud) from
1873 // demanding a re-auth on a credential that has no human to re-auth.1920 // demanding a re-auth on a credential that has no human to re-auth.
1874 const token =1921 const token =
1875 String(clientRow.grant) === "client_credentials"1922 clientRow.grant === "client_credentials"
1876 ? yield* exchangeClientCredentials({1923 ? yield* exchangeClientCredentials({
1877 tokenUrl,1924 tokenUrl,
1878 clientId: String(clientRow.client_id),1925 clientId: clientRow.clientId,
1879 clientSecret,1926 clientSecret,
1880 scopes: grantedScopes,1927 scopes: grantedScopes,
1881 resource: clientRow.resource ? String(clientRow.resource) : undefined,1928 resource: clientRow.resource ?? undefined,
1882 endpointUrlPolicy: config.oauthEndpointUrlPolicy,1929 endpointUrlPolicy: config.oauthEndpointUrlPolicy,
1883 fetch: config.fetch,1930 fetch: config.fetch,
1884 }).pipe(1931 }).pipe(
@@ -1905,13 +1952,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines
1905 }1952 }
1906 return yield* refreshAccessToken({1953 return yield* refreshAccessToken({
1907 tokenUrl,1954 tokenUrl,
1908 clientId: String(clientRow.client_id),1955 clientId: clientRow.clientId,
1909 clientSecret,1956 clientSecret,
1910 refreshToken,1957 refreshToken,
1911 scopes: grantedScopes,1958 scopes: grantedScopes,
1912 // RFC 8707: keep the re-minted token bound to the same resource1959 // RFC 8707: keep the re-minted token bound to the same resource
1913 // (MCP servers require this on refresh).1960 // (MCP servers require this on refresh).
1914 resource: clientRow.resource ? String(clientRow.resource) : undefined,1961 resource: clientRow.resource ?? undefined,
1915 endpointUrlPolicy: config.oauthEndpointUrlPolicy,1962 endpointUrlPolicy: config.oauthEndpointUrlPolicy,
1916 fetch: config.fetch,1963 fetch: config.fetch,
1917 }).pipe(1964 }).pipe(
@@ -4566,6 +4613,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 2648 unmodified lines
4566 // OAuth (cloud, self-host) derive a real `${webBaseUrl}/oauth/callback`.4613 // OAuth (cloud, self-host) derive a real `${webBaseUrl}/oauth/callback`.
4567 redirectUri: config.redirectUri ?? null,4614 redirectUri: config.redirectUri ?? null,
4568 callbackStateOrgSlug: config.oauthCallbackStateOrgSlug ?? null,4615 callbackStateOrgSlug: config.oauthCallbackStateOrgSlug ?? null,
4616 firstPartyClients: config.firstPartyOAuthClients,
4569 });4617 });
4570 4618
4571 // ------------------------------------------------------------------4619 // ------------------------------------------------------------------
packages/core/sdk/src/index.ts+5−0
@@ -291,6 +291,11 @@ export { · 290 unmodified lines
291 OAuthProbeError,291 OAuthProbeError,
292 OAuthRegisterDynamicError,292 OAuthRegisterDynamicError,
293 OAuthSessionNotFoundError,293 OAuthSessionNotFoundError,
294 FIRST_PARTY_OAUTH_CLIENT_PREFIX,
295 firstPartyOAuthClientSlug,
296 isFirstPartyOAuthClientSlug,
297 type FirstPartyOAuthClientConfig,
298 type OAuthClientOrigin,
294 type OAuthGrant,299 type OAuthGrant,
295 type OAuthAuthentication,300 type OAuthAuthentication,
296 type OAuthClient,301 type OAuthClient,
packages/core/sdk/src/oauth-client.ts+48−1
@@ -77,10 +77,57 @@ export type OAuthClientOrigin = · 76 unmodified lines
77 | {77 | {
78 readonly kind: "dynamic_client_registration";78 readonly kind: "dynamic_client_registration";
79 readonly integration?: IntegrationSlug | null;79 readonly integration?: IntegrationSlug | null;
80 }
81 | {
82 /** A host-operated app declared in executor config (`firstPartyOAuthClients`),
83 * not a stored row: the deployment operator registered ONE app with the
84 * provider (GitHub, Google, …) and every org connects through it, so
85 * users paste nothing. Config-resolved — never persisted, never
86 * creatable or removable through the client CRUD surface. Plural
87 * `integrations` (unlike the stored origins' single recorded intent):
88 * one Google app deliberately backs gmail, calendar, drive, …. */
89 readonly kind: "first_party";
90 readonly integrations?: readonly IntegrationSlug[];
80 };91 };
81 92
93/** Slug namespace separating config-declared first-party apps from stored
94 * owner-scoped rows. A first-party client declared as `github` is addressed
95 * everywhere (start input, connection rows' `oauth_client` column, listings)
96 * as `first-party:github`, so it can never collide with a BYO row and every
97 * load path can intercept before touching storage. The client CRUD surface
98 * rejects the prefix on stored rows. */
99export const FIRST_PARTY_OAUTH_CLIENT_PREFIX = "first-party:";
100
101export const isFirstPartyOAuthClientSlug = (slug: string): boolean =>
102 slug.startsWith(FIRST_PARTY_OAUTH_CLIENT_PREFIX);
103
104export const firstPartyOAuthClientSlug = (name: string): OAuthClientSlug =>
105 OAuthClientSlug.make(`${FIRST_PARTY_OAUTH_CLIENT_PREFIX}${name}`);
106
107/** A first-party OAuth app the HOST declares at composition time — the
108 * deployment operator's own registered app for a provider. The secret comes
109 * from host env/config and stays in memory: it is never written to a
110 * credential provider and never surfaced over any read surface. Minted
111 * connections (and their tokens) remain per-owner exactly as with BYO apps;
112 * only the app identity is shared. */
113export interface FirstPartyOAuthClientConfig {
114 /** Unprefixed name, e.g. `"github"`; addressed as `first-party:github`. */
115 readonly name: string;
116 readonly authorizationUrl: string;
117 readonly tokenUrl: string;
118 readonly clientId: string;
119 /** Literal secret from host env. Empty string for a public/PKCE client. */
120 readonly clientSecret: string;
121 /** Integrations this app is intended for, used by pickers to rank it as the
122 * exact-match default for those integrations. Endpoint-host matching still
123 * applies when omitted. */
124 readonly integrations?: readonly IntegrationSlug[];
125}
126
82export type CreateOAuthClientInput = OAuthClient & {127export type CreateOAuthClientInput = OAuthClient & {
83 readonly origin?: OAuthClientOrigin;128 /** Stored-row origins only — `first_party` is config-declared, never created
129 * through this surface (the service also rejects the slug namespace). */
130 readonly origin?: Exclude<OAuthClientOrigin, { kind: "first_party" }>;
84 readonly originIssuer?: string | null;131 readonly originIssuer?: string | null;
85 /** The redirect URI a DCR registration sent as the client's `redirect_uris`132 /** The redirect URI a DCR registration sent as the client's `redirect_uris`
86 * entry. Persisted so reuse can detect a changed callback (strict servers133 * entry. Persisted so reuse can detect a changed callback (strict servers
packages/core/sdk/src/oauth-first-party.test.ts+290−0
@@ -0,0 +1,290 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Predicate } from "effect";
3
4import {
5 AuthTemplateSlug,
6 ConnectionName,
7 IntegrationSlug,
8 OAuthClientSlug,
9 ToolAddress,
10 ToolName,
11} from "./ids";
12import {
13 firstPartyOAuthClientSlug,
14 type FirstPartyOAuthClientConfig,
15 type OAuthStartError,
16} from "./oauth-client";
17import { definePlugin } from "./plugin";
18import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
19import { serveOAuthTestServer } from "./testing/oauth-test-server";
20
21// First-party OAuth clients: host-operated apps declared in executor config
22// (`firstPartyOAuthClients`), addressed as `first-party:<name>`. Resolved from
23// config, never storage — these tests prove the whole lifecycle (start →
24// complete → execute → refresh) runs off the config-declared identity, that the
25// client CRUD surface rejects the reserved namespace, and that listings project
26// the app without ever having written its secret to a credential provider.
27
28const INTEG = IntegrationSlug.make("acme");
29const TEMPLATE = AuthTemplateSlug.make("oauth");
30const FIRST_PARTY = firstPartyOAuthClientSlug("acme");
31
32const oauthPlugin = definePlugin(() => ({
33 id: "acme" as const,
34 storage: () => ({}),
35 resolveTools: () =>
36 Effect.succeed({
37 tools: [{ name: ToolName.make("whoami"), description: "whoami" }],
38 }),
39 describeAuthMethods: (record) => {
40 const config = record.config as { readonly scopes?: readonly string[] } | null;
41 return [
42 {
43 id: "oauth",
44 label: "OAuth2",
45 kind: "oauth" as const,
46 template: String(TEMPLATE),
47 oauth: { scopes: config?.scopes ?? [] },
48 },
49 ];
50 },
51 invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }),
52 checkHealth: ({ credential }) =>
53 Effect.succeed({
54 status: credential.value === null ? "expired" : "healthy",
55 checkedAt: Date.now(),
56 }),
57 extension: (ctx) => ({
58 seed: (scopes: readonly string[] = []) =>
59 ctx.core.integrations.register({
60 slug: INTEG,
61 description: "Acme",
62 config: { scopes },
63 }),
64 }),
65}))();
66
67const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const;
68
69const firstPartyClientFor = (server: {
70 readonly authorizationEndpoint: string;
71 readonly tokenEndpoint: string;
72}): FirstPartyOAuthClientConfig => ({
73 name: "acme",
74 authorizationUrl: server.authorizationEndpoint,
75 tokenUrl: server.tokenEndpoint,
76 clientId: "test-client",
77 clientSecret: "test-secret",
78 integrations: [INTEG],
79});
80
81describe("first-party oauth clients", () => {
82 it.effect(
83 "start → complete through a config-declared client mints an executable connection",
84 () =>
85 Effect.scoped(
86 Effect.gen(function* () {
87 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
88 const { executor } = yield* makeTestWorkspaceHarness({
89 plugins,
90 firstPartyOAuthClients: [firstPartyClientFor(server)],
91 });
92 yield* executor.acme.seed();
93
94 // No createClient call — the app exists purely in config.
95 const started = yield* executor.oauth.start({
96 owner: "org",
97 client: FIRST_PARTY,
98 clientOwner: "org",
99 name: ConnectionName.make("main-account"),
100 integration: INTEG,
101 template: TEMPLATE,
102 });
103 expect(started.status).toBe("redirect");
104 if (started.status !== "redirect") return;
105
106 const callback = yield* server.completeAuthorizationCodeFlow({
107 authorizationUrl: started.authorizationUrl,
108 });
109 const connection = yield* executor.oauth.complete({
110 state: started.state,
111 code: callback.code,
112 });
113 expect(String(connection.address)).toBe("tools.acme.org.mainAccount");
114
115 const out = (yield* executor.execute(
116 ToolAddress.make("tools.acme.org.mainAccount.whoami"),
117 {},
118 )) as { token: string };
119 expect(out.token).toMatch(/^at_/);
120 expect(yield* server.acceptsAccessToken(out.token)).toBe(true);
121 }),
122 ),
123 );
124
125 it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>
126 Effect.scoped(
127 Effect.gen(function* () {
128 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
129 const harness = yield* makeTestWorkspaceHarness({
130 plugins,
131 firstPartyOAuthClients: [firstPartyClientFor(server)],
132 });
133 const { executor, config } = harness;
134 yield* executor.acme.seed();
135
136 const started = yield* executor.oauth.start({
137 owner: "org",
138 client: FIRST_PARTY,
139 clientOwner: "org",
140 name: ConnectionName.make("main"),
141 integration: INTEG,
142 template: TEMPLATE,
143 });
144 if (started.status !== "redirect") return;
145 const callback = yield* server.completeAuthorizationCodeFlow({
146 authorizationUrl: started.authorizationUrl,
147 });
148 yield* executor.oauth.complete({ state: started.state, code: callback.code });
149
150 const firstToken = (yield* executor.execute(
151 ToolAddress.make("tools.acme.org.main.whoami"),
152 {},
153 )) as { token: string };
154
155 // Force expiry so the next resolve refreshes through the config client.
156 yield* Effect.promise(() =>
157 config.db.updateMany("connection", {
158 where: (b) => b("name", "=", "main"),
159 set: { expires_at: Date.now() - 60_000 },
160 }),
161 );
162
163 const refreshedToken = (yield* executor.execute(
164 ToolAddress.make("tools.acme.org.main.whoami"),
165 {},
166 )) as { token: string };
167 expect(refreshedToken.token).not.toBe(firstToken.token);
168 expect(yield* server.acceptsAccessToken(refreshedToken.token)).toBe(true);
169 }),
170 ),
171 );
172
173 it.effect("listClients projects the first-party app ahead of stored rows, secretless", () =>
174 Effect.scoped(
175 Effect.gen(function* () {
176 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
177 const { executor } = yield* makeTestWorkspaceHarness({
178 plugins,
179 firstPartyOAuthClients: [firstPartyClientFor(server)],
180 });
181 yield* executor.acme.seed();
182
183 yield* executor.oauth.createClient({
184 owner: "org",
185 slug: OAuthClientSlug.make("byo-app"),
186 authorizationUrl: server.authorizationEndpoint,
187 tokenUrl: server.tokenEndpoint,
188 grant: "authorization_code",
189 clientId: "byo-client",
190 clientSecret: "byo-secret",
191 });
192
193 const clients = yield* executor.oauth.listClients();
194 expect(clients.map((c) => String(c.slug))).toEqual(["first-party:acme", "byo-app"]);
195 const firstParty = clients[0]!;
196 expect(firstParty.origin).toEqual({ kind: "first_party", integrations: [INTEG] });
197 expect(firstParty.clientId).toBe("test-client");
198 expect("clientSecret" in firstParty).toBe(false);
199 }),
200 ),
201 );
202
203 it.effect("createClient and removeClient reject the reserved first-party namespace", () =>
204 Effect.scoped(
205 Effect.gen(function* () {
206 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
207 const { executor } = yield* makeTestWorkspaceHarness({
208 plugins,
209 firstPartyOAuthClients: [firstPartyClientFor(server)],
210 });
211
212 const createError = yield* executor.oauth
213 .createClient({
214 owner: "org",
215 slug: FIRST_PARTY,
216 authorizationUrl: server.authorizationEndpoint,
217 tokenUrl: server.tokenEndpoint,
218 grant: "authorization_code",
219 clientId: "impostor",
220 clientSecret: "impostor-secret",
221 })
222 .pipe(Effect.flip);
223 expect(createError.message).toContain("reserved first-party namespace");
224
225 const removeError = yield* executor.oauth
226 .removeClient("org", FIRST_PARTY)
227 .pipe(Effect.flip);
228 expect(removeError.message).toContain("cannot be removed");
229 }),
230 ),
231 );
232
233 it.effect("start with an undeclared first-party slug fails as client-not-found", () =>
234 Effect.scoped(
235 Effect.gen(function* () {
236 yield* serveOAuthTestServer({ scopes: ["read"] });
237 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
238 yield* executor.acme.seed();
239
240 const error = yield* executor.oauth
241 .start({
242 owner: "org",
243 client: firstPartyOAuthClientSlug("nope"),
244 clientOwner: "org",
245 name: ConnectionName.make("main"),
246 integration: INTEG,
247 template: TEMPLATE,
248 })
249 .pipe(Effect.flip);
250 // `OAuthStartError` carries a typed `message`; the `Predicate.isTagged`
251 // guard narrows the union so this read is on a typed failure.
252 expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true);
253 const startError = error as OAuthStartError;
254 expect(startError.message).toContain("not found");
255 }),
256 ),
257 );
258
259 it.effect("a Personal connection can mint through a first-party app", () =>
260 Effect.scoped(
261 Effect.gen(function* () {
262 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
263 const { executor } = yield* makeTestWorkspaceHarness({
264 plugins,
265 firstPartyOAuthClients: [firstPartyClientFor(server)],
266 });
267 yield* executor.acme.seed();
268
269 const started = yield* executor.oauth.start({
270 owner: "user",
271 client: FIRST_PARTY,
272 clientOwner: "org",
273 name: ConnectionName.make("mine"),
274 integration: INTEG,
275 template: TEMPLATE,
276 });
277 expect(started.status).toBe("redirect");
278 if (started.status !== "redirect") return;
279 const callback = yield* server.completeAuthorizationCodeFlow({
280 authorizationUrl: started.authorizationUrl,
281 });
282 const connection = yield* executor.oauth.complete({
283 state: started.state,
284 code: callback.code,
285 });
286 expect(String(connection.address)).toBe("tools.acme.user.mine");
287 }),
288 ),
289 );
290});
packages/core/sdk/src/oauth-service.ts+94−5
@@ -36,8 +36,11 @@ import { · 35 unmodified lines
36 OAuthRegisterDynamicError,36 OAuthRegisterDynamicError,
37 OAuthSessionNotFoundError,37 OAuthSessionNotFoundError,
38 OAuthStartError,38 OAuthStartError,
39 firstPartyOAuthClientSlug,
40 isFirstPartyOAuthClientSlug,
39 type ConnectResult,41 type ConnectResult,
40 type CreateOAuthClientInput,42 type CreateOAuthClientInput,
43 type FirstPartyOAuthClientConfig,
41 type OAuthClientOrigin,44 type OAuthClientOrigin,
42 type OAuthClientSummary,45 type OAuthClientSummary,
43 type OAuthCompleteInput,46 type OAuthCompleteInput,
@@ -174,6 +177,12 @@ export interface OAuthServiceDeps { · 130 unmodified lines
174 readonly redirectUri: string | null;177 readonly redirectUri: string | null;
175 /** URL selected organization slug to round-trip through OAuth `state`. */178 /** URL selected organization slug to round-trip through OAuth `state`. */
176 readonly callbackStateOrgSlug?: string | null;179 readonly callbackStateOrgSlug?: string | null;
180 /** Host-operated apps declared at composition time (`first-party:<name>`
181 * slugs). Resolved from config, never from storage: `loadClient` intercepts
182 * the prefix ahead of the DB, `listClients` appends their summaries, and the
183 * client CRUD surface rejects the namespace. Empty/omitted on hosts that
184 * ship no first-party apps. */
185 readonly firstPartyClients?: readonly FirstPartyOAuthClientConfig[];
177}186}
178 187
179type LooseDb = {188type LooseDb = {
@@ -490,9 +499,42 @@ const validateClientEndpoints = ( · 310 unmodified lines
490 }499 }
491 });500 });
492 501
502/** Resolve a config-declared first-party app to the loaded-client shape the
503 * flow/refresh paths consume. First-party apps are authorization_code only:
504 * client_credentials mints machine tokens under the OPERATOR's app identity,
505 * which must never be shared across tenants. */
506export const loadedFirstPartyClient = (
507 config: FirstPartyOAuthClientConfig,
508): {
509 readonly slug: string;
510 readonly authorizationUrl: string;
511 readonly tokenUrl: string;
512 readonly grant: OAuthGrant;
513 readonly clientId: string;
514 readonly clientSecret: string;
515 readonly resource: null;
516} => ({
517 slug: String(firstPartyOAuthClientSlug(config.name)),
518 authorizationUrl: config.authorizationUrl,
519 tokenUrl: config.tokenUrl,
520 grant: "authorization_code",
521 clientId: config.clientId,
522 clientSecret: config.clientSecret,
523 resource: null,
524});
525
493export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {526export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
494 const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer;527 const httpClientLayer = deps.httpClientLayer ?? FetchHttpClient.layer;
495 const fetch = deps.fetch;528 const fetch = deps.fetch;
529 // Config-declared first-party apps, keyed by their prefixed slug. Config is
530 // the source of truth — no row exists, so every stored-row path (CRUD, GC)
531 // is bypassed by construction, and rotating a secret is an env change.
532 const firstPartyBySlug = new Map(
533 (deps.firstPartyClients ?? []).map((client) => [
534 String(firstPartyOAuthClientSlug(client.name)),
535 client,
536 ]),
537 );
496 // EXPLICIT — no localhost default. `null` means this executor has no OAuth538 // EXPLICIT — no localhost default. `null` means this executor has no OAuth
497 // callback; redirect-requiring flows fail loudly via `requireRedirectUri`.539 // callback; redirect-requiring flows fail loudly via `requireRedirectUri`.
498 const redirectUri = deps.redirectUri;540 const redirectUri = deps.redirectUri;
@@ -597,6 +639,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 98 unmodified lines
597 input: CreateOAuthClientInput,639 input: CreateOAuthClientInput,
598 ): Effect.Effect<OAuthClientSlug, StorageFailure> =>640 ): Effect.Effect<OAuthClientSlug, StorageFailure> =>
599 Effect.gen(function* () {641 Effect.gen(function* () {
642 // The `first-party:` namespace is reserved for config-declared apps — a
643 // stored row under it would be shadowed by (or worse, impersonate) the
644 // host's own app.
645 if (isFirstPartyOAuthClientSlug(String(input.slug))) {
646 return yield* new StorageError({
647 message: `OAuth client slug "${String(input.slug)}" uses the reserved first-party namespace.`,
648 cause: undefined,
649 });
650 }
600 yield* validateClientEndpoints(input, deps.endpointUrlPolicy);651 yield* validateClientEndpoints(input, deps.endpointUrlPolicy);
601 const keys = yield* Effect.try({652 const keys = yield* Effect.try({
602 try: () => deps.ownedKeys(input.owner),653 try: () => deps.ownedKeys(input.owner),
@@ -682,6 +733,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 79 unmodified lines
682 // -----------------------------------------------------------------------733 // -----------------------------------------------------------------------
683 const removeClient = (owner: Owner, slug: OAuthClientSlug): Effect.Effect<void, StorageFailure> =>734 const removeClient = (owner: Owner, slug: OAuthClientSlug): Effect.Effect<void, StorageFailure> =>
684 Effect.gen(function* () {735 Effect.gen(function* () {
736 // Config-declared apps have no row to remove; removing one is an env
737 // change on the host, not a storage operation. Fail loudly rather than
738 // returning a success that changed nothing.
739 if (isFirstPartyOAuthClientSlug(String(slug))) {
740 return yield* new StorageError({
741 message: `OAuth client "${String(slug)}" is a first-party app declared in host config; it cannot be removed through this surface.`,
742 cause: undefined,
743 });
744 }
685 yield* deps.fuma745 yield* deps.fuma
686 .use("oauth_client.delete", (db) =>746 .use("oauth_client.delete", (db) =>
687 looseDb(db).deleteMany("oauth_client", {747 looseDb(db).deleteMany("oauth_client", {
@@ -953,8 +1013,24 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 265 unmodified lines
953 // tenant's org rows + this subject's own user rows, so no explicit filter is1013 // tenant's org rows + this subject's own user rows, so no explicit filter is
954 // needed. The `client_secret` column is deliberately never projected.1014 // needed. The `client_secret` column is deliberately never projected.
955 // -----------------------------------------------------------------------1015 // -----------------------------------------------------------------------
956 const listClients = (): Effect.Effect<readonly OAuthClientSummary[], StorageFailure> =>1016 const listClients = (): Effect.Effect<readonly OAuthClientSummary[], StorageFailure> => {
957 deps.fuma1017 // First-party apps lead the list: config-resolved, visible to every caller,
1018 // and projected exactly like stored rows — clientId only, never the secret.
1019 // Owner is reported as "org" (the widest visibility the summary shape can
1020 // express); the flow itself ignores owner for first-party slugs.
1021 const firstPartySummaries: readonly OAuthClientSummary[] = [...firstPartyBySlug.values()].map(
1022 (config) => ({
1023 owner: "org",
1024 slug: firstPartyOAuthClientSlug(config.name),
1025 grant: "authorization_code",
1026 authorizationUrl: config.authorizationUrl,
1027 tokenUrl: config.tokenUrl,
1028 resource: null,
1029 clientId: config.clientId,
1030 origin: { kind: "first_party", integrations: config.integrations ?? [] },
1031 }),
1032 );
1033 return deps.fuma
958 .use("oauth_client.findMany", (db) => looseDb(db).findMany("oauth_client", {}))1034 .use("oauth_client.findMany", (db) => looseDb(db).findMany("oauth_client", {}))
959 .pipe(1035 .pipe(
960 Effect.flatMap((rows) =>1036 Effect.flatMap((rows) =>
@@ -982,7 +1058,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 21 unmodified lines
982 } satisfies OAuthClientSummary);1058 } satisfies OAuthClientSummary);
983 }),1059 }),
984 ),1060 ),
1061 Effect.map((stored) => [...firstPartySummaries, ...stored]),
985 );1062 );
1063 };
986 1064
987 // -----------------------------------------------------------------------1065 // -----------------------------------------------------------------------
988 // Load an oauth_client row by (owner, slug).1066 // Load an oauth_client row by (owner, slug).
@@ -990,8 +1068,15 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1 unmodified line
990 const loadClient = (1068 const loadClient = (
991 owner: Owner,1069 owner: Owner,
992 slug: OAuthClientSlug,1070 slug: OAuthClientSlug,
993 ): Effect.Effect<LoadedOAuthClient | null, StorageFailure> =>1071 ): Effect.Effect<LoadedOAuthClient | null, StorageFailure> => {
994 deps.fuma1072 // First-party apps resolve from config, never storage. Owner is irrelevant:
1073 // the app belongs to the DEPLOYMENT, and visibility policy has nothing to
1074 // narrow — only the minted connection (and its tokens) is owner-scoped.
1075 if (isFirstPartyOAuthClientSlug(String(slug))) {
1076 const config = firstPartyBySlug.get(String(slug));
1077 return Effect.succeed(config ? loadedFirstPartyClient(config) : null);
1078 }
1079 return deps.fuma
995 .use("oauth_client.findFirst", (db) =>1080 .use("oauth_client.findFirst", (db) =>
996 looseDb(db).findFirst("oauth_client", {1081 looseDb(db).findFirst("oauth_client", {
997 where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))),1082 where: (b: any) => b.and(b("owner", "=", owner), b("slug", "=", String(slug))),
@@ -1038,6 +1123,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 40 unmodified lines
1038 });1123 });
1039 }),1124 }),
1040 );1125 );
1126 };
1041 1127
1042 // -----------------------------------------------------------------------1128 // -----------------------------------------------------------------------
1043 // start — begin a flow through a client to mint a connection.1129 // start — begin a flow through a client to mint a connection.
@@ -1058,7 +1144,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 14 unmodified lines
1058 // cannot be backed by a member's private (user) app. The connection owner1144 // cannot be backed by a member's private (user) app. The connection owner
1059 // and the app owner are otherwise independent — a Personal connection1145 // and the app owner are otherwise independent — a Personal connection
1060 // through a shared Workspace app is the supported cross-owner case.1146 // through a shared Workspace app is the supported cross-owner case.
1061 if (input.owner === "org" && input.clientOwner === "user") {1147 // First-party apps are deployment-owned, outside the owner lattice
1148 // entirely, so the rule does not apply to them.
1149 const firstPartyFlow = isFirstPartyOAuthClientSlug(String(input.client));
1150 if (!firstPartyFlow && input.owner === "org" && input.clientOwner === "user") {
1062 return yield* new OAuthStartError({1151 return yield* new OAuthStartError({
1063 message: "A Workspace connection must use a Workspace app.",1152 message: "A Workspace connection must use a Workspace app.",
1064 });1153 });
packages/core/sdk/src/shared.ts+4−0
@@ -144,6 +144,10 @@ export { · 143 unmodified lines
144 144
145// OAuth wire contracts (data + tagged errors; the flow impl is server-only).145// OAuth wire contracts (data + tagged errors; the flow impl is server-only).
146export {146export {
147 FIRST_PARTY_OAUTH_CLIENT_PREFIX,
148 firstPartyOAuthClientSlug,
149 isFirstPartyOAuthClientSlug,
150 type FirstPartyOAuthClientConfig,
147 type OAuthGrant,151 type OAuthGrant,
148 type OAuthAuthentication,152 type OAuthAuthentication,
149 type OAuthClient,153 type OAuthClient,
packages/core/sdk/src/test-config.ts+2−0
@@ -123,6 +123,7 @@ export type TestConfigOptions<TPlugins extends readonly AnyPlugin[] = readonly [ · 122 unmodified lines
123 readonly redirectUri?: string | null;123 readonly redirectUri?: string | null;
124 readonly oauthCallbackStateOrgSlug?: string;124 readonly oauthCallbackStateOrgSlug?: string;
125 readonly onIntegrationChange?: ExecutorConfig<TPlugins>["onIntegrationChange"];125 readonly onIntegrationChange?: ExecutorConfig<TPlugins>["onIntegrationChange"];
126 readonly firstPartyOAuthClients?: ExecutorConfig<TPlugins>["firstPartyOAuthClients"];
126};127};
127 128
128export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>(129export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>(
@@ -163,6 +164,7 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = rea · 34 unmodified lines
163 onIntegrationChange: options?.onIntegrationChange,164 onIntegrationChange: options?.onIntegrationChange,
164 ...(redirectUri != null ? { redirectUri } : {}),165 ...(redirectUri != null ? { redirectUri } : {}),
165 oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug,166 oauthCallbackStateOrgSlug: options?.oauthCallbackStateOrgSlug,
167 firstPartyOAuthClients: options?.firstPartyOAuthClients,
166 };168 };
167};169};
168 170
packages/plugins/openapi/src/sdk/presets.ts+16−0
@@ -16,6 +16,21 @@ export interface OpenApiPreset { · 15 unmodified lines
16 readonly healthCheck?: HealthCheckSpec;16 readonly healthCheck?: HealthCheckSpec;
17}17}
18 18
19/** GitHub OAuth-app flow (classic apps, not GitHub Apps): scopes are requested
20 * at authorize time, tokens don't expire, and the token endpoint returns
21 * form-encoded unless the request sends `Accept: application/json` — which the
22 * SDK's token exchange always does (oauth4webapi sets it). `repo` + `read:org`
23 * + `user:email` covers the common repo/issue/PR surface without admin grants. */
24export const GITHUB_SUPPORTED_OAUTH_SCOPES = ["repo", "read:org", "user:email"] as const;
25
26export const GITHUB_OAUTH_TEMPLATE: IntegrationPresetAuthentication = {
27 slug: "oauth2",
28 kind: "oauth2",
29 authorizationUrl: "https://github.com/login/oauth/authorize",
30 tokenUrl: "https://github.com/login/oauth/access_token",
31 scopes: GITHUB_SUPPORTED_OAUTH_SCOPES,
32};
33
19export const FIGMA_SUPPORTED_OAUTH_SCOPES = [34export const FIGMA_SUPPORTED_OAUTH_SCOPES = [
20 "current_user:read",35 "current_user:read",
21 "file_comments:read",36 "file_comments:read",
@@ -68,6 +83,7 @@ const openApiOnlyPresets: readonly OpenApiPreset[] = [ · 46 unmodified lines
68 url: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json",83 url: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json",
69 icon: "https://svgl.app/library/github_dark.svg",84 icon: "https://svgl.app/library/github_dark.svg",
70 featured: true,85 featured: true,
86 authTemplate: [GITHUB_OAUTH_TEMPLATE],
71 },87 },
72 {88 {
73 id: "vercel",89 id: "vercel",
packages/react/src/components/add-account-modal.tsx+12−3
@@ -876,11 +876,18 @@ function OAuthAppRadioRow(props: { · 875 unmodified lines
876 <span className="min-w-0 flex-1">876 <span className="min-w-0 flex-1">
877 <span className="block text-sm font-medium">{clientDisplayName(String(app.slug))}</span>877 <span className="block text-sm font-medium">{clientDisplayName(String(app.slug))}</span>
878 <span className="block truncate text-xs text-muted-foreground">878 <span className="block truncate text-xs text-muted-foreground">
879 {clientHost(app.tokenUrl)} ·{" "}879 {app.origin.kind === "first_party"
880 {app.grant === "client_credentials" ? "app-to-app" : "you'll sign in"}880 ? "No setup needed · you'll sign in"
881 : `${clientHost(app.tokenUrl)} · ${
882 app.grant === "client_credentials" ? "app-to-app" : "you'll sign in"
883 }`}
881 </span>884 </span>
882 </span>885 </span>
883 {showOwnerLabel ? <Badge variant="outline">{ownerLabel(app.owner)}</Badge> : null}886 {app.origin.kind === "first_party" ? (
887 <Badge variant="outline">Built-in</Badge>
888 ) : showOwnerLabel ? (
889 <Badge variant="outline">{ownerLabel(app.owner)}</Badge>
890 ) : null}
884 </Label>891 </Label>
885 {onManage ? (892 {onManage ? (
886 <DropdownMenu>893 <DropdownMenu>
@@ -1595,6 +1602,8 @@ function AddAccountModalView(props: AddAccountModalProps) { · 708 unmodified lines
1595 const manageHandlersFor = (1602 const manageHandlersFor = (
1596 appOption: OAuthClientOption,1603 appOption: OAuthClientOption,
1597 ): { readonly onEdit: () => void; readonly onRemove: () => void } | undefined => {1604 ): { readonly onEdit: () => void; readonly onRemove: () => void } | undefined => {
1605 // First-party apps are host config, not rows: nothing to edit or remove.
1606 if (appOption.origin.kind === "first_party") return undefined;
1598 const summary = clientSummaries.find(1607 const summary = clientSummaries.find(
1599 (c: OAuthClientSummary) =>1608 (c: OAuthClientSummary) =>
1600 c.owner === appOption.owner && String(c.slug) === String(appOption.slug),1609 c.owner === appOption.owner && String(c.slug) === String(appOption.slug),
packages/react/src/plugins/use-effective-oauth-client.test.ts+59−0
@@ -267,6 +267,65 @@ describe("selectClientsForEndpoints", () => { · 266 unmodified lines
267 "spotify-app-2",267 "spotify-app-2",
268 ]);268 ]);
269 });269 });
270
271 it("ranks a first-party app above BYO apps when both match", () => {
272 const integration = IntegrationSlug.make("github_rest");
273 const byo = app("my-github-app", {
274 authorizationUrl: "https://github.com/login/oauth/authorize",
275 tokenUrl: "https://github.com/login/oauth/access_token",
276 });
277 const firstParty = app("first-party:github", {
278 owner: "org",
279 authorizationUrl: "https://github.com/login/oauth/authorize",
280 tokenUrl: "https://github.com/login/oauth/access_token",
281 origin: { kind: "first_party", integrations: [integration] },
282 });
283 const result = selectClientsForEndpoints([byo, firstParty], {
284 authorizationUrl: "https://github.com/login/oauth/authorize",
285 tokenUrl: "https://github.com/login/oauth/access_token",
286 integration,
287 });
288 expect(result.endpointMatched).toBe(true);
289 // First-party leads despite being org-owned (user-owned normally sorts first).
290 expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([
291 "first-party:github",
292 "my-github-app",
293 ]);
294 });
295
296 it("intent-matches a first-party app to its declared integrations even without endpoints", () => {
297 const integration = IntegrationSlug.make("github_rest");
298 const firstParty = app("first-party:github", {
299 owner: "org",
300 authorizationUrl: "https://github.com/login/oauth/authorize",
301 tokenUrl: "https://github.com/login/oauth/access_token",
302 origin: { kind: "first_party", integrations: [integration] },
303 });
304 const result = selectClientsForEndpoints([firstParty], {
305 requireEndpointMatch: true,
306 integration,
307 });
308 expect(result.endpointMatched).toBe(true);
309 expect(result.matched.map((a: OAuthClientOption) => String(a.slug))).toEqual([
310 "first-party:github",
311 ]);
312 });
313
314 it("does not surface a first-party app for an unrelated integration", () => {
315 const firstParty = app("first-party:github", {
316 owner: "org",
317 authorizationUrl: "https://github.com/login/oauth/authorize",
318 tokenUrl: "https://github.com/login/oauth/access_token",
319 origin: { kind: "first_party", integrations: [IntegrationSlug.make("github_rest")] },
320 });
321 const result = selectClientsForEndpoints([firstParty], {
322 authorizationUrl: "https://accounts.spotify.com/authorize",
323 tokenUrl: "https://accounts.spotify.com/api/token",
324 integration: IntegrationSlug.make("spotify"),
325 });
326 expect(result.endpointMatched).toBe(false);
327 expect(result.matched).toEqual([]);
328 });
270});329});
271 330
272describe("selectDcrClientsForIntegration", () => {331describe("selectDcrClientsForIntegration", () => {
packages/react/src/plugins/use-effective-oauth-client.tsx+31−12
@@ -47,6 +47,12 @@ export interface OAuthClientOption { · 46 unmodified lines
47export const isDcrClient = (app: OAuthClientOption): boolean =>47export const isDcrClient = (app: OAuthClientOption): boolean =>
48 app.origin.kind === "dynamic_client_registration";48 app.origin.kind === "dynamic_client_registration";
49 49
50/** True for host-operated first-party apps (config-declared, `first-party:`
51 * slugs). They rank ABOVE user/workspace apps when they match an integration:
52 * the one-click "nothing to paste" path is the default, BYO the escape hatch. */
53export const isFirstPartyClient = (app: OAuthClientOption): boolean =>
54 app.origin.kind === "first_party";
55
50const hostOf = (url: string): string | undefined => {56const hostOf = (url: string): string | undefined => {
51 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host"57 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host"
52 try {58 try {
@@ -115,12 +121,14 @@ const EMPTY_CLIENTS: readonly OAuthClientOption[] = []; · 62 unmodified lines
115const hostEq = (a: string | undefined, b: string | undefined): boolean =>121const hostEq = (a: string | undefined, b: string | undefined): boolean =>
116 a !== undefined && b !== undefined && a === b;122 a !== undefined && b !== undefined && a === b;
117 123
118/** Sort apps user-owned first (so the user's own apps surface before shared124/** Sort first-party apps first (the one-click default), then user-owned before
119 * workspace apps). */125 * shared workspace apps. */
120const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] =>126const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] =>
121 [...apps].sort((a: OAuthClientOption, b: OAuthClientOption) =>127 [...apps].sort((a: OAuthClientOption, b: OAuthClientOption) => {
122 a.owner === b.owner ? 0 : a.owner === "user" ? -1 : 1,128 const aFirstParty = isFirstPartyClient(a);
123 );129 if (aFirstParty !== isFirstPartyClient(b)) return aFirstParty ? -1 : 1;
130 return a.owner === b.owner ? 0 : a.owner === "user" ? -1 : 1;
131 });
124 132
125/**133/**
126 * Pure matcher (no React/atoms) — split owner-visible apps into three honest134 * Pure matcher (no React/atoms) — split owner-visible apps into three honest
@@ -170,11 +178,19 @@ export function selectClientsForEndpoints( · 43 unmodified lines
170 const manual = all.filter((app) => !isDcrClient(app));178 const manual = all.filter((app) => !isDcrClient(app));
171 179
172 const intent = endpoints.integration;180 const intent = endpoints.integration;
173 const matchesIntent = (app: OAuthClientOption): boolean =>181 const matchesIntent = (app: OAuthClientOption): boolean => {
174 intent != null &&182 if (intent == null) return false;
175 app.origin.kind === "manual" &&183 // A first-party app declaring this integration is intent-matched the same
176 app.origin.integration != null &&184 // way a BYO app registered from this dialog is.
177 app.origin.integration === intent;185 if (app.origin.kind === "first_party") {
186 return (app.origin.integrations ?? []).includes(intent);
187 }
188 return (
189 app.origin.kind === "manual" &&
190 app.origin.integration != null &&
191 app.origin.integration === intent
192 );
193 };
178 194
179 const wantedTokenHost = endpoints.tokenUrl ? hostOf(endpoints.tokenUrl) : undefined;195 const wantedTokenHost = endpoints.tokenUrl ? hostOf(endpoints.tokenUrl) : undefined;
180 const wantedAuthorizationHost = endpoints.authorizationUrl196 const wantedAuthorizationHost = endpoints.authorizationUrl
@@ -377,9 +393,12 @@ export function optimisticDcrClientSlug(issuerOrEndpoint: string): OAuthClientSl · 196 unmodified lines
377 return OAuthClientSlug.make(`dcr-${base || "authorization-server"}`);393 return OAuthClientSlug.make(`dcr-${base || "authorization-server"}`);
378}394}
379 395
380/** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). */396/** Humanize a client slug for display ("spotify-prod" → "Spotify prod").
397 * First-party slugs drop their namespace prefix ("first-party:github" →
398 * "Github") — the row's badge already says it's the built-in app. */
381export function clientDisplayName(slug: string): string {399export function clientDisplayName(slug: string): string {
382 const text = slug.replace(/[-_]/g, " ").trim();400 const bare = slug.startsWith("first-party:") ? slug.slice("first-party:".length) : slug;
401 const text = bare.replace(/[-_]/g, " ").trim();
383 return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : slug;402 return text.length > 0 ? text.charAt(0).toUpperCase() + text.slice(1) : slug;
384}403}
385 404