Unified · Split
| @@ -43,7 +43,7 @@ import { · 42 unmodified lines | |||
| 43 | collectTables, | 43 | collectTables, |
| 44 | } from "@executor-js/api/server"; | 44 | } from "@executor-js/api/server"; |
| 45 | import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; | 45 | import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker"; |
| 46 | import type { AnyPlugin } from "@executor-js/sdk"; | 46 | import type { AnyPlugin, FirstPartyOAuthClientConfig } from "@executor-js/sdk"; |
| 47 | 47 | ||
| 48 | import executorConfig from "../../executor.config"; | 48 | import executorConfig from "../../executor.config"; |
| 49 | import { DbService } from "../db/db"; | 49 | import { DbService } from "../db/db"; |
| @@ -88,6 +88,35 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 38 unmodified lines | |||
| 88 | */ | 88 | */ |
| 89 | export const CLOUD_MOUNT_PREFIX = "/api" as const; | 89 | export 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. | ||
| 95 | const 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 | |||
| 91 | export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({ | 120 | export 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 a | 128 | // 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 | ||
| 104 | export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync( | 134 | export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync( |
| @@ -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 | // Billing | 66 | // 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). */ |
| @@ -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. | ||
| 14 | import { randomBytes } from "node:crypto"; | ||
| 15 | |||
| 16 | import { expect } from "@effect/vitest"; | ||
| 17 | import { Effect } from "effect"; | ||
| 18 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 19 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 20 | import { | ||
| 21 | AuthTemplateSlug, | ||
| 22 | ConnectionName, | ||
| 23 | IntegrationSlug, | ||
| 24 | OAuthClientSlug, | ||
| 25 | } from "@executor-js/sdk/shared"; | ||
| 26 | |||
| 27 | import { scenario } from "../src/scenario"; | ||
| 28 | import { Api, Target } from "../src/services"; | ||
| 29 | |||
| 30 | const api = composePluginApi([openApiHttpPlugin()] as const); | ||
| 31 | |||
| 32 | const 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. */ | ||
| 36 | const 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 | |||
| 65 | scenario( | ||
| 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 | ); | ||
| @@ -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 number | 102 | // Shrink the per-org hourly execution cap (prod default 1000) to a number |
| 98 | // the rate-limit-backstop scenario can actually exhaust with real | 103 | // the rate-limit-backstop scenario can actually exhaust with real |
| 99 | // executions — but see execution-limits.ts: it must stay above every other | 104 | // executions — but see execution-limits.ts: it must stay above every other |
| @@ -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 | ||
| @@ -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"; |
| 46 | import { | 47 | import { |
| @@ -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 | ||
| 102 | export class HostConfig extends Context.Service<HostConfig, HostConfigShape>()( | 111 | export 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, |
| @@ -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"; |
| 111 | import type { OAuthService } from "./oauth-client"; | 111 | import { isFirstPartyOAuthClientSlug, type OAuthService } from "./oauth-client"; |
| 112 | import type { FirstPartyOAuthClientConfig } from "./oauth-client"; | ||
| 112 | import { | 113 | import { |
| 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-facing | 638 | * 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 an | 1782 | /** 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 Personal | 1868 | // 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: a | 1904 | // Re-request the scopes this connection was GRANTED (RFC 6749 §6: a |
| 1856 | // refresh must not exceed the originally-granted scope). Empty → omit | 1905 | // 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 was | 1911 | // Refresh against the region the code was redeemed at when one was |
| 1863 | // recorded at connect time (multi-site providers like Datadog), else | 1912 | // 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_url | 1914 | 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 — the | 1916 | // client_credentials (machine-to-machine) has NO refresh token — the |
| 1870 | // token is RE-MINTED from the client id/secret. The authorization_code | 1917 | // 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) from | 1919 | // 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 resource | 1959 | // 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 | // ------------------------------------------------------------------ |
| @@ -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, |
| @@ -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. */ | ||
| 99 | export const FIRST_PARTY_OAUTH_CLIENT_PREFIX = "first-party:"; | ||
| 100 | |||
| 101 | export const isFirstPartyOAuthClientSlug = (slug: string): boolean => | ||
| 102 | slug.startsWith(FIRST_PARTY_OAUTH_CLIENT_PREFIX); | ||
| 103 | |||
| 104 | export 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. */ | ||
| 113 | export 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 | |||
| 82 | export type CreateOAuthClientInput = OAuthClient & { | 127 | export 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 servers | 133 | * entry. Persisted so reuse can detect a changed callback (strict servers |
| @@ -0,0 +1,290 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Predicate } from "effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | AuthTemplateSlug, | ||
| 6 | ConnectionName, | ||
| 7 | IntegrationSlug, | ||
| 8 | OAuthClientSlug, | ||
| 9 | ToolAddress, | ||
| 10 | ToolName, | ||
| 11 | } from "./ids"; | ||
| 12 | import { | ||
| 13 | firstPartyOAuthClientSlug, | ||
| 14 | type FirstPartyOAuthClientConfig, | ||
| 15 | type OAuthStartError, | ||
| 16 | } from "./oauth-client"; | ||
| 17 | import { definePlugin } from "./plugin"; | ||
| 18 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; | ||
| 19 | import { 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 | |||
| 28 | const INTEG = IntegrationSlug.make("acme"); | ||
| 29 | const TEMPLATE = AuthTemplateSlug.make("oauth"); | ||
| 30 | const FIRST_PARTY = firstPartyOAuthClientSlug("acme"); | ||
| 31 | |||
| 32 | const 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 | |||
| 67 | const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; | ||
| 68 | |||
| 69 | const 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 | |||
| 81 | describe("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 | }); | ||
| @@ -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 | ||
| 179 | type LooseDb = { | 188 | type 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. */ | ||
| 506 | export 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 | |||
| 493 | export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { | 526 | export 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 OAuth | 538 | // 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.fuma | 745 | 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 is | 1013 | // 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.fuma | 1017 | // 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.fuma | 1072 | // 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 owner | 1144 | // cannot be backed by a member's private (user) app. The connection owner |
| 1059 | // and the app owner are otherwise independent — a Personal connection | 1145 | // 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 | }); |
| @@ -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). |
| 146 | export { | 146 | export { |
| 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, |
| @@ -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 | ||
| 128 | export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = readonly []>( | 129 | export 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 | ||
| @@ -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. */ | ||
| 24 | export const GITHUB_SUPPORTED_OAUTH_SCOPES = ["repo", "read:org", "user:email"] as const; | ||
| 25 | |||
| 26 | export 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 | |||
| 19 | export const FIGMA_SUPPORTED_OAUTH_SCOPES = [ | 34 | export 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", |
| @@ -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), |
| @@ -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 | ||
| 272 | describe("selectDcrClientsForIntegration", () => { | 331 | describe("selectDcrClientsForIntegration", () => { |
| @@ -47,6 +47,12 @@ export interface OAuthClientOption { · 46 unmodified lines | |||
| 47 | export const isDcrClient = (app: OAuthClientOption): boolean => | 47 | export 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. */ | ||
| 53 | export const isFirstPartyClient = (app: OAuthClientOption): boolean => | ||
| 54 | app.origin.kind === "first_party"; | ||
| 55 | |||
| 50 | const hostOf = (url: string): string | undefined => { | 56 | const 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 | |||
| 115 | const hostEq = (a: string | undefined, b: string | undefined): boolean => | 121 | const 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 shared | 124 | /** Sort first-party apps first (the one-click default), then user-owned before |
| 119 | * workspace apps). */ | 125 | * shared workspace apps. */ |
| 120 | const sortUserFirst = (apps: readonly OAuthClientOption[]): readonly OAuthClientOption[] => | 126 | const 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 honest | 134 | * 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.authorizationUrl | 196 | 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. */ | ||
| 381 | export function clientDisplayName(slug: string): string { | 399 | export 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 | ||