diff --git a/.changeset/28866-installer-oauth-client.md b/.changeset/28866-installer-oauth-client.md new file mode 100644 index 00000000000..85fe231265a --- /dev/null +++ b/.changeset/28866-installer-oauth-client.md @@ -0,0 +1,30 @@ +--- +'@objectstack/spec': patch +'@objectstack/service-automation': patch +'@objectstack/runtime': patch +--- + +feat(automation): an `oauth2` connector instance with `clientSource: 'installer'` connects with the OAuth client its administrator entered, and uses it for every exchange and refresh + +Clause-②: yes (narrowing) +Metadata-protocol: none +Ruling-ref: 6566728470 + + + +**BREAKING** for three call shapes only, each of which used to be answered as if its extra input were absent and now answers `400 VALIDATION_ERROR` with nothing connected, read or revoked: a `POST /api/v1/automation/connectors/NAME/connection` whose JSON body carries a key other than `clientId` and `clientSecret`; the same begin carrying a client for an instance that does not declare `clientSource: 'installer'`; and an in-process `getConnectorConnection` or `revokeConnectorConnection` call whose request carries `client`. What to do: nothing, unless a caller sends a body to that begin leg. Send none (or `{}`) for an instance that declares its client or leaves it to MCP dynamic registration, and send only `clientId` and `clientSecret` for one that takes the installer's. + +An `authorization_code` instance that declares `clientSource: 'installer'` names no client in its definition. Its administrator registers a client with the vendor against this deployment's redirect URI and hands it to the connect door: + +```http +POST /api/v1/automation/connectors/esign/connection +{ "clientId": "CLIENT_ID", "clientSecret": "CLIENT_SECRET" } +``` + +- **What it does.** The authorization request, the code exchange and every refresh of that connection go out as the administrator's client. Each organization's connection keeps its own client, and a reconnect replaces it. On `provider: 'mcp'` over http the MCP authorization flow still discovers the endpoints, and registers nothing. +- **Where the client is kept.** In the connection record's encrypted client column, the one that keeps a dynamically registered client. The id alone is also kept in plain text, so the connection's read shows `client: { source: 'installer', clientId, hasSecret }`. The secret is never answered back, logged or written to metadata. +- **What it refuses (400 `VALIDATION_ERROR`).** A connect of such an instance with no client: the message names the instance and the vendor registration step, with the redirect URI to register. A client offered to an instance that declares its own client or leaves it to dynamic registration, which used to be ignored. A body key other than `clientId` and `clientSecret` on the begin leg, which used to be ignored too. A client on the read or revoke leg. No refusal repeats a value the caller sent. +- **Contracts.** `ConnectorConnectionRequest` gains an optional `client`, and `ConnectorConnectionState` an optional `client`. +- **Unchanged.** An instance that declares its `clientId`, or leaves its client to MCP dynamic registration, connects, exchanges and refreshes exactly as before, and a begin with no body is still a begin with no client. + +The `clientSource` key no longer carries `[PLANNED — not enforced]`, and the liveness ledger's `connector/auth` row no longer calls it planned. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index 6f3851b2d6f..82904afbd4d 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -558,7 +558,11 @@ What the publish door checks, each refusal located at its key under `auth`: `authorization_code`. The one exception is `provider: 'mcp'` over an `http` transport, which may omit the endpoints and the client id: the MCP authorization flow discovers the server's metadata and can register the - client. `client_credentials` has no authorization step, so + client. An `authorization_code` instance may instead declare + `clientSource: 'installer'` and name no `clientId` and no `credentialRef`: + the administrator of each deployment registers their own client with the + vendor and enters it when they connect (below); a `clientId` beside it is + refused. `client_credentials` has no authorization step, so `authorizationUrl` is refused there, and it requires `credentialRef`. A `credentialRef` without its `clientId` is refused. An inline `clientSecret`, `refreshToken` or `accessToken` is refused by name, pointing @@ -640,6 +644,17 @@ a rotating refresh token is never spent twice. DELETE /api/v1/automation/connectors/:name/connection → revoke ``` + An instance declaring `clientSource: 'installer'` takes its client in the + connect's body, `{ "clientId": "…", "clientSecret": "…" }` (omit + `clientSecret` for a public client). Register that client with the vendor + first, with the redirect URI `/api/v1/automation/connectors/oauth/callback`. + The authorization request, the code exchange and every refresh of that + organization's connection then go out as that client, and a reconnect may + bring a new one. The connection keeps it encrypted; the status read shows + `client: { source: 'installer', clientId, hasSecret }`, never the secret. A + connect of such an instance without a client, a client offered to any other + instance, and any other body key are refused `400 VALIDATION_ERROR`. + **In the Console**, **Setup → Integrations → Connector Connections** drives these calls; the entry is shown to holders of `manage_connector_connections`. It lists each `authorization_code` instance with the organization's status, diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index c2266a42aba..ebbd1652b83 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -270,7 +270,7 @@ const result = ConnectorSchema.parse(data); | **clientId** | `string` | optional | OAuth2 client id of a pre-registered client (not a secret). Required, except where the client comes from elsewhere: `provider: 'mcp'` over http may register one dynamically, and with `clientSource: 'installer'` the administrator enters it on the connection, so it is not named here. | | **grant** | `'authorization_code'` | ✅ | An administrator connects once; the platform refreshes the grant per dispatch. | | **authorizationUrl** | `string` | optional | Authorization endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | -| **clientSource** | `Enum<'installer'>` | optional | [PLANNED — not enforced] Where the OAuth client comes from. `'installer'`: the administrator of each deployment registers their own client with the vendor, against that deployment's callback, and enters its id and secret on the instance's connection, so the definition names no `clientId` and no `credentialRef`. Omit it to name a pre-registered `clientId` here, or to let `provider: 'mcp'` over http register a client. Not served yet: the automation service reads it once the server half of the ADR-0143 D2.1 amendment lands. Until then, off `provider: 'mcp'` over http, an instance that declares it is held degraded for a missing `clientId`; on `provider: 'mcp'` over http the provider registers a client dynamically instead of taking the installer's. | +| **clientSource** | `Enum<'installer'>` | optional | Where the OAuth client comes from. `'installer'`: the administrator of each deployment registers their own client with the vendor, against that deployment's callback, and enters its id and secret on the instance's connection, so the definition names no `clientId` and no `credentialRef`. Omit it to name a pre-registered `clientId` here, or to let `provider: 'mcp'` over http register a client. The connection keeps the client encrypted, and every authorization, code exchange and refresh of that connection uses it (ADR-0143 D2.1). | | **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a public client, and with `clientSource: 'installer'`, where the administrator enters the secret on the connection. Never an inline secret. | ### Nested Shape: `Connector.auth[option 5][grant='client_credentials']` @@ -565,7 +565,7 @@ This schema accepts one of the following structures: | **clientId** | `string` | optional | OAuth2 client id of a pre-registered client (not a secret). Required, except where the client comes from elsewhere: `provider: 'mcp'` over http may register one dynamically, and with `clientSource: 'installer'` the administrator enters it on the connection, so it is not named here. | | **grant** | `'authorization_code'` | ✅ | An administrator connects once; the platform refreshes the grant per dispatch. | | **authorizationUrl** | `string` | optional | Authorization endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | -| **clientSource** | `Enum<'installer'>` | optional | [PLANNED — not enforced] Where the OAuth client comes from. `'installer'`: the administrator of each deployment registers their own client with the vendor, against that deployment's callback, and enters its id and secret on the instance's connection, so the definition names no `clientId` and no `credentialRef`. Omit it to name a pre-registered `clientId` here, or to let `provider: 'mcp'` over http register a client. Not served yet: the automation service reads it once the server half of the ADR-0143 D2.1 amendment lands. Until then, off `provider: 'mcp'` over http, an instance that declares it is held degraded for a missing `clientId`; on `provider: 'mcp'` over http the provider registers a client dynamically instead of taking the installer's. | +| **clientSource** | `Enum<'installer'>` | optional | Where the OAuth client comes from. `'installer'`: the administrator of each deployment registers their own client with the vendor, against that deployment's callback, and enters its id and secret on the instance's connection, so the definition names no `clientId` and no `credentialRef`. Omit it to name a pre-registered `clientId` here, or to let `provider: 'mcp'` over http register a client. The connection keeps the client encrypted, and every authorization, code exchange and refresh of that connection uses it (ADR-0143 D2.1). | | **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a public client, and with `clientSource: 'installer'`, where the administrator enters the secret on the connection. Never an inline secret. | --- @@ -604,7 +604,7 @@ This schema accepts one of the following structures: | **clientId** | `string` | optional | OAuth2 client id of a pre-registered client (not a secret). Required, except where the client comes from elsewhere: `provider: 'mcp'` over http may register one dynamically, and with `clientSource: 'installer'` the administrator enters it on the connection, so it is not named here. | | **grant** | `'authorization_code'` | ✅ | An administrator connects once; the platform refreshes the grant per dispatch. | | **authorizationUrl** | `string` | optional | Authorization endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | -| **clientSource** | `Enum<'installer'>` | optional | [PLANNED — not enforced] Where the OAuth client comes from. `'installer'`: the administrator of each deployment registers their own client with the vendor, against that deployment's callback, and enters its id and secret on the instance's connection, so the definition names no `clientId` and no `credentialRef`. Omit it to name a pre-registered `clientId` here, or to let `provider: 'mcp'` over http register a client. Not served yet: the automation service reads it once the server half of the ADR-0143 D2.1 amendment lands. Until then, off `provider: 'mcp'` over http, an instance that declares it is held degraded for a missing `clientId`; on `provider: 'mcp'` over http the provider registers a client dynamically instead of taking the installer's. | +| **clientSource** | `Enum<'installer'>` | optional | Where the OAuth client comes from. `'installer'`: the administrator of each deployment registers their own client with the vendor, against that deployment's callback, and enters its id and secret on the instance's connection, so the definition names no `clientId` and no `credentialRef`. Omit it to name a pre-registered `clientId` here, or to let `provider: 'mcp'` over http register a client. The connection keeps the client encrypted, and every authorization, code exchange and refresh of that connection uses it (ADR-0143 D2.1). | | **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a public client, and with `clientSource: 'installer'`, where the administrator enters the secret on the connection. Never an inline secret. | @@ -876,7 +876,7 @@ Connector type | **clientId** | `string` | optional | OAuth2 client id of a pre-registered client (not a secret). Required, except where the client comes from elsewhere: `provider: 'mcp'` over http may register one dynamically, and with `clientSource: 'installer'` the administrator enters it on the connection, so it is not named here. | | **grant** | `'authorization_code'` | ✅ | An administrator connects once; the platform refreshes the grant per dispatch. | | **authorizationUrl** | `string` | optional | Authorization endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | -| **clientSource** | `Enum<'installer'>` | optional | [PLANNED — not enforced] Where the OAuth client comes from. `'installer'`: the administrator of each deployment registers their own client with the vendor, against that deployment's callback, and enters its id and secret on the instance's connection, so the definition names no `clientId` and no `credentialRef`. Omit it to name a pre-registered `clientId` here, or to let `provider: 'mcp'` over http register a client. Not served yet: the automation service reads it once the server half of the ADR-0143 D2.1 amendment lands. Until then, off `provider: 'mcp'` over http, an instance that declares it is held degraded for a missing `clientId`; on `provider: 'mcp'` over http the provider registers a client dynamically instead of taking the installer's. | +| **clientSource** | `Enum<'installer'>` | optional | Where the OAuth client comes from. `'installer'`: the administrator of each deployment registers their own client with the vendor, against that deployment's callback, and enters its id and secret on the instance's connection, so the definition names no `clientId` and no `credentialRef`. Omit it to name a pre-registered `clientId` here, or to let `provider: 'mcp'` over http register a client. The connection keeps the client encrypted, and every authorization, code exchange and refresh of that connection uses it (ADR-0143 D2.1). | | **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a public client, and with `clientSource: 'installer'`, where the administrator enters the secret on the connection. Never an inline secret. | ### Nested Shape: `DeclarativeConnectorEntry.auth[option 5][grant='client_credentials']` diff --git a/packages/qa/dogfood/test/connector-installer-oauth-client.dogfood.test.ts b/packages/qa/dogfood/test/connector-installer-oauth-client.dogfood.test.ts new file mode 100644 index 00000000000..7a6a1a53f49 --- /dev/null +++ b/packages/qa/dogfood/test/connector-installer-oauth-client.dogfood.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D2.1 (2026-11-02 amendment) — an `oauth2` `authorization_code` +// connector instance declaring `clientSource: 'installer'` names no client in +// its definition: the administrator of each deployment registers a client +// with the vendor and enters it on the connection. Booted on the showcase with +// its own connector plugins, through the real HTTP connection door: +// +// - SERVED: saved over `PUT /meta/connector/:name`, the instance is held "not +// connected" until a connect, never degraded for a missing `clientId`. +// - ⛔ REFUSED: a begin with no client, and a begin body carrying a key other +// than `clientId` / `clientSecret`, answer `400 VALIDATION_ERROR` and store +// nothing; no refusal repeats a value the caller sent. +// - CONNECTED: a begin with the administrator's client sends the browser to +// the vendor as that client; the callback exchanges the code as it (its +// secret in HTTP Basic); the connection's read shows which client is set, +// never its secret; the stored row keeps the id in plain text and the +// secret only behind the masked column; revoke removes it. +// +// Every credential below is an obviously-fake placeholder, and every +// assertion on the secret is its absence from what a door served. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { type VerifyStack } from '@objectstack/verify'; +import { bootShowcase } from './showcase-boot.js'; + +const SYSTEM = { isSystem: true, positions: [], permissions: [] }; + +const NAME = 'cioc_vendor'; +const INSTALLER_ID = 'TEST_PLACEHOLDER_INSTALLER_CLIENT_ID'; +const INSTALLER_SECRET = 'TEST_PLACEHOLDER_INSTALLER_CLIENT_SECRET'; +const CODE = 'TEST_PLACEHOLDER_AUTHORIZATION_CODE'; +const ACCESS_TOKEN = 'TEST_PLACEHOLDER_ACCESS_TOKEN'; +const REFRESH_TOKEN = 'TEST_PLACEHOLDER_REFRESH_TOKEN'; + +const door = `/automation/connectors/${NAME}/connection`; +const basicOf = (id: string, secret: string) => `Basic ${Buffer.from(`${id}:${secret}`).toString('base64')}`; +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : Array.isArray(r?.value) ? r.value : []); +const dataOf = (json: any): any => json?.data ?? json; +const errorOf = (json: any): { code?: string; message?: string } => { + const e = json?.error; + return typeof e === 'object' && e !== null ? e : { code: json?.code, message: typeof e === 'string' ? e : json?.message }; +}; + +describe("connector connection door: an `oauth2` instance that takes the installer's OAuth client", () => { + let stack: VerifyStack; + let admin: string; + let ql: any; + let idp: Server; + let idpUrl: string; + let upstream: Server; + let upstreamUrl: string; + const tokenRequests: Array<{ form: URLSearchParams; authorization?: string }> = []; + const savedOrigin = { auth: process.env.OS_AUTH_URL, base: process.env.OS_BASE_URL }; + + const stored = async () => + rowsOf(await ql.find('sys_connector_connection', { where: { connector_name: NAME }, context: SYSTEM })); + + /** The listing's entry for the instance, polled until `until` holds. */ + const listed = async (until: (entry: any) => boolean): Promise => { + const deadline = Date.now() + 15_000; + let last: any; + while (Date.now() < deadline) { + const res = await stack.apiAs(admin, 'GET', '/automation/connectors'); + const json = (await res.json()) as any; + last = (json?.data?.connectors ?? []).find((c: any) => c?.name === NAME); + if (last && until(last)) return last; + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error(`connector ${NAME} never reached the expected state: ${JSON.stringify(last ?? null)}`); + }; + + beforeAll(async () => { + idp = createServer((req, res) => { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + const authorization = req.headers.authorization; + tokenRequests.push({ form: new URLSearchParams(body), ...(authorization ? { authorization } : {}) }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ access_token: ACCESS_TOKEN, refresh_token: REFRESH_TOKEN, token_type: 'Bearer', expires_in: 3600 })); + }); + }); + await new Promise((r) => idp.listen(0, '127.0.0.1', () => r())); + idpUrl = `http://127.0.0.1:${(idp.address() as AddressInfo).port}`; + upstream = createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + }); + await new Promise((r) => upstream.listen(0, '127.0.0.1', () => r())); + upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; + + stack = await bootShowcase({}); + ql = await stack.kernel.getServiceAsync('objectql'); + admin = await stack.signIn(); + // The redirect URI is the deployment's fixed origin, read when a connect + // is begun — never at boot, and never from the request (ADR-0143 D5). + if (!savedOrigin.auth && !savedOrigin.base) process.env.OS_BASE_URL = 'https://dogfood.example.com'; + + const put = await stack.apiAs(admin, 'PUT', `/meta/connector/${NAME}`, { + name: NAME, + label: 'CIOC vendor', + type: 'api', + description: "An `oauth2` instance whose OAuth client each deployment's administrator enters.", + provider: 'rest', + providerConfig: { baseUrl: upstreamUrl }, + auth: { + type: 'oauth2', + grant: 'authorization_code', + authorizationUrl: `${idpUrl}/authorize`, + tokenUrl: `${idpUrl}/token`, + clientSource: 'installer', + scopes: ['vendor.read'], + }, + }); + expect(put.status, await put.clone().text()).toBe(200); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + await new Promise((r) => (idp ? idp.close(() => r()) : r())); + await new Promise((r) => (upstream ? upstream.close(() => r()) : r())); + if (savedOrigin.base === undefined) delete process.env.OS_BASE_URL; + else process.env.OS_BASE_URL = savedOrigin.base; + }); + + it('SERVED: before a connect the instance is held "not connected", never for a missing `clientId`', async () => { + const entry = await listed((e) => JSON.stringify(e).includes('is not connected')); + expect(JSON.stringify(entry)).not.toContain('clientId'); + const read = await stack.apiAs(admin, 'GET', door); + expect(read.status, await read.clone().text()).toBe(200); + expect(dataOf(await read.json())).toMatchObject({ connector: NAME, status: 'not_connected' }); + }); + + it('⛔ a begin with no client is refused 400 VALIDATION_ERROR, naming the vendor registration step; nothing is stored', async () => { + const res = await stack.apiAs(admin, 'POST', door); + const json = (await res.json()) as any; + expect(res.status, JSON.stringify(json).slice(0, 400)).toBe(400); + expect(errorOf(json).code).toBe('VALIDATION_ERROR'); + expect(errorOf(json).message).toContain(`Connector instance '${NAME}' takes its OAuth client from the installer`); + expect(errorOf(json).message).toContain('Register an OAuth client for this deployment with the vendor'); + expect(tokenRequests).toHaveLength(0); + expect(await stored()).toHaveLength(0); + }); + + it('⛔ a begin body carrying another key is refused 400 naming the key, never repeating a value', async () => { + const res = await stack.apiAs(admin, 'POST', door, { clientId: INSTALLER_ID, clientSecret: INSTALLER_SECRET, client_secret: INSTALLER_SECRET }); + const text = await res.text(); + expect(res.status, text.slice(0, 400)).toBe(400); + expect(errorOf(JSON.parse(text)).code).toBe('VALIDATION_ERROR'); + expect(text).toContain('`client_secret`'); + expect(text).not.toContain(INSTALLER_SECRET); + expect(tokenRequests).toHaveLength(0); + expect(await stored()).toHaveLength(0); + }); + + it("CONNECTED: authorizes and exchanges as the administrator's client; the read names it, never its secret; revoke removes it", async () => { + const begun = await stack.apiAs(admin, 'POST', door, { clientId: INSTALLER_ID, clientSecret: INSTALLER_SECRET }); + const beginText = await begun.text(); + expect(begun.status, beginText.slice(0, 400)).toBe(200); + expect(beginText).not.toContain(INSTALLER_SECRET); + const authorizationUrl = new URL(dataOf(JSON.parse(beginText)).authorizationUrl); + expect(authorizationUrl.origin + authorizationUrl.pathname).toBe(`${idpUrl}/authorize`); + expect(authorizationUrl.searchParams.get('client_id')).toBe(INSTALLER_ID); + const state = authorizationUrl.searchParams.get('state'); + expect(state).toBeTruthy(); + + const callback = await stack.apiAs( + admin, 'GET', `/automation/connectors/oauth/callback?state=${encodeURIComponent(state!)}&code=${CODE}`, + ); + const callbackText = await callback.text(); + expect(callback.status, callbackText.slice(0, 400)).toBe(200); + expect(callbackText).not.toContain(INSTALLER_SECRET); + expect(tokenRequests).toHaveLength(1); + expect(tokenRequests[0].authorization).toBe(basicOf(INSTALLER_ID, INSTALLER_SECRET)); + expect(tokenRequests[0].form.get('grant_type')).toBe('authorization_code'); + expect(tokenRequests[0].form.get('code')).toBe(CODE); + + const read = await stack.apiAs(admin, 'GET', door); + const readText = await read.text(); + expect(read.status, readText.slice(0, 400)).toBe(200); + expect(dataOf(JSON.parse(readText))).toMatchObject({ + connector: NAME, + status: 'connected', + client: { source: 'installer', clientId: INSTALLER_ID, hasSecret: true }, + }); + expect(readText).not.toContain(INSTALLER_SECRET); + expect(readText).not.toContain(REFRESH_TOKEN); + + const [row] = await stored(); + expect(row).toMatchObject({ connector_name: NAME, client_id: INSTALLER_ID }); + expect(JSON.stringify(row)).not.toContain(INSTALLER_SECRET); + + const revoked = await stack.apiAs(admin, 'DELETE', door); + expect(revoked.status, await revoked.clone().text()).toBe(200); + expect(dataOf(await revoked.json())).toMatchObject({ connector: NAME, status: 'not_connected' }); + expect(await stored()).toHaveLength(0); + }); +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 8c421f8946a..5591d8e5b79 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1789,7 +1789,11 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu const result = await dispatcher.dispatch( verb.toUpperCase(), `/automation/connectors/${encodeURIComponent(req.params.name)}/connection`, - undefined, + // Only the begin leg reads a body: the OAuth client an + // administrator entered for an instance declaring + // `clientSource: 'installer'` (ADR-0143 D2.1). Read once + // the floor has admitted the caller, never ahead of it. + verb === 'post' ? deferRequestBody(req) : undefined, req.query, { request: req }, ); diff --git a/packages/runtime/src/domains/automation-connector-connection-door.test.ts b/packages/runtime/src/domains/automation-connector-connection-door.test.ts index 7c10576c85a..45887fe2670 100644 --- a/packages/runtime/src/domains/automation-connector-connection-door.test.ts +++ b/packages/runtime/src/domains/automation-connector-connection-door.test.ts @@ -377,3 +377,66 @@ describe('ADR-0143 D5 — connector connection door: begin and revoke refuse a d expect(h.complete).toHaveBeenCalledTimes(1); }); }); + +// ADR-0143 D2.1 (2026-11-02 amendment): an instance declaring `clientSource: +// 'installer'` takes its OAuth client from the administrator, in the begin +// leg's body. The door closes that body's key set and hands the client to the +// service, which judges it (and refuses it on an instance that takes none). +// ⛔ The secret travels in this body: no refusal here repeats a value. +describe('ADR-0143 D2.1 — connector connection door: the begin leg takes the installer\'s client', () => { + const SECRET = 'dummy-installer-secret-28866'; + const begin = (h: Harness, body: unknown) => + h.dispatcher.handleAutomation('/connectors/crm/connection', 'POST', body, ADMIN(), undefined); + + it('hands `clientId` and `clientSecret` to the service as `client`, and answers what begin answered', async () => { + const h = boot(); + const { response } = await begin(h, { clientId: 'installer-client-28866', clientSecret: SECRET }); + expect(statusOf(response)).toBe(200); + expect(h.begin).toHaveBeenCalledWith({ + connector: 'crm', + context: expect.objectContaining({ userId: 'u_admin', tenantId: 'org_a' }), + client: { clientId: 'installer-client-28866', clientSecret: SECRET }, + }); + expect(JSON.stringify(response)).not.toContain(SECRET); + // A public client: the id alone. + await begin(h, { clientId: 'installer-client-28866' }); + expect(h.begin).toHaveBeenLastCalledWith(expect.objectContaining({ client: { clientId: 'installer-client-28866' } })); + }); + + it('CONTROL: no body, or an empty object, begins with no client, exactly as before', async () => { + for (const body of [undefined, null, {}]) { + const h = boot(); + expect(statusOf((await begin(h, body)).response)).toBe(200); + expect(h.begin).toHaveBeenCalledWith({ connector: 'crm', context: expect.objectContaining({ tenantId: 'org_a' }) }); + } + }); + + it('refuses an unknown key, a non-object body and a secret without its id, 400 naming keys only, before the service', async () => { + const cases: Array<[unknown, string]> = [ + [{ clientId: 'c', clientSecret: SECRET, client_secret: SECRET }, '`client_secret`'], + [{ redirectUri: 'https://evil.example.com' }, '`redirectUri`'], + [[SECRET], 'JSON object body'], + [SECRET, 'JSON object body'], + [{ clientSecret: SECRET }, '`clientId`'], + ]; + for (const [body, named] of cases) { + const h = boot(); + const { response } = await begin(h, body); + expect(statusOf(response)).toBe(400); + expect(codeOf(response)).toBe('VALIDATION_ERROR'); + expect(messageOf(response)).toContain(named); + expect(JSON.stringify(response)).not.toContain(SECRET); + expect(JSON.stringify(response)).not.toContain('evil.example.com'); + expect(h.begin).not.toHaveBeenCalled(); + } + }); + + it('the read and revoke legs read no body, as before', async () => { + const h = boot(); + for (const verb of ['GET', 'DELETE'] as const) { + await h.dispatcher.handleAutomation('/connectors/crm/connection', verb, { clientId: 'c', clientSecret: SECRET }, ADMIN(), undefined); + } + expect(h.get).toHaveBeenCalledWith({ connector: 'crm', context: expect.objectContaining({ tenantId: 'org_a' }) }); + expect(h.revoke).toHaveBeenCalledWith({ connector: 'crm', context: expect.objectContaining({ tenantId: 'org_a' }) }); + }); +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 509d0dd0af5..28496848319 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -32,7 +32,7 @@ import { PLATFORM_OWNER_EMAIL_ENV, resolveTenancyPosture } from '@objectstack/ty import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; import { CoreServiceName } from '@objectstack/spec/system'; -import type { AutomationResult, IAutomationService, ISecurityService } from '@objectstack/spec/contracts'; +import type { AutomationResult, ConnectorConnectionRequest, IAutomationService, ISecurityService } from '@objectstack/spec/contracts'; // [#23501] The served list read's contract — the facts a package refusal names. import type { MetadataProtocol } from '@objectstack/spec/api'; import { isServiceServeable } from '../service-serveable.js'; @@ -938,6 +938,46 @@ export const CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS: readonly string[] = Object.f 'code', 'state', 'error', 'error_description', 'error_uri', 'iss', ]); +/** + * ADR-0143 D2.1 (2026-11-02 amendment) — the begin leg's closed body set: the + * OAuth client an administrator registered with the vendor, for an instance + * declaring `clientSource: 'installer'`. No body (or an empty object) begins + * with no client, as before; anything else is refused naming the key, never + * dropped. The automation service judges the values, and refuses a client + * on an instance that does not take one. + */ +export const CONNECTOR_CONNECTION_BEGIN_BODY_KEYS: readonly string[] = Object.freeze(['clientId', 'clientSecret']); + +/** + * The begin leg's body as the service's `client`, `undefined` for no client, + * or the refusal sentence. ⛔ The sentence names keys only, never a value the + * caller sent (the secret travels in this body). + */ +function connectorConnectionBeginClientOf( + connector: string, + body: unknown, +): { client?: { clientId: string; clientSecret?: string } } | { refusal: string } { + if (body === undefined || body === null || body === '') return {}; + if (typeof body !== 'object' || Array.isArray(body)) { + return { refusal: `Beginning a connect of connector instance '${connector}' takes a JSON object body, or none.` }; + } + const keys = Object.keys(body); + if (keys.length === 0) return {}; + const unknownKeys = keys.filter((k) => !CONNECTOR_CONNECTION_BEGIN_BODY_KEYS.includes(k)).sort(); + if (unknownKeys.length > 0) { + return { + refusal: `Beginning a connect of connector instance '${connector}' takes only \`clientId\` and \`clientSecret\` in the ` + + `body; unrecognised: ${unknownKeys.map((k) => `\`${k}\``).join(', ')}.`, + }; + } + const fields = body as { clientId?: unknown; clientSecret?: unknown }; + if (fields.clientId === undefined) { + return { refusal: `Beginning a connect of connector instance '${connector}' with \`clientSecret\` needs the \`clientId\` it belongs to.` }; + } + // The values are the service's to judge (`beginConnectorConnection`). + return { client: fields as { clientId: string; clientSecret?: string } }; +} + /** * ADR-0143 D5 — "who may connect, reconnect or revoke is a capability check * the server slice declares". The organization-scoped capability the spec @@ -1044,6 +1084,7 @@ async function respondToConnectorConnectionDoor( automationService: unknown, parts: string[], method: string, + body: unknown, context: HttpProtocolContext, query: unknown, ): Promise { @@ -1080,11 +1121,19 @@ async function respondToConnectorConnectionDoor( }; } const member = method === 'GET' ? 'getConnectorConnection' : method === 'POST' ? 'beginConnectorConnection' : 'revokeConnectorConnection'; - const call = svc[member] as ((request: { connector: string; context?: unknown }) => Promise) | undefined; + const call = svc[member] as ((request: ConnectorConnectionRequest) => Promise) | undefined; if (typeof call !== 'function') { return { handled: true, response: deps.error(CONNECTOR_CONNECTION_UNSUPPORTED_MESSAGE(member), 501) }; } - const answer = await call.call(svc, { connector, context: executionContext }); + // Only the begin leg reads a body (the installer's OAuth client); the + // read and revoke legs read none, as before. + let client: ConnectorConnectionRequest['client']; + if (method === 'POST') { + const read = connectorConnectionBeginClientOf(connector, body); + if ('refusal' in read) return { handled: true, response: deps.error(read.refusal, 400, { code: 'VALIDATION_ERROR' }) }; + client = read.client; + } + const answer = await call.call(svc, { connector, context: executionContext, ...(client ? { client } : {}) }); return { handled: true, response: deps.success(answer) }; } catch (err) { const refusal = connectorCredentialRefusal(deps, err); @@ -3506,7 +3555,7 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str // ADR-0143 D5 — the connector connection door, ahead of `/:name` for the // reason `/connectors` is. if (isConnectorConnectionDoor(parts, m)) { - return respondToConnectorConnectionDoor(deps, automationService, parts, m, context, query); + return respondToConnectorConnectionDoor(deps, automationService, parts, m, body, context, query); } if (parts[0] === 'connectors' && parts.length === 1 && m === 'GET') { diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 8bba418ad5d..60b255866d0 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -583,9 +583,9 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ // `authorization_code` instance for their organization (Q1 = A). The // store is `sys_connector_connection`, whose refresh token is a `Field.secret`. { route: 'GET /automation/connectors/:name/connection', domain: '/automation', disposition: 'server-only', - note: "The caller's organization's connection of one declared `oauth2` `authorization_code` instance: `status` (`not_connected` / `connected` / `reconnect_required`), its reason, the granted scopes, the access token's expiry and who connected it. ⛔ Never a token. Refused: an undeclared instance 404 `RESOURCE_NOT_FOUND`; one whose auth is not that grant, or a caller with no organization, 400 `VALIDATION_ERROR`. ⚑ `manage_connector_connections`, the organization-scoped capability (ADR-0143 D5), fail-closed, only `isSystem` bypassing; the platform-scoped `manage_metadata` does not admit a caller. No walled refusal, since a connection belongs to its organization. Closed query set: none. NOT JS-SDK surface on this leg: the driver is the Console's Connect / Reconnect flow (ADR-0143 S4). Pinned in `domains/automation-connector-connection-door.test.ts`" }, + note: "The caller's organization's connection of one declared `oauth2` `authorization_code` instance: `status` (`not_connected` / `connected` / `reconnect_required`), its reason, the granted scopes, the access token's expiry, who connected it and, for an instance declaring `clientSource: 'installer'`, `client: { source, clientId, hasSecret }`. ⛔ Never a token or a client secret. Refused: an undeclared instance 404 `RESOURCE_NOT_FOUND`; one whose auth is not that grant, or a caller with no organization, 400 `VALIDATION_ERROR`. ⚑ `manage_connector_connections`, the organization-scoped capability (ADR-0143 D5), fail-closed, only `isSystem` bypassing; the platform-scoped `manage_metadata` does not admit a caller. No walled refusal, since a connection belongs to its organization. Closed query set: none. NOT JS-SDK surface on this leg: the driver is the Console's Connect / Reconnect flow (ADR-0143 S4). Pinned in `domains/automation-connector-connection-door.test.ts`" }, { route: 'POST /automation/connectors/:name/connection', domain: '/automation', disposition: 'server-only', - note: "Begin connecting (or reconnecting) the instance for the caller's organization: answers `{ authorizationUrl, expiresAt }`, the vendor URL carrying a fresh `state` and an `S256` PKCE challenge. Nothing is written to the connection record until the callback completes; the pending authorization (verifier included) waits ten minutes in the cluster KV, or in process on a single node, sealed (AES-256-GCM) under a key derived from the raw `state`, which the server keeps only as a digest. The redirect URI is the deployment's (`OS_AUTH_URL` / `OS_BASE_URL`, or the plugin option), never the request's — 503 `SERVICE_UNAVAILABLE` when there is none. No body. Same refusals, gates and SDK posture as the GET row above. Pinned in `domains/automation-connector-connection-door.test.ts`" }, + note: "Begin connecting (or reconnecting) the instance for the caller's organization: answers `{ authorizationUrl, expiresAt }`, the vendor URL carrying a fresh `state` and an `S256` PKCE challenge. Nothing is written to the connection record until the callback completes; the pending authorization (verifier included) waits ten minutes in the cluster KV, or in process on a single node, sealed (AES-256-GCM) under a key derived from the raw `state`, which the server keeps only as a digest. The redirect URI is the deployment's (`OS_AUTH_URL` / `OS_BASE_URL`, or the plugin option), never the request's — 503 `SERVICE_UNAVAILABLE` when there is none. Closed body set: `{ clientId, clientSecret? }`, the OAuth client an administrator registered with the vendor for an instance declaring `clientSource: 'installer'` (ADR-0143 D2.1); no body, or `{}`, begins with no client. Refused 400 `VALIDATION_ERROR`, naming keys only and never repeating a value: a body key other than `clientId` / `clientSecret` (or a non-object body, or a `clientSecret` without its `clientId`); an installer instance begun with no client (the message names the vendor registration step and the redirect URI to register); a client offered to an instance that declares its own or leaves it to MCP dynamic registration. The secret is kept only encrypted in the connection record, never answered back. Same refusals, gates and SDK posture as the GET row above. Pinned in `domains/automation-connector-connection-door.test.ts`" }, { route: 'DELETE /automation/connectors/:name/connection', domain: '/automation', disposition: 'server-only', note: "Revoke the caller's organization's connection: the row is removed, the cached access token dropped, and the instance re-judged — its next dispatch there is refused 'not connected'. ⚑ Local only: no RFC 7009 revocation is sent to the vendor, because no revocation endpoint is known for the instance (the auth declares none; ADR-0143 S2 discovery would supply one), so the refresh token stays valid at the vendor until it expires or is revoked there. 404 `RESOURCE_NOT_FOUND` when it was not connected; 503 `SERVICE_UNAVAILABLE` when the connection store cannot be read. Same gates and SDK posture as the GET row above. Pinned in `domains/automation-connector-connection-door.test.ts`" }, { route: 'GET /automation/connectors/oauth/callback', domain: '/automation', disposition: 'server-only', diff --git a/packages/services/service-automation/src/connector-connection-door.test.ts b/packages/services/service-automation/src/connector-connection-door.test.ts index 54c0b689f16..4db474f24e1 100644 --- a/packages/services/service-automation/src/connector-connection-door.test.ts +++ b/packages/services/service-automation/src/connector-connection-door.test.ts @@ -638,3 +638,200 @@ describe('ADR-0143 D3/D6 — what a grant leaves behind', () => { expect(Object.keys(SysConnectorConnection.fields)).not.toContain('access_token'); }); }); + +// ─── ADR-0143 D2.1 (2026-11-02 amendment): the installer's client ──── + +/** + * An instance declaring `clientSource: 'installer'`: the definition names no + * client and no secret, and the administrator of each deployment enters the + * client they registered with the vendor on the connection. + */ +function installerCrm(over: Record = {}) { + const { clientId: _id, credentialRef: _ref, ...auth } = crm().auth; + return crm({ auth: { ...auth, clientSource: 'installer' }, ...over }); +} +const INSTALLER_ID = 'installer-client-28866'; +const INSTALLER_SECRET = 'dummy-installer-secret-28866'; +const INSTALLER_CLIENT = { clientId: INSTALLER_ID, clientSecret: INSTALLER_SECRET }; +const basicOf = (id: string, secret: string) => `Basic ${Buffer.from(`${id}:${secret}`).toString('base64')}`; + +describe("ADR-0143 D2.1 — an instance that takes the installer's OAuth client", () => { + /** Begin with `client`, complete as the same caller. */ + const connectWith = async (engine: AutomationEngine, client: unknown, context = ADMIN_A) => { + const begun = await engine.beginConnectorConnection({ connector: 'crm', context: context as never, client: client as never }); + const state = new URL(begun.authorizationUrl).searchParams.get('state')!; + return { begun, done: () => engine.completeConnectorConnection({ query: { state, code: GOOD_CODE }, context: context as never }) }; + }; + + it("connects with the administrator's client, refreshes as it, and revokes; each call carries that client", async () => { + const { engine, provider, call, ql } = await boot([installerCrm()]); + // Served: held "not connected", never "without `clientId`". + expect(engine.getConnectorDegradedReason('crm')).toContain("connector instance 'crm' is not connected"); + expect(engine.getConnectorDegradedReason('crm')).not.toContain('clientId'); + + const { begun, done } = await connectWith(engine, INSTALLER_CLIENT); + expect(new URL(begun.authorizationUrl).searchParams.get('client_id')).toBe(INSTALLER_ID); + const state = await done(); + expect(state).toMatchObject({ + connector: 'crm', organizationId: 'org_a', status: 'connected', + client: { source: 'installer', clientId: INSTALLER_ID, hasSecret: true }, + }); + // The code exchange authenticated as the installer's client. + expect(idp.requests).toHaveLength(1); + expect(idp.requests[0].authorization).toBe(basicOf(INSTALLER_ID, INSTALLER_SECRET)); + expect(idp.requests[0].form).toMatchObject({ grant_type: 'authorization_code', code: GOOD_CODE, redirect_uri: REDIRECT_URI }); + + // Dispatch with the exchanged token, then a 401 refreshes as the installer's client. + expect(await call('org_a')).toMatchObject({ status: 200 }); + provider.state.accepted = new Set(['dummy-at-refresh-2']); + expect(await call('org_a')).toMatchObject({ status: 200 }); + expect(idp.requests).toHaveLength(2); + expect(idp.requests[1].form).toMatchObject({ grant_type: 'refresh_token', refresh_token: 'dummy-rt-1' }); + expect(idp.requests[1].authorization).toBe(basicOf(INSTALLER_ID, INSTALLER_SECRET)); + + // The row: the id in plain text, the client (with its secret) only behind the encrypted column. + const [row] = await rowsOf(ql); + expect(row).toMatchObject({ client_id: INSTALLER_ID }); + expect(Boolean(row.client_has_secret)).toBe(true); + expect(JSON.stringify(row)).not.toContain(INSTALLER_SECRET); + const kept = JSON.parse(await (ql as any).resolveSecretField(CONNECTOR_CONNECTION_OBJECT, row.id, 'registered_client')); + expect(kept).toEqual({ source: 'installer', clientId: INSTALLER_ID, clientSecret: INSTALLER_SECRET }); + + // The read leg shows that a client is set and its id, never the secret. + const read = await engine.getConnectorConnection({ connector: 'crm', context: ADMIN_A as never }); + expect(read.client).toEqual({ source: 'installer', clientId: INSTALLER_ID, hasSecret: true }); + expect(JSON.stringify(read)).not.toContain(INSTALLER_SECRET); + + // Revoke takes the client with the row. + expect(await engine.revokeConnectorConnection({ connector: 'crm', context: ADMIN_A as never })) + .toEqual({ connector: 'crm', organizationId: 'org_a', status: 'not_connected' }); + expect(await rowsOf(ql)).toHaveLength(0); + await vi.waitFor(() => expect(engine.getConnectorDegradedReason('crm')).toContain('is not connected')); + }); + + it('a public client (no secret) exchanges and refreshes with its id in the form; the read says no secret is kept', async () => { + const { engine, provider, call } = await boot([installerCrm()]); + const state = await (await connectWith(engine, { clientId: INSTALLER_ID })).done(); + expect(state.client).toEqual({ source: 'installer', clientId: INSTALLER_ID, hasSecret: false }); + expect(idp.requests[0].authorization).toBeUndefined(); + expect(idp.requests[0].form.client_id).toBe(INSTALLER_ID); + provider.state.accepted = new Set(['dummy-at-refresh-2']); + await call('org_a'); + expect(idp.requests[1].form).toMatchObject({ grant_type: 'refresh_token', client_id: INSTALLER_ID }); + }); + + it('each organization connects with its own client, and a reconnect replaces it', async () => { + const { engine, ql } = await boot([installerCrm()]); + await (await connectWith(engine, INSTALLER_CLIENT, ADMIN_A)).done(); + await (await connectWith(engine, { clientId: 'installer-client-b', clientSecret: 'dummy-secret-b' }, ADMIN_B)).done(); + expect(idp.requests[1].authorization).toBe(basicOf('installer-client-b', 'dummy-secret-b')); + await (await connectWith(engine, { clientId: 'installer-client-a2' }, ADMIN_A)).done(); + const byOrg = Object.fromEntries((await rowsOf(ql)).map((r) => [r.organization_id, r.client_id])); + expect(byOrg).toEqual({ org_a: 'installer-client-a2', org_b: 'installer-client-b' }); + expect((await engine.getConnectorConnection({ connector: 'crm', context: ADMIN_A as never })).client) + .toEqual({ source: 'installer', clientId: 'installer-client-a2', hasSecret: false }); + }); + + it('⛔ a connect without a client is refused 400, naming the instance and the vendor registration step; nothing is kept', async () => { + const { engine, ql } = await boot([installerCrm()]); + const err = (await engine.beginConnectorConnection({ connector: 'crm', context: ADMIN_A as never }).catch((e: Error) => e)) as Error & { status?: number; code?: string }; + expect(err).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(err.message).toContain("Connector instance 'crm' takes its OAuth client from the installer"); + expect(err.message).toContain('Register an OAuth client for this deployment with the vendor'); + expect(err.message).toContain(REDIRECT_URI); + expect(idp.requests).toHaveLength(0); + expect(await rowsOf(ql)).toHaveLength(0); + }); + + it('⛔ a malformed client is refused 400 naming the key, never repeating a value', async () => { + const { engine } = await boot([installerCrm()]); + for (const [client, named] of [ + [{ clientId: '' }, '`clientId`'], + [{ clientId: 42, clientSecret: INSTALLER_SECRET }, '`clientId`'], + [{ clientId: 'x'.repeat(513), clientSecret: INSTALLER_SECRET }, '`clientId`'], + [{ clientId: INSTALLER_ID, clientSecret: '' }, '`clientSecret`'], + [{ clientId: INSTALLER_ID, clientSecret: INSTALLER_SECRET, redirect_uri: 'https://evil.example.com' }, '`redirect_uri`'], + [INSTALLER_SECRET, 'an object naming `clientId`'], + ] as Array<[unknown, string]>) { + const err = (await engine.beginConnectorConnection({ connector: 'crm', context: ADMIN_A as never, client: client as never }) + .catch((e: Error) => e)) as Error & { status?: number }; + expect(err.status).toBe(400); + expect(err.message).toContain(named); + expect(err.message).not.toContain(INSTALLER_SECRET); + expect(err.message).not.toContain('evil.example.com'); + } + expect(idp.requests).toHaveLength(0); + }); + + it('⛔ CONTROL: a client offered to an instance that declares its own is refused 400, not ignored; without one it connects as before', async () => { + const { engine } = await boot([crm()]); + const err = (await engine.beginConnectorConnection({ connector: 'crm', context: ADMIN_A as never, client: INSTALLER_CLIENT }) + .catch((e: Error) => e)) as Error & { status?: number; code?: string }; + expect(err).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + expect(err.message).toContain("Connector instance 'crm' does not take its OAuth client from the installer"); + expect(err.message).not.toContain(INSTALLER_SECRET); + const state = await (await connectWith(engine, undefined)).done(); + expect(state.client).toBeUndefined(); + expect(idp.requests[0].authorization).toBe(basicOf('client-26702', CLIENT_SECRET)); + }); + + it('⛔ the read and revoke legs refuse a client: one is entered only when a connect is begun', async () => { + const { engine } = await boot([installerCrm()]); + for (const leg of [ + () => engine.getConnectorConnection({ connector: 'crm', context: ADMIN_A as never, client: INSTALLER_CLIENT }), + () => engine.revokeConnectorConnection({ connector: 'crm', context: ADMIN_A as never, client: INSTALLER_CLIENT }), + ]) { + const err = (await leg().catch((e: Error) => e)) as Error & { status?: number }; + expect(err.status).toBe(400); + expect(err.message).toContain('takes no OAuth client'); + expect(err.message).not.toContain(INSTALLER_SECRET); + } + }); + + it('⛔ a declaration that changed where its client comes from while the browser was away uses no code', async () => { + const declared: unknown[] = [installerCrm()]; + const { engine, ql } = await boot(declared); + const { done } = await connectWith(engine, INSTALLER_CLIENT); + declared[0] = crm(); + await expect(done()).rejects.toMatchObject({ status: 400, message: expect.stringContaining('changed where its OAuth client comes from') }); + expect(idp.requests).toHaveLength(0); + expect(await rowsOf(ql)).toHaveLength(0); + }); + + it("⛔ the installer's secret is in no log line, descriptor, reason, door answer or engine read", async () => { + const out = captureOutput(); + const { engine, call, provider, ql } = await boot([installerCrm()]); + const answers: string[] = []; + await engine.beginConnectorConnection({ connector: 'crm', context: ADMIN_A as never }).catch((e: Error) => answers.push(e.message)); + await engine.beginConnectorConnection({ connector: 'crm', context: ADMIN_A as never, client: { ...INSTALLER_CLIENT, x: 1 } as never }) + .catch((e: Error) => answers.push(e.message)); + const { begun, done } = await connectWith(engine, INSTALLER_CLIENT); + answers.push(JSON.stringify(begun), JSON.stringify(await done())); + provider.state.accepted = new Set(['dummy-at-refresh-2']); + await call('org_a'); + idp.refresh = 'invalid_grant'; + provider.state.accepted = new Set(); + await call('org_a').catch((e) => answers.push(String(e.message))); + await vi.waitFor(() => expect(engine.getConnectorDegradedReason('crm')).toBeDefined()); + answers.push(JSON.stringify(await engine.getConnectorConnection({ connector: 'crm', context: ADMIN_A as never }))); + const surfaces = { + logs: out.text(), + descriptors: JSON.stringify(engine.getConnectorDescriptors()), + reason: String(engine.getConnectorDegradedReason('crm')), + answers: answers.join('\n'), + 'engine read': JSON.stringify(await rowsOf(ql)), + }; + for (const [where, text] of Object.entries(surfaces)) { + expect(text.includes(INSTALLER_SECRET), `${where} carries the installer's secret`).toBe(false); + } + // Not passing on silence: the connect was logged and the read named the client. + expect(out.text()).toContain("connector instance 'crm' connected for organization 'org_a' by user 'usr_admin'"); + expect(surfaces.answers).toContain(INSTALLER_ID); + }); + + it('the installer client columns: the id and the secret flag are plain, the client itself stays the masked field', () => { + expect(collectMaskedReadFields(SysConnectorConnection as never)).toEqual(['refresh_token', 'registered_client']); + expect(SysConnectorConnection.fields.client_id.type).toBe('text'); + expect(SysConnectorConnection.fields.client_has_secret.type).toBe('boolean'); + }); +}); diff --git a/packages/services/service-automation/src/connector-connections.ts b/packages/services/service-automation/src/connector-connections.ts index 42db9adef73..9eaf8e5a643 100644 --- a/packages/services/service-automation/src/connector-connections.ts +++ b/packages/services/service-automation/src/connector-connections.ts @@ -29,9 +29,20 @@ export const CONNECTOR_CONNECTION_OBJECT = 'sys_connector_connection'; /** Its `type: 'secret'` column. */ export const CONNECTOR_CONNECTION_REFRESH_TOKEN_FIELD = 'refresh_token'; -/** The encrypted field holding the client an MCP authorization flow discovered or registered (ADR-0143 D2.1). */ +/** + * The encrypted field holding the client a connection authorizes with when the + * instance declares none (ADR-0143 D2.1): the one an MCP authorization flow + * discovered or registered, or the one the administrator entered for an + * instance declaring `clientSource: 'installer'` (2026-11-02 amendment). + */ export const CONNECTOR_CONNECTION_REGISTERED_CLIENT_FIELD = 'registered_client'; +/** The installer's client id, in plain text beside the encrypted client: what a read shows. Never a secret. */ +export const CONNECTOR_CONNECTION_CLIENT_ID_FIELD = 'client_id'; + +/** Whether the installer's client came with a secret (kept only inside the encrypted client). */ +export const CONNECTOR_CONNECTION_CLIENT_HAS_SECRET_FIELD = 'client_has_secret'; + /** A connection's status (`not connected` is the absence of a row). */ export type ConnectorConnectionRowStatus = 'connected' | 'reconnect_required'; @@ -52,8 +63,17 @@ export interface ConnectorConnectionRecord { revision: number; /** Whether a refresh token is stored — never what it is. */ hasRefreshToken: boolean; - /** Whether the row keeps a discovered or registered client (an MCP instance's connection). */ + /** Whether the row keeps a client of its own: discovered or registered (an MCP instance's connection), or the installer's. */ hasRegisteredClient: boolean; + /** + * The id of the client the administrator entered (`clientSource: + * 'installer'`, ADR-0143 D2.1), from the plain column; absent for any + * other connection. The client itself is read only through + * {@link ConnectorConnectionStore.resolveRegisteredClient}. + */ + installerClientId?: string; + /** Whether that client came with a secret — never what it is. */ + installerClientHasSecret: boolean; connectedAt?: string; connectedBy?: string; } @@ -62,7 +82,8 @@ export interface ConnectorConnectionRecord { const RECORD_FIELDS = [ 'id', 'organization_id', 'connector_name', 'status', 'status_reason', 'scopes', 'expires_at', 'revision', 'connected_at', 'connected_by', CONNECTOR_CONNECTION_REFRESH_TOKEN_FIELD, - CONNECTOR_CONNECTION_REGISTERED_CLIENT_FIELD, + CONNECTOR_CONNECTION_REGISTERED_CLIENT_FIELD, CONNECTOR_CONNECTION_CLIENT_ID_FIELD, + CONNECTOR_CONNECTION_CLIENT_HAS_SECRET_FIELD, ]; function toRecord(row: Record): ConnectorConnectionRecord | undefined { @@ -84,6 +105,11 @@ function toRecord(row: Record): ConnectorConnectionRecord | und revision: Number.isFinite(revision) ? revision : 0, hasRefreshToken: typeof masked === 'string' && masked.length > 0, hasRegisteredClient: typeof maskedClient === 'string' && maskedClient.length > 0, + ...(optionalText(row[CONNECTOR_CONNECTION_CLIENT_ID_FIELD]) === undefined + ? {} + : { installerClientId: optionalText(row[CONNECTOR_CONNECTION_CLIENT_ID_FIELD]) }), + installerClientHasSecret: row[CONNECTOR_CONNECTION_CLIENT_HAS_SECRET_FIELD] === true + || row[CONNECTOR_CONNECTION_CLIENT_HAS_SECRET_FIELD] === 1, connectedAt: optionalText(row.connected_at), connectedBy: optionalText(row.connected_by), }; @@ -236,12 +262,21 @@ export class ConnectorConnectionStore { /** * Store what a completed connect produced for `(organizationId, * connectorName)`, replacing any earlier connection there (a reconnect), - * and raise its revision. `registeredClient` is the serialized client an - * MCP authorization flow discovered or registered, kept encrypted beside - * the refresh token it authorizes. + * and raise its revision. `registeredClient` is the serialized client the + * connection authorizes with (one an MCP authorization flow discovered or + * registered, or the installer's), kept encrypted beside the refresh token + * it authorizes. `installerClient` repeats the installer's client id, and + * whether it has a secret, in the plain columns a read shows — never the + * secret, which is only inside `registeredClient`. */ async connect( - args: { organizationId: string; connectorName: string; userId?: string; registeredClient?: string } & ConnectorGrantWrite, + args: { + organizationId: string; + connectorName: string; + userId?: string; + registeredClient?: string; + installerClient?: { clientId: string; hasSecret: boolean }; + } & ConnectorGrantWrite, ): Promise { const engine = this.engineOrThrow(`Connector instance '${args.connectorName}' was not connected`); const existing = await this.readOrRefuse(args.organizationId, args.connectorName); @@ -250,6 +285,8 @@ export class ConnectorConnectionStore { grant: 'authorization_code', [CONNECTOR_CONNECTION_REFRESH_TOKEN_FIELD]: args.refreshToken ?? null, [CONNECTOR_CONNECTION_REGISTERED_CLIENT_FIELD]: args.registeredClient ?? null, + [CONNECTOR_CONNECTION_CLIENT_ID_FIELD]: args.installerClient?.clientId ?? null, + [CONNECTOR_CONNECTION_CLIENT_HAS_SECRET_FIELD]: args.installerClient?.hasSecret ?? false, status: 'connected', status_reason: null, scopes: args.scopes ?? null, diff --git a/packages/services/service-automation/src/oauth2-grant/authorization.ts b/packages/services/service-automation/src/oauth2-grant/authorization.ts index 2d4d4909ebf..75c236c59f8 100644 --- a/packages/services/service-automation/src/oauth2-grant/authorization.ts +++ b/packages/services/service-automation/src/oauth2-grant/authorization.ts @@ -55,6 +55,7 @@ */ import type { McpAuthorizationClient } from './mcp-authorization.js'; +import type { InstallerClient } from './installer-client.js'; import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from 'node:crypto'; import type { IKV } from '@objectstack/spec/contracts'; @@ -79,6 +80,13 @@ export interface PendingAuthorization { * client. */ readonly client?: McpAuthorizationClient; + /** + * The client the administrator entered for an instance declaring + * `clientSource: 'installer'` (ADR-0143 D2.1, 2026-11-02 amendment): the + * code is exchanged as it, and the connection keeps it. Held here only + * sealed, like the verifier. Absent for any other instance. + */ + readonly installerClient?: InstallerClient; } /** Where pending authorizations wait for their callback. `take` is single-use. */ diff --git a/packages/services/service-automation/src/oauth2-grant/grant-handle.test.ts b/packages/services/service-automation/src/oauth2-grant/grant-handle.test.ts index 41139c1d99d..6ecdf450f7d 100644 --- a/packages/services/service-automation/src/oauth2-grant/grant-handle.test.ts +++ b/packages/services/service-automation/src/oauth2-grant/grant-handle.test.ts @@ -12,6 +12,7 @@ import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration import type { ILock } from '@objectstack/spec/contracts'; import type { ConnectorConnectionRecord, ConnectorConnectionStore, ConnectorGrantWrite } from '../connector-connections.js'; import { createOAuth2GrantHandle, reconnectRequiredReason, type OAuth2GrantConfig } from './grant-handle.js'; +import { serializeInstallerClient } from './installer-client.js'; const TOKEN_URL = 'https://idp.example.com/oauth/token'; @@ -51,6 +52,7 @@ function fakeConnections(rows: Array<{ org: string; refreshToken?: string; statu revision: 1, hasRefreshToken: r.refreshToken !== undefined, hasRegisteredClient: r.registeredClient !== undefined, + installerClientHasSecret: false, refreshToken: r.refreshToken, registeredClient: r.registeredClient, }); @@ -414,6 +416,91 @@ describe('ADR-0143 D2.1 — an MCP instance whose connection keeps the client it }); }); +describe("ADR-0143 D2.1 (2026-11-02) — an instance taking the installer's client refreshes as the client its administrator entered", () => { + const INSTALLER_SECRET = 'dummy-installer-secret-28866'; + const DISCOVERED_ENDPOINTS = { + issuer: 'https://auth.mcp.example.com', + authorizationUrl: 'https://auth.mcp.example.com/authorize', + tokenUrl: 'https://auth.mcp.example.com/token', + resource: 'https://mcp.example.com/mcp', + clientId: 'installer-client-a', + registered: false, + }; + function recordingEndpoint() { + const seen: Array<{ url: string; form: URLSearchParams; authorization?: string }> = []; + const fetchImpl = (async (url: string, init: RequestInit) => { + const headers = new Headers(init.headers); + seen.push({ url, form: new URLSearchParams(String(init.body)), ...(headers.get('authorization') ? { authorization: headers.get('authorization') as string } : {}) }); + return new Response(JSON.stringify({ access_token: `dummy-at-${seen.length}`, token_type: 'Bearer', expires_in: 3600 }), { status: 200 }); + }) as unknown as typeof fetch; + return { seen, fetch: fetchImpl }; + } + const INSTALLER: OAuth2GrantConfig = { grant: 'authorization_code', tokenUrl: TOKEN_URL, clientSource: 'installer' }; + const basic = (id: string, secret: string) => `Basic ${Buffer.from(`${id}:${secret}`).toString('base64')}`; + + it("refreshes at the declared token endpoint as the connection's installer client, its secret in Basic", async () => { + const idp = recordingEndpoint(); + const { store } = fakeConnections([{ + org: 'org_a', refreshToken: 'dummy-rt-a', registeredClient: serializeInstallerClient({ clientId: 'installer-client-a', clientSecret: INSTALLER_SECRET }), + }]); + const handle = createOAuth2GrantHandle({ connector: 'docusign', config: INSTALLER, connections: store, fetch: idp.fetch }); + expect((await handle.get({ organizationId: 'org_a' })).auth).toEqual({ type: 'bearer', token: 'dummy-at-1' }); + expect(idp.seen[0].url).toBe(TOKEN_URL); + expect(idp.seen[0].authorization).toBe(basic('installer-client-a', INSTALLER_SECRET)); + expect(idp.seen[0].form.get('grant_type')).toBe('refresh_token'); + }); + + it('each organization refreshes with its own administrator\'s client; a public one sends its id in the form', async () => { + const idp = recordingEndpoint(); + const { store } = fakeConnections([ + { org: 'org_a', refreshToken: 'dummy-rt-a', registeredClient: serializeInstallerClient({ clientId: 'installer-client-a', clientSecret: INSTALLER_SECRET }) }, + { org: 'org_b', refreshToken: 'dummy-rt-b', registeredClient: serializeInstallerClient({ clientId: 'installer-client-b' }) }, + ]); + const handle = createOAuth2GrantHandle({ connector: 'docusign', config: INSTALLER, connections: store, fetch: idp.fetch }); + await handle.get({ organizationId: 'org_a' }); + await handle.get({ organizationId: 'org_b' }); + expect(idp.seen[0].authorization).toBe(basic('installer-client-a', INSTALLER_SECRET)); + expect(idp.seen[1].authorization).toBeUndefined(); + expect(idp.seen[1].form.get('client_id')).toBe('installer-client-b'); + }); + + it("on MCP over http, refreshes at the kept discovered endpoint as the installer's client", async () => { + const idp = recordingEndpoint(); + const { store } = fakeConnections([{ + org: 'org_a', refreshToken: 'dummy-rt-a', + registeredClient: serializeInstallerClient({ clientId: 'installer-client-a', clientSecret: INSTALLER_SECRET }, DISCOVERED_ENDPOINTS), + }]); + const handle = createOAuth2GrantHandle({ + connector: 'notion', config: { grant: 'authorization_code', clientSource: 'installer' }, connections: store, fetch: idp.fetch, + }); + await handle.get({ organizationId: 'org_a' }); + expect(idp.seen[0].url).toBe('https://auth.mcp.example.com/token'); + expect(idp.seen[0].form.get('resource')).toBe('https://mcp.example.com/mcp'); + expect(idp.seen[0].authorization).toBe(basic('installer-client-a', INSTALLER_SECRET)); + }); + + it('⛔ a kept client of the other kind than the declaration takes is never used: reconnect required, no token request', async () => { + // An installer declaration over a client an MCP registration kept, and + // a dynamic-registration declaration over an installer's client. + const registered = JSON.stringify({ ...DISCOVERED_ENDPOINTS, clientId: 'registered-client-a', registered: true }); + const installerKept = serializeInstallerClient({ clientId: 'installer-client-a', clientSecret: INSTALLER_SECRET }, DISCOVERED_ENDPOINTS); + for (const [config, kept] of [ + [{ grant: 'authorization_code', clientSource: 'installer' }, registered], + [{ grant: 'authorization_code' }, installerKept], + ] as Array<[OAuth2GrantConfig, string]>) { + const idp = recordingEndpoint(); + const { store, calls } = fakeConnections([{ org: 'org_a', refreshToken: 'dummy-rt-a', registeredClient: kept }]); + const handle = createOAuth2GrantHandle({ connector: 'notion', config, connections: store, fetch: idp.fetch }); + const err = await handle.get({ organizationId: 'org_a' }).catch((e: Error) => e); + expect(err).toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect((err as Error).message).toContain('keeps no client to refresh with'); + expect((err as Error).message).not.toContain(INSTALLER_SECRET); + expect(idp.seen).toHaveLength(0); + expect(calls.markReconnectRequired).toEqual(['org_a']); + } + }); +}); + describe('[#28863] the stored reason of a connection marked reconnect required names the cause that happened', () => { const storedReason = (held: Map) => String(held.get('org_a')?.statusReason); diff --git a/packages/services/service-automation/src/oauth2-grant/grant-handle.ts b/packages/services/service-automation/src/oauth2-grant/grant-handle.ts index fa1c2372d7a..e2465a2dbe8 100644 --- a/packages/services/service-automation/src/oauth2-grant/grant-handle.ts +++ b/packages/services/service-automation/src/oauth2-grant/grant-handle.ts @@ -50,19 +50,28 @@ import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration import type { ILock } from '@objectstack/spec/contracts'; import type { ConnectorConnectionRecord, ConnectorConnectionStore } from '../connector-connections.js'; import { OAuth2GrantRefusedError, requestOAuth2Token, type OAuth2Client, type OAuth2TokenResponse } from './token-endpoint.js'; -import { parseMcpAuthorizationClient } from './mcp-authorization.js'; +import { parseKeptConnectionClient } from './installer-client.js'; /** * What an `oauth2` instance's handle needs from its declaration, already * judged servable. An `authorization_code` instance whose endpoints or client * the MCP authorization flow discovers (ADR-0143 D2.1) declares neither * `tokenUrl` nor `clientId`: each connection then keeps its own client - * (`registered_client`), which its refreshes use. + * (`registered_client`), which its refreshes use. So does an instance that + * declares `clientSource: 'installer'` (the 2026-11-02 amendment): the + * administrator entered each connection's client, and it declares none. */ export interface OAuth2GrantConfig { readonly grant: 'authorization_code' | 'client_credentials'; readonly tokenUrl?: string; readonly clientId?: string; + /** + * `'installer'`: every connection authorizes with the client its + * administrator entered, and with nothing else — never a client a + * connection kept from a discovery or registration under an earlier + * declaration. Without it, a kept installer's client is never used either. + */ + readonly clientSource?: 'installer'; /** The resolved client secret; absent for a public `authorization_code` client. */ readonly clientSecret?: string; readonly scopes?: readonly string[]; @@ -268,7 +277,10 @@ export function createOAuth2GrantHandle(deps: OAuth2GrantHandleDeps): OAuth2Gran /** * Where `record`'s refresh is sent, and as which client: the declared ones, * else the client the connection keeps (an MCP instance's discovered or - * registered one); `undefined` when it keeps none it can be read back as. + * registered one, or the installer's); `undefined` when it keeps none it + * can be read back as, or one of the other kind than the declaration says + * (an installer's client under a declaration that no longer takes one, + * or the reverse). */ const endpointOf = async ( record: ConnectorConnectionRecord, @@ -283,12 +295,15 @@ export function createOAuth2GrantHandle(deps: OAuth2GrantHandleDeps): OAuth2Gran throw unavailable(`the connection of connector instance '${connector}' for organization '${record.organizationId}' could ` + 'not be read back now; the next dispatch retries'); } - const kept = parseMcpAuthorizationClient(text); - if (!kept) return undefined; + const kept = parseKeptConnectionClient(text); + if (!kept || kept.installer !== (config.clientSource === 'installer')) return undefined; + const tokenUrl = config.tokenUrl ?? kept.tokenUrl; + if (tokenUrl === undefined) return undefined; + const resource = config.resource ?? kept.resource; return { - tokenUrl: config.tokenUrl ?? kept.tokenUrl, + tokenUrl, client: declaredClient ?? { clientId: kept.clientId, ...(kept.clientSecret === undefined ? {} : { clientSecret: kept.clientSecret }) }, - resource: config.resource ?? kept.resource, + ...(resource === undefined ? {} : { resource }), }; }; diff --git a/packages/services/service-automation/src/oauth2-grant/index.ts b/packages/services/service-automation/src/oauth2-grant/index.ts index b9648b25ff1..42404e67267 100644 --- a/packages/services/service-automation/src/oauth2-grant/index.ts +++ b/packages/services/service-automation/src/oauth2-grant/index.ts @@ -14,3 +14,4 @@ export * from './authorization.js'; export * from './endpoint-transport.js'; export * from './app-credential.js'; export * from './mcp-authorization.js'; +export * from './installer-client.js'; diff --git a/packages/services/service-automation/src/oauth2-grant/installer-client.ts b/packages/services/service-automation/src/oauth2-grant/installer-client.ts new file mode 100644 index 00000000000..493f20072d4 --- /dev/null +++ b/packages/services/service-automation/src/oauth2-grant/installer-client.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0143 D2.1 (2026-11-02 amendment) — the OAuth client an administrator + * supplies for an `authorization_code` instance that declares + * `clientSource: 'installer'`. + * + * The administrator registers their own client with the vendor, against this + * deployment's redirect URI, and hands its id (and secret, when the vendor + * issued one) to the connect door. The begun connect carries it in its sealed + * pending authorization; the completed connect keeps it in the connection + * record's encrypted client column (`registered_client`), the same + * per-connection client record that keeps a dynamically registered client, + * and repeats only the id in plain text for reads. Every authorization, code + * exchange and refresh of that connection then uses it. + * + * ⛔ The secret leaves this module only inside the serialized client the + * connection store encrypts, and as the client a token request authenticates + * with. No refusal, log line or read answer carries it. + */ + +import { parseMcpAuthorizationClient, type McpAuthorizationClient } from './mcp-authorization.js'; + +/** The longest client id a connect accepts: the width of the connection's plain `client_id` column. */ +export const INSTALLER_CLIENT_ID_MAX_LENGTH = 512; + +/** The client the administrator entered. */ +export interface InstallerClient { + readonly clientId: string; + /** Absent for a public client (PKCE alone protects the code). */ + readonly clientSecret?: string; +} + +/** Whether an instance's `oauth2` auth takes its client from the installer. */ +export function takesInstallerClient(auth: object | undefined): boolean { + return (auth as { clientSource?: unknown } | undefined)?.clientSource === 'installer'; +} + +/** + * What the connection keeps for an installer's client: the client, marked + * `source: 'installer'`, and — on `provider: 'mcp'` over http, where the + * endpoints are discovered — the discovered endpoints, in the shape + * {@link parseMcpAuthorizationClient} reads (`registered: false`). + */ +export function serializeInstallerClient(client: InstallerClient, discovered?: McpAuthorizationClient): string { + return JSON.stringify({ + source: 'installer', + ...(discovered + ? { + issuer: discovered.issuer, + authorizationUrl: discovered.authorizationUrl, + tokenUrl: discovered.tokenUrl, + resource: discovered.resource, + registered: false, + } + : {}), + clientId: client.clientId, + ...(client.clientSecret === undefined ? {} : { clientSecret: client.clientSecret }), + }); +} + +/** The client a connection keeps, as a refresh reads it back. */ +export interface KeptConnectionClient { + /** `true` for the administrator's client, `false` for a discovered or registered one. */ + readonly installer: boolean; + readonly clientId: string; + readonly clientSecret?: string; + /** Only when the endpoints were discovered. */ + readonly tokenUrl?: string; + readonly resource?: string; +} + +function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** + * Read a connection's kept client (`registered_client`) back: an MCP flow's + * discovered or registered client, or the installer's (with or without + * discovered endpoints). `undefined` when the text is neither. + */ +export function parseKeptConnectionClient(text: string | undefined): KeptConnectionClient | undefined { + if (text === undefined) return undefined; + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return undefined; + } + if (!isObject(value)) return undefined; + const installer = value.source === 'installer'; + const mcp = parseMcpAuthorizationClient(text); + if (mcp) { + return { + installer, + clientId: mcp.clientId, + ...(mcp.clientSecret === undefined ? {} : { clientSecret: mcp.clientSecret }), + tokenUrl: mcp.tokenUrl, + resource: mcp.resource, + }; + } + if (!installer) return undefined; + const clientId = typeof value.clientId === 'string' && value.clientId.length > 0 ? value.clientId : undefined; + if (clientId === undefined) return undefined; + const clientSecret = typeof value.clientSecret === 'string' && value.clientSecret.length > 0 ? value.clientSecret : undefined; + return { installer: true, clientId, ...(clientSecret === undefined ? {} : { clientSecret }) }; +} diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 2045bb8cf34..105e0f8e9d8 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -88,6 +88,10 @@ import { reconnectRequiredReason, requestOAuth2Token, retryOnVendorTokenRefusal, + INSTALLER_CLIENT_ID_MAX_LENGTH, + serializeInstallerClient, + takesInstallerClient, + type InstallerClient, type McpAuthorizationClient, type OAuth2GrantHandle, type PendingAuthorizationStore, @@ -713,12 +717,16 @@ function credentialHandleNotAppliedReason(name: string, provider: string, kind: * ADR-0143 D2.1 — the MCP server URL of an `oauth2` instance whose endpoints or * client the MCP authorization flow discovers: `provider: 'mcp'` over an http * transport, the `authorization_code` grant, and at least one of - * `authorizationUrl`, `tokenUrl` and `clientId` left undeclared. `undefined` + * `authorizationUrl`, `tokenUrl` and the client left undeclared. The client is + * not left to the flow when the instance declares `clientId`, or takes the + * installer's (`clientSource: 'installer'`, the 2026-11-02 amendment): the + * flow then only discovers the endpoints, and registers nothing. `undefined` * for every other instance, which declares what its handle needs. */ function mcpDiscoveryServerUrl(entry: { provider?: unknown; providerConfig?: Record }, auth: OAuth2InstanceAuth): string | undefined { if (entry.provider !== 'mcp' || auth.grant !== 'authorization_code') return undefined; - if (auth.authorizationUrl !== undefined && auth.tokenUrl !== undefined && auth.clientId !== undefined) return undefined; + const clientSupplied = auth.clientId !== undefined || takesInstallerClient(auth); + if (auth.authorizationUrl !== undefined && auth.tokenUrl !== undefined && clientSupplied) return undefined; const transport = entry.providerConfig?.transport as { kind?: unknown; url?: unknown } | undefined; if (transport?.kind !== 'http' || typeof transport.url !== 'string' || transport.url.length === 0) return undefined; return transport.url; @@ -733,7 +741,9 @@ function mcpDiscoveryServerUrl(entry: { provider?: unknown; providerConfig?: Rec * `provider: 'mcp'` over http with the `authorization_code` grant they may be * omitted: each connect runs the MCP authorization flow * (`./oauth2-grant/mcp-authorization.ts`), and the connection keeps the client - * it found. An endpoint over plain `http:` off loopback, or any other scheme, + * it found. The client alone may be omitted by an `authorization_code` instance + * declaring `clientSource: 'installer'`: the administrator enters it on each + * connection (ADR-0143 D2.1, 2026-11-02 amendment). An endpoint over plain `http:` off loopback, or any other scheme, * is refused by field (`./oauth2-grant/endpoint-transport.ts`): the client * secret and the refresh token would cross the network in the clear — and so * is such an MCP server's own URL, which the access token travels to. @@ -754,7 +764,7 @@ function oauth2NotServableReason( } const missing = discoveredAt !== undefined ? [] : [ auth.tokenUrl === undefined ? 'tokenUrl' : undefined, - auth.clientId === undefined ? 'clientId' : undefined, + auth.clientId === undefined && !takesInstallerClient(auth) ? 'clientId' : undefined, auth.grant === 'authorization_code' && auth.authorizationUrl === undefined ? 'authorizationUrl' : undefined, ].filter((k): k is string => k !== undefined); if (missing.length > 0) { @@ -3684,11 +3694,13 @@ export class AutomationServicePlugin implements Plugin { clientSecret: string | undefined, ): OAuth2GrantHandle { // `oauth2NotServableReason` passed: both are declared, or each - // connection keeps the client the MCP authorization flow found. + // connection keeps the client the MCP authorization flow found, or + // the client its administrator entered (`clientSource: 'installer'`). return createOAuth2GrantHandle({ connector: name, config: { grant: auth.grant, + ...(takesInstallerClient(auth) ? { clientSource: 'installer' as const } : {}), ...(auth.tokenUrl === undefined ? {} : { tokenUrl: auth.tokenUrl }), ...(auth.clientId === undefined ? {} : { clientId: auth.clientId }), ...(clientSecret === undefined ? {} : { clientSecret }), @@ -3858,9 +3870,12 @@ export class AutomationServicePlugin implements Plugin { * The organization's connection keeps the client it was connected with, * reused on a reconnect while the declaration still leaves the same fields * to discovery; otherwise the MCP authorization flow runs, registering a - * client when none is declared. Its refusals answer the door: `400` for a - * server that does not say what the flow needs, `503` for one that did not - * answer. + * client when none is declared. An instance taking the installer's client + * (`installerClient`, ADR-0143 D2.1's 2026-11-02 amendment) always runs + * the flow with that client in place of a declared one — a reconnect may + * bring a new client — and so never registers one. Its refusals answer the + * door: `400` for a server that does not say what the flow needs, `503` + * for one that did not answer. */ private async mcpAuthorizationClient( ctx: PluginContext, @@ -3869,11 +3884,12 @@ export class AutomationServicePlugin implements Plugin { auth: Extract, organizationId: string, redirectUri: string, + installerClient?: InstallerClient, ): Promise { const serverUrl = mcpDiscoveryServerUrl(entry, auth); if (serverUrl === undefined) return undefined; const store = this.connectorConnectionStoreOrNone(); - const existing = await store.readOrRefuse(organizationId, name); + const existing = installerClient ? undefined : await store.readOrRefuse(organizationId, name); if (existing?.hasRegisteredClient) { let kept: McpAuthorizationClient | undefined; try { @@ -3893,6 +3909,8 @@ export class AutomationServicePlugin implements Plugin { ...(auth.authorizationUrl === undefined ? {} : { authorizationUrl: auth.authorizationUrl }), ...(auth.tokenUrl === undefined ? {} : { tokenUrl: auth.tokenUrl }), ...(auth.clientId === undefined ? {} : { clientId: auth.clientId }), + ...(installerClient ? { clientId: installerClient.clientId } : {}), + ...(installerClient?.clientSecret === undefined ? {} : { clientSecret: installerClient.clientSecret }), ...(auth.resource === undefined ? {} : { resource: auth.resource }), }, redirectUri, @@ -3980,12 +3998,95 @@ export class AutomationServicePlugin implements Plugin { ...(record.expiresAt ? { expiresAt: record.expiresAt } : {}), ...(record.connectedAt ? { connectedAt: record.connectedAt } : {}), ...(record.connectedBy ? { connectedBy: record.connectedBy } : {}), + // ADR-0143 D2.1 (2026-11-02 amendment): which client the + // administrator entered, from the plain columns — never the secret, + // which only the encrypted client column holds. + ...(record.installerClientId + ? { client: { source: 'installer' as const, clientId: record.installerClientId, hasSecret: record.installerClientHasSecret } } + : {}), }; } + /** + * ADR-0143 D2.1 (2026-11-02 amendment) — the read and revoke legs take no + * client: one is entered only when a connect is begun. + */ + private refuseClientOffLeg(name: string, request: ConnectorConnectionRequest | undefined, leg: 'read' | 'revoke'): void { + if (request?.client === undefined) return; + throw credentialDoorRefusal( + `Connector instance '${name}': ${leg === 'read' ? 'reading a connection' : 'revoking a connection'} takes no OAuth ` + + 'client. A client is entered only when a connect is begun (POST /api/v1/automation/connectors/' + + `${name}/connection). Nothing was ${leg === 'read' ? 'read' : 'revoked'}.`, + 'VALIDATION_ERROR', + 400, + ); + } + + /** + * ADR-0143 D2.1 (2026-11-02 amendment) — the client a begun connect + * authorizes with when the instance declares `clientSource: 'installer'`, + * or `undefined` for any other instance. A begin of such an instance + * without a client is refused naming the vendor's registration step; a + * client offered to an instance that declares its client, or leaves it to + * MCP dynamic registration, is refused rather than ignored: no published + * form lets a connect replace a declared client. ⛔ No refusal carries a + * value the caller sent — only key names. + */ + private installerClientOfBegin( + name: string, + auth: Extract, + request: ConnectorConnectionRequest, + redirectUri: string, + ): InstallerClient | undefined { + const offered = request.client as unknown; + const refuse = (message: string): Error => credentialDoorRefusal(`${message} Nothing was connected.`, 'VALIDATION_ERROR', 400); + if (!takesInstallerClient(auth)) { + if (offered === undefined) return undefined; + throw refuse( + `Connector instance '${name}' does not take its OAuth client from the installer: its definition names the client, ` + + 'or leaves it to the MCP server\'s dynamic registration, so a connect cannot supply one. Begin the connect ' + + 'without `clientId` and `clientSecret`.', + ); + } + if (offered === undefined) { + throw refuse( + `Connector instance '${name}' takes its OAuth client from the installer (\`clientSource: 'installer'\`), and this ` + + 'connect names none. Register an OAuth client for this deployment with the vendor, giving it the redirect ' + + `URI ${redirectUri}, then begin the connect again with its \`clientId\`, and its \`clientSecret\` when the ` + + 'vendor issued one.', + ); + } + if (typeof offered !== 'object' || offered === null || Array.isArray(offered)) { + throw refuse(`Connector instance '${name}': the OAuth client of a connect is an object naming \`clientId\`.`); + } + const fields = offered as Record; + const unknownKeys = Object.keys(fields).filter((k) => k !== 'clientId' && k !== 'clientSecret').sort(); + if (unknownKeys.length > 0) { + throw refuse( + `Connector instance '${name}': the OAuth client of a connect takes only \`clientId\` and \`clientSecret\`; ` + + `unrecognised: ${unknownKeys.map((k) => `\`${k}\``).join(', ')}.`, + ); + } + const { clientId, clientSecret } = fields; + if (typeof clientId !== 'string' || clientId.trim().length === 0 || clientId.length > INSTALLER_CLIENT_ID_MAX_LENGTH) { + throw refuse( + `Connector instance '${name}': \`clientId\` must be the client id the vendor issued, a non-empty text of at most ` + + `${INSTALLER_CLIENT_ID_MAX_LENGTH} characters.`, + ); + } + if (clientSecret !== undefined && (typeof clientSecret !== 'string' || clientSecret.length === 0)) { + throw refuse( + `Connector instance '${name}': \`clientSecret\`, when given, must be the client secret the vendor issued, a ` + + 'non-empty text. Omit it for a public client.', + ); + } + return { clientId, ...(clientSecret === undefined ? {} : { clientSecret: clientSecret as string }) }; + } + /** ADR-0143 D3 — `IAutomationService.getConnectorConnection`. */ private async getConnectorConnection(ctx: PluginContext, request: ConnectorConnectionRequest): Promise { const { name, organizationId } = await this.connectionDoorSubject(ctx, request); + this.refuseClientOffLeg(name, request, 'read'); return this.describeConnection(name, organizationId, await this.connectorConnectionStoreOrNone().readOrRefuse(organizationId, name)); } @@ -4005,12 +4106,15 @@ export class AutomationServicePlugin implements Plugin { ); } const redirectUri = this.connectorOAuthRedirectUri(); - const client = await this.mcpAuthorizationClient(ctx, name, entry, auth, organizationId, redirectUri); + const installerClient = this.installerClientOfBegin(name, auth, request, redirectUri); + const client = await this.mcpAuthorizationClient(ctx, name, entry, auth, organizationId, redirectUri, installerClient); const state = createAuthorizationState(); const { verifier, challenge } = createPkcePair(); const expiresAt = Date.now() + PENDING_AUTHORIZATION_TTL_SECONDS * 1000; await this.pendingAuthorizations(ctx).put(state, { - connector: name, organizationId, userId, verifier, redirectUri, expiresAt, ...(client ? { client } : {}), + connector: name, organizationId, userId, verifier, redirectUri, expiresAt, + ...(client ? { client } : {}), + ...(installerClient ? { installerClient } : {}), }); ctx.logger.info(`[Automation] connect of connector instance '${name}' begun for organization '${organizationId}' by user '${userId}'.`); const resource = auth.resource ?? client?.resource; @@ -4019,7 +4123,7 @@ export class AutomationServicePlugin implements Plugin { organizationId, authorizationUrl: buildAuthorizationUrl({ authorizationUrl: client?.authorizationUrl ?? (auth.authorizationUrl as string), - clientId: client?.clientId ?? (auth.clientId as string), + clientId: client?.clientId ?? installerClient?.clientId ?? (auth.clientId as string), redirectUri, state, challenge, @@ -4088,8 +4192,19 @@ export class AutomationServicePlugin implements Plugin { context: request.context, }); const client = pending.client; - let clientSecret: string | undefined = client?.registered ? client.clientSecret : undefined; - if (!client?.registered && auth.credentialRef !== undefined) { + // ADR-0143 D2.1 (2026-11-02 amendment): an instance that takes the + // installer's client exchanges as the client its connect was begun + // with. A declaration that changed which kind of client it takes while + // the browser was away answers no connect this server began. + const installerClient = pending.installerClient; + if (takesInstallerClient(auth) !== (installerClient !== undefined)) { + throw refuse( + `Connector instance '${name}' changed where its OAuth client comes from while this connect was waiting for its ` + + 'callback, so the code was not used. Nothing was connected; begin the connect again.', + ); + } + let clientSecret: string | undefined = client?.registered ? client.clientSecret : installerClient?.clientSecret; + if (!client?.registered && installerClient === undefined && auth.credentialRef !== undefined) { const chain = await this.connectorCredentialChain(ctx); const rows = chain.store ? await chain.store.list() : new Map(); const resolution = await resolveConnectorCredential(chain, auth.credentialRef, rows); @@ -4116,7 +4231,10 @@ export class AutomationServicePlugin implements Plugin { token = await requestOAuth2Token({ connector: name, tokenUrl: client?.tokenUrl ?? (auth.tokenUrl as string), - client: { clientId: client?.clientId ?? (auth.clientId as string), ...(clientSecret === undefined ? {} : { clientSecret }) }, + client: { + clientId: client?.clientId ?? installerClient?.clientId ?? (auth.clientId as string), + ...(clientSecret === undefined ? {} : { clientSecret }), + }, params, }); } catch (err) { @@ -4137,8 +4255,15 @@ export class AutomationServicePlugin implements Plugin { ...(token.refreshToken === undefined ? {} : { refreshToken: token.refreshToken }), ...(token.scope === undefined ? {} : { scopes: token.scope }), ...(token.expiresAt === undefined ? {} : { expiresAt: new Date(token.expiresAt) }), + // The installer's client is kept whole, encrypted (ADR-0143 D2.1, + // 2026-11-02 amendment), and its id alone in plain text for reads. // A declared client's secret stays behind its credentialRef; only a registered one is kept. - ...(client + ...(installerClient + ? { + registeredClient: serializeInstallerClient(installerClient, client), + installerClient: { clientId: installerClient.clientId, hasSecret: installerClient.clientSecret !== undefined }, + } + : client ? { registeredClient: JSON.stringify(client.registered ? client @@ -4162,6 +4287,7 @@ export class AutomationServicePlugin implements Plugin { /** ADR-0143 D5 — `IAutomationService.revokeConnectorConnection`. */ private async revokeConnectorConnection(ctx: PluginContext, request: ConnectorConnectionRequest): Promise { const { name, organizationId, userId } = await this.connectionDoorSubject(ctx, request); + this.refuseClientOffLeg(name, request, 'revoke'); const removed = await this.onReconcileQueue(async () => { const gone = await this.connectorConnectionStoreOrNone().revoke(organizationId, name, userId); if (!gone) return false; diff --git a/packages/services/service-automation/src/sys-connector-connection.object.ts b/packages/services/service-automation/src/sys-connector-connection.object.ts index c06f1a72f96..bfa61994cdd 100644 --- a/packages/services/service-automation/src/sys-connector-connection.object.ts +++ b/packages/services/service-automation/src/sys-connector-connection.object.ts @@ -13,7 +13,14 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * - the refresh token (`refresh_token`, `Field.secret()`), replaced * atomically when the authorization server rotates it (D2.1, D4); * - the grant's status, scopes and expiry, for the Console and for the - * `degraded` reason D4 names ("not connected", "reconnect required"). + * `degraded` reason D4 names ("not connected", "reconnect required"); + * - the client the connection authorizes with when the instance does not + * declare one: a client the MCP authorization flow discovered or + * registered, or the client the administrator entered on an instance + * declaring `clientSource: 'installer'` (D2.1, 2026-11-02 amendment), in + * `registered_client` (`Field.secret()`), with the installer's client id + * repeated in plain `client_id` so a read can show which client is set + * without decrypting anything. * * ⛔ Not here: the access token. It lives in the automation plugin's * in-process cache only (D3, last bullet), as does every `client_credentials` @@ -87,7 +94,23 @@ export const SysConnectorConnection = ObjectSchema.create({ registered_client: Field.secret({ label: 'Registered Client', description: - "The OAuth client a `provider: 'mcp'` instance's connection authorizes with, when the MCP authorization flow discovered its endpoints or registered it (ADR-0143 D2.1): the issuer, the two endpoints, the resource indicator, the client id and any client secret the registration issued. Encrypted at rest and masked on every read; absent when the instance declares its endpoints and client.", + "The OAuth client this connection authorizes with when the instance does not declare one (ADR-0143 D2.1): the client a `provider: 'mcp'` instance's MCP authorization flow discovered or registered (the issuer, the two endpoints, the resource indicator, the client id and any client secret the registration issued), or the client the administrator entered on an instance declaring `clientSource: 'installer'` (its id and secret, and the endpoints when they were discovered). Encrypted at rest and masked on every read; absent when the instance declares its endpoints and client.", + }), + + client_id: Field.text({ + label: 'Client ID', + readonly: true, + maxLength: 512, + description: + "The id of the OAuth client the administrator entered on this connection, for an instance declaring `clientSource: 'installer'` (ADR-0143 D2.1). Not a secret: it is what a read shows to say which client is set. The client itself, with its secret, is kept in `registered_client`. Empty for any other instance.", + }), + + client_has_secret: Field.boolean({ + label: 'Client Secret Set', + readonly: true, + defaultValue: false, + description: + "Whether the administrator's client (`client_id`) came with a client secret, which is kept encrypted in `registered_client` and never read back here. `false` for a public client and for any instance whose client is not the installer's.", }), status: Field.select(['connected', 'reconnect_required'], { diff --git a/packages/spec/liveness/connector.json b/packages/spec/liveness/connector.json index eaeadf14f5d..b3c425172af 100644 --- a/packages/spec/liveness/connector.json +++ b/packages/spec/liveness/connector.json @@ -64,7 +64,7 @@ "verifiedAt": "2026-09-17", "evidence": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — `auth = await this.resolveInstanceAuth(entry.auth, resolver, name, provider)` resolves the declared `credentialRef` through the secrets/env layer and puts the RESOLVED credential on the provider context; a failure there degrades or fails the instance by name (ADR-0097 §3). packages/connectors/connector-rest/src/rest-provider.ts, packages/connectors/connector-openapi/src/openapi-provider.ts and packages/connectors/connector-mcp/src/mcp-provider.ts each read `ctx.auth` and use it to sign the upstream call. packages/spec/src/integration/connector.zod.ts#DeclarativeConnectorEntrySchema refuses it on an entry with no `provider`.", "producer": "packages/services/service-automation/src/plugin.ts#materializeDeclaredConnectors — the ADR-0097 reconcile: it builds the desired set from the declared `connectors:` items, resolves the provider factory and calls `engine.registerConnector(def, handlers, 'declarative')`. This is the ONLY seam by which an authored entry reaches the connector registry, so it is the producer every `live` row below depends on (#4837): without it a declared connector is a stored document and nothing else.", - "note": "The live half of the pair whose other half (`authentication`) is refused ANY VALUE BUT `{ type: 'none' }` — the key itself is accepted and inert, so ⛔ do not compress this to '`authentication` is refused' (see that row): this key is how an authored connector carries a credential at all, and it carries a REFERENCE, never the secret. 2026-11-02 (#28864): one member of this union is `planned`, not `live` — `clientSource` on the `oauth2` `authorization_code` shape (ADR-0143 D2.1 amendment), until #28866 makes the automation service read the installer's client from the connection. The walker does not descend into the `auth` union, so this row's `live` verdict is the only ledger coordinate over the key and would otherwise cover it unseen; its `.describe()` carries `[PLANNED — not enforced]`. Meanwhile an instance that declares it is held degraded for a missing `clientId` off `provider: 'mcp'` over http (`oauth2NotServableReason` in service-automation's plugin.ts), and on MCP over http the provider registers a client dynamically instead of taking the installer's. When #28866 lands, drop this sentence and the marker together." + "note": "The live half of the pair whose other half (`authentication`) is refused ANY VALUE BUT `{ type: 'none' }` — the key itself is accepted and inert, so ⛔ do not compress this to '`authentication` is refused' (see that row): this key is how an authored connector carries a credential at all, and it carries a REFERENCE, never the secret." }, "actions": { "children": { diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 131c90caffd..6b4c0c2b913 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -1157,6 +1157,19 @@ export interface ConnectorConnectionState { /** When it was last connected, and by whom. */ connectedAt?: string; connectedBy?: string; + /** + * ADR-0143 D2.1 (2026-11-02 amendment) — the OAuth client the + * administrator entered on this connection, for an instance that declares + * `clientSource: 'installer'`: its id, and whether a client secret is kept + * with it. ⛔ Never the secret itself, which stays encrypted in the + * connection record. Absent for an instance whose client is declared or + * registered dynamically. + */ + client?: { + source: 'installer'; + clientId: string; + hasSecret: boolean; + }; } /** What {@link IAutomationService.getConnectorConnection} and its revoke / begin siblings are asked about. */ @@ -1165,6 +1178,20 @@ export interface ConnectorConnectionRequest { connector: string; /** The caller — its organization selects the connection, its user stamps the row. */ context?: ExecutionContext; + /** + * ADR-0143 D2.1 (2026-11-02 amendment) — the OAuth client the + * administrator registered with the vendor against this deployment's + * redirect URI, for an instance that declares `clientSource: 'installer'`. + * Read by {@link IAutomationService.beginConnectorConnection} only, which + * requires it for such an instance and refuses it for any other (`400 + * VALIDATION_ERROR`); the read and revoke members refuse it. The secret, + * when the vendor issued one, is kept encrypted in the connection record + * and never answered back. + */ + client?: { + clientId: string; + clientSecret?: string; + }; } /** What {@link IAutomationService.beginConnectorConnection} answers: where to send the administrator's browser. */ @@ -1511,7 +1538,9 @@ export interface IAutomationService { * URL to send the browser to. Nothing is stored in the connection record * until the callback completes. Rejects as {@link getConnectorConnection} * does, plus `503 SERVICE_UNAVAILABLE` when the deployment has no fixed - * origin for the redirect URI. + * origin for the redirect URI, and `400 VALIDATION_ERROR` for an instance + * declaring `clientSource: 'installer'` begun without `client`, or any + * other instance begun with one (ADR-0143 D2.1, 2026-11-02 amendment). */ beginConnectorConnection?(request: ConnectorConnectionRequest): Promise; diff --git a/packages/spec/src/integration/connector-auth-grant-kinds.test.ts b/packages/spec/src/integration/connector-auth-grant-kinds.test.ts index 91f58e090a7..1a66f17b7ac 100644 --- a/packages/spec/src/integration/connector-auth-grant-kinds.test.ts +++ b/packages/spec/src/integration/connector-auth-grant-kinds.test.ts @@ -216,24 +216,24 @@ describe("ADR-0143 D2.1 — an `authorization_code` client supplied by the insta ); }); - // Declared before it is served (ADR-0049): until #28866 makes the automation - // service read the installer's client, the key says so on the spec surface. - // The liveness walker does not descend into the `auth` union, so the - // `connector/auth` row is the only ledger coordinate over the key. #28866 - // drops the marker and the row's sentence together, and flips this test. - it('is PLANNED until the automation service serves it: the describe marker, and the ledger row over the key', () => { + // Served (ADR-0049): the automation service's connect door takes the + // installer's client and the connection keeps it, so the key no longer + // carries the planned marker, and the `connector/auth` ledger row — the only + // coordinate over the key, since the walker does not descend into the `auth` + // union — no longer calls it planned. + it('is served: no PLANNED describe marker, and no planned sentence on the ledger row over the key', () => { const shape = (ConnectorInstanceOAuth2AuthorizationCodeAuthSchema as unknown as { shape: Record; }).shape; - expect(shape.clientSource.description).toMatch(/^\[PLANNED — not enforced\] /); + expect(shape.clientSource.description).not.toMatch(/PLANNED/); expect(shape.clientSource.description).toContain('ADR-0143 D2.1'); - // Customer-facing prose carries no tracker id (check:doc-authoring); the slice is named in the ledger row. + // Customer-facing prose carries no tracker id (check:doc-authoring). expect(shape.clientSource.description).not.toMatch(/#\d{3,}/); const ledger = JSON.parse(fs.readFileSync(new URL('../../liveness/connector.json', import.meta.url), 'utf8')); - const note: string = ledger.props.auth.note; - expect(note).toContain('`clientSource`'); - expect(note).toContain('`planned`'); - expect(note).toContain('#28866'); + const row = ledger.props.auth; + expect(row.status).toBe('live'); + expect(row.note).not.toContain('`clientSource`'); + expect(row.note).not.toContain('`planned`'); }); }); diff --git a/packages/spec/src/shared/connector-auth.zod.ts b/packages/spec/src/shared/connector-auth.zod.ts index d6e27a392fc..879673f98a4 100644 --- a/packages/spec/src/shared/connector-auth.zod.ts +++ b/packages/spec/src/shared/connector-auth.zod.ts @@ -341,10 +341,8 @@ const OAUTH2_GUIDANCE: Readonly> = { * refused below, and a `clientId` beside `clientSource` is refused by * `DeclarativeConnectorEntrySchema` with the other client-key rules. This * is the form for an app installed on deployments its publisher never sees. - * Declared before it is served: the key carries `[PLANNED — not enforced]` - * until the automation service reads the installer's client from the - * connection, and the liveness ledger's `connector/auth` row says so, - * because the walker does not descend into this union. + * The automation service's connect door takes the client from the + * administrator and keeps it in the connection record. * - **Dynamic registration** — neither, on `provider: 'mcp'` over http: the * provider runs the MCP authorization flow and registers a client per * connection. @@ -369,17 +367,12 @@ export const ConnectorInstanceOAuth2AuthorizationCodeAuthSchema = lazySchema(() authorizationUrl: z.string().url().optional().describe( 'Authorization endpoint. Required (except `provider: \'mcp\'` over http, which discovers it).', ), - // Declared before it is served: the marker comes off when the server slice - // (#28866) reads the installer's client from the connection, together with - // the sentence on the `connector/auth` row of packages/spec/liveness/connector.json. clientSource: z.enum(['installer']).optional().describe( - "[PLANNED — not enforced] Where the OAuth client comes from. `'installer'`: the administrator of each deployment " - + "registers their own client with the vendor, against that deployment's callback, and enters its id and secret on " - + "the instance's connection, so the definition names no `clientId` and no `credentialRef`. Omit it to name a " - + "pre-registered `clientId` here, or to let `provider: 'mcp'` over http register a client. Not served yet: the " - + 'automation service reads it once the server half of the ADR-0143 D2.1 amendment lands. Until then, off ' - + "`provider: 'mcp'` over http, an instance that declares it is held degraded for a missing `clientId`; on " - + "`provider: 'mcp'` over http the provider registers a client dynamically instead of taking the installer's.", + "Where the OAuth client comes from. `'installer'`: the administrator of each deployment registers their own client " + + "with the vendor, against that deployment's callback, and enters its id and secret on the instance's connection, " + + "so the definition names no `clientId` and no `credentialRef`. Omit it to name a pre-registered `clientId` here, or " + + "to let `provider: 'mcp'` over http register a client. The connection keeps the client encrypted, and every " + + 'authorization, code exchange and refresh of that connection uses it (ADR-0143 D2.1).', ), credentialRef: z.string().min(1).optional().describe( 'Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a '