diff --git a/.changeset/28864-oauth2-installer-client.md b/.changeset/28864-oauth2-installer-client.md new file mode 100644 index 00000000000..b2fc5b496a0 --- /dev/null +++ b/.changeset/28864-oauth2-installer-client.md @@ -0,0 +1,36 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): an `oauth2` `authorization_code` connector instance can take its OAuth client from the administrator who installs it — `clientSource: 'installer'` + +Clause-②: yes (widening) +Metadata-protocol: added +Ruling-ref: 6566728470 + +A vendor accepts an OAuth client only at the callback URLs its owner registered, and the platform's callback is fixed per deployment. A `clientId` written into a connector definition is the publisher's client, so an app installed on a deployment its publisher never saw could not connect there. The `authorization_code` instance shape gains one optional key that says the client comes from the installer instead: + +```ts +connectors: [{ + name: 'esign', + label: 'E-Signature', + type: 'api', + provider: 'openapi', + providerConfig: { spec: 'https://api.vendor.example.com/openapi.json' }, + auth: { + type: 'oauth2', + grant: 'authorization_code', + authorizationUrl: 'https://account.vendor.example.com/oauth/authorize', + tokenUrl: 'https://account.vendor.example.com/oauth/token', + scopes: ['signature'], + clientSource: 'installer', + }, +}] +``` + +- **What it means.** 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. The definition names no client and no secret. +- **What it refuses.** A `clientId` beside `clientSource` is refused at publish, located at `auth.clientId`: the definition would name two clients. A `credentialRef` without its `clientId` is refused as before, and its message now names this form. An unknown value is refused at `auth.clientSource`. On `grant: 'client_credentials'` the key is refused with guidance, because that grant keeps no connection to enter a client on. +- **What it does not change.** The endpoints stay declared, or discovered on `provider: 'mcp'` over http: the installer owns the client, not the vendor's authorization server. An instance that names a pre-registered `clientId` without the key parses exactly as before. That form is kept for an app whose author registers the callback of every deployment that installs it, such as an organization's own app. +- **Not yet served.** The automation service does not read the key yet. The connection door that takes the installer's client, and the form that asks for it, land in follow-up changes. Until then an instance that declares it on a non-MCP provider is held degraded, with a reason naming the missing `clientId`, and is never called without a client. On `provider: 'mcp'` over http the service runs the MCP authorization flow as it does for an instance that leaves its client to dynamic registration. The key's reference row carries `[PLANNED — not enforced]` until then, and the liveness ledger's `connector/auth` row records it as `planned`. + +ADR-0143 D2.1 carries the amendment (2026-11-02). diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index dd91a8550ff..c2266a42aba 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -267,10 +267,11 @@ const result = ConnectorSchema.parse(data); | **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | | **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | | **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | -| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **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). | -| **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a public client. Never an inline secret. | +| **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. | +| **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']` @@ -280,7 +281,7 @@ const result = ConnectorSchema.parse(data); | **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | | **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | | **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | -| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **clientId** | `string` | optional | OAuth2 client id of a pre-registered client (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | | **grant** | `'client_credentials'` | ✅ | No user step: the client exchanges its own credentials for a token. | | **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret; declared together with `clientId` (a `client_credentials` client is confidential). Never an inline secret. | @@ -561,10 +562,11 @@ This schema accepts one of the following structures: | **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | | **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | | **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | -| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **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). | -| **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a public client. Never an inline secret. | +| **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. | +| **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. | --- @@ -580,7 +582,7 @@ This schema accepts one of the following structures: | **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | | **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | | **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | -| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **clientId** | `string` | optional | OAuth2 client id of a pre-registered client (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | | **grant** | `'client_credentials'` | ✅ | No user step: the client exchanges its own credentials for a token. | | **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret; declared together with `clientId` (a `client_credentials` client is confidential). Never an inline secret. | @@ -599,10 +601,11 @@ This schema accepts one of the following structures: | **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | | **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | | **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | -| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **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). | -| **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a public client. Never an inline secret. | +| **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. | +| **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. | --- @@ -617,7 +620,7 @@ This schema accepts one of the following structures: | **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | | **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | | **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | -| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **clientId** | `string` | optional | OAuth2 client id of a pre-registered client (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | | **grant** | `'client_credentials'` | ✅ | No user step: the client exchanges its own credentials for a token. | | **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret; declared together with `clientId` (a `client_credentials` client is confidential). Never an inline secret. | @@ -870,10 +873,11 @@ Connector type | **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | | **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | | **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | -| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **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). | -| **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret of a pre-registered client (requires `clientId`). Omit for a public client. Never an inline secret. | +| **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. | +| **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']` @@ -883,7 +887,7 @@ Connector type | **tokenUrl** | `string` | optional | Token endpoint. Required (except `provider: 'mcp'` over http, which discovers it). | | **scopes** | `string[]` | optional | Scopes to request; sent space-separated. | | **resource** | `string` | optional | Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience). | -| **clientId** | `string` | optional | OAuth2 client id (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | +| **clientId** | `string` | optional | OAuth2 client id of a pre-registered client (not a secret). Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically). | | **grant** | `'client_credentials'` | ✅ | No user step: the client exchanges its own credentials for a token. | | **credentialRef** | `string` | optional | Secrets-layer reference resolved to the client secret; declared together with `clientId` (a `client_credentials` client is confidential). Never an inline secret. | diff --git a/docs/adr/0143-outbound-connector-auth-is-open-tier.md b/docs/adr/0143-outbound-connector-auth-is-open-tier.md index 944d2cae6f5..5f6bed8096e 100644 --- a/docs/adr/0143-outbound-connector-auth-is-open-tier.md +++ b/docs/adr/0143-outbound-connector-auth-is-open-tier.md @@ -207,6 +207,50 @@ carry and forbid; exact key spelling is the spec slice's, reviewed against this > `credentialRef`" this decision names. It resolves through the same chain as `credentialRef`, and > an instance whose public key does not resolve is degraded, not served unverified. +> **Amended (2026-11-02, #28864) — D2.1: the installing administrator may supply the OAuth client.** The +> maintainer ruled on 2026-11-02, verbatim: 「同意」, accepting letter B of director record 6566728470 on +> #28702: an open-edition OAuth connector app ships no client of its publisher's, and the administrator of +> each deployment registers their own client with the vendor and enters it on the connection. +> +> - **Why.** D5 fixes the redirect URI per deployment, and a vendor accepts a client only at the callbacks +> that client's owner registered. A `clientId` written into the definition is the publisher's client, so +> an app installed on a deployment its publisher never sees cannot connect there. +> - **What the variant carries.** An `authorization_code` instance may declare `clientSource: 'installer'`. +> The administrator of each deployment then registers their own client with the vendor, against that +> deployment's callback, and enters its id and secret on the instance's connection. The definition names +> no client on any provider: a `clientId` beside `clientSource` is refused at publish, located at +> `auth.clientId`, and a `credentialRef` without its `clientId` already was. The endpoints do not move. +> They are declared, or discovered on `provider: 'mcp'` over http: the installer owns the client, not the +> vendor's authorization server. The shape is +> `packages/spec/src/shared/connector-auth.zod.ts#ConnectorInstanceOAuth2AuthorizationCodeAuthSchema`, +> and the client-key rule is on `packages/spec/src/integration/connector.zod.ts#DeclarativeConnectorEntrySchema`. +> - **Where the installer's client lives.** In the instance's connection record (D3, Q2), in the same +> per-connection client record that keeps a dynamically registered client, encrypted at rest through the +> `secret` channel and masked on every read. D3's list of what is persisted therefore reads: the +> refresh token, a dynamically registered or installer-supplied client, and the grant's status, scopes +> and expiry. The client secret entered there is not a `credentialRef`, and D3's first bullet still +> holds: the administrator names it, not the author. A connection belongs to one organization (Q1), so +> each organization's connection carries its own client, and replacing it is a reconnect. +> - **A packaged `clientId` is kept, for private apps.** An instance that names its `clientId` works as +> D2.1 states, because its author registers the callback of every deployment that installs it: an +> organization's own app, or a vendor MCP server without dynamic registration whose client the author +> registered (HubSpot, Q3). Refusing it would narrow what such an app may write today, and the schema +> cannot tell a private app from a listing. The ruling's rule for an official listing, that it ships no +> ObjectStack-owned client id or secret, is held where listings are reviewed, not by the schema. +> - **`client_credentials` is unchanged.** It keeps no connection record (D3), so there is nowhere to +> enter a client. Its client stays `clientId` with the `credentialRef` of its secret, which the +> installing administrator sets in the secrets layer, and `clientSource` is refused on it. +> - **Not decided here: an API host per account.** Some vendors split sandbox and production +> authorization hosts and give each production account its own API host, learned after the grant +> (DocuSign, per its documentation; not measured here). The endpoints stay in the definition, so an app +> targets one host set, sandbox first. Discovering an API host per connection is a follow-up. +> - **Slices.** The server slice keeps the installer's client on the connection and sends it: the connect +> door takes it, and the grant handle uses it in place of a declared one. The ui slice asks for it on +> the connection form and shows the deployment's redirect URI. Until the server slice lands, the key is +> declared and not served, and its schema description says so: off `provider: 'mcp'` over http an +> instance that declares it is held degraded for a missing `clientId`, and on MCP over http the +> provider registers a client dynamically instead of taking the installer's. + ### D3 — Secrets are references; tokens are runtime state in a connection record - Every secret an author names is a `credentialRef`, resolved through `CredentialResolver` (ADR-0097 §3, @@ -217,6 +261,9 @@ carry and forbid; exact key spelling is the spec slice's, reviewed against this client a dynamic registration created, and the grant's status, scopes and expiry. These live in a dedicated connection record bound to the instance (Q2), their secrets encrypted at rest through the `secret` channel (ADR-0100). + > **Amended (2026-11-02, #28864).** This list also holds a client the installing administrator supplies, + > kept in the same per-connection client record as a dynamically registered one. See the 2026-11-02 + > D2.1 note under D2. - Access tokens are never persisted. They live in an in-process cache. This includes every `client_credentials` and `app-credential` token, which the configured secret re-derives at any time. diff --git a/packages/spec/authorable-surface/integration.json b/packages/spec/authorable-surface/integration.json index 45061bb145a..e8ca0535950 100644 --- a/packages/spec/authorable-surface/integration.json +++ b/packages/spec/authorable-surface/integration.json @@ -54,6 +54,7 @@ "integration/ConnectorInstanceNoAuth:type", "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:authorizationUrl", "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:clientId", + "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:clientSource", "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:credentialRef", "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:grant", "integration/ConnectorInstanceOAuth2AuthorizationCodeAuth:resource", diff --git a/packages/spec/liveness/connector.json b/packages/spec/liveness/connector.json index b3c425172af..eaeadf14f5d 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." + "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." }, "actions": { "children": { 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 0b46b844f1d..91f58e090a7 100644 --- a/packages/spec/src/integration/connector-auth-grant-kinds.test.ts +++ b/packages/spec/src/integration/connector-auth-grant-kinds.test.ts @@ -7,8 +7,10 @@ // error the ADR's Acceptance asks for; every member has a CONTROL accept, so a // refusal that fired on everything could not pass. +import fs from 'node:fs'; import { describe, it, expect } from 'vitest'; import { DeclarativeConnectorEntrySchema, ConnectorInstanceAuthSchema } from './connector.zod'; +import { ConnectorInstanceOAuth2AuthorizationCodeAuthSchema } from '../shared/connector-auth.zod'; const entry = (auth: unknown, over: Record = {}) => ({ name: 'vendor', @@ -152,6 +154,89 @@ describe('ADR-0143 D2.1 — `oauth2` instance auth', () => { }); }); +// ADR-0143 D2.1, amended 2026-11-02 (#28702 letter B): the administrator of +// each deployment registers their own OAuth client with the vendor, against +// that deployment's callback, and enters it on the instance's connection. The +// definition says so with `clientSource: 'installer'` and names no client. +describe("ADR-0143 D2.1 — an `authorization_code` client supplied by the installer (`clientSource: 'installer'`)", () => { + const { clientId: _c, credentialRef: _r, ...endpointsOnly } = authorizationCode; + const installer = { ...endpointsOnly, clientSource: 'installer' }; + + it('CONTROL: accepts the installer form with the endpoints and no client, on a non-MCP provider', () => { + const parsed = DeclarativeConnectorEntrySchema.parse(entry(installer)); + expect(parsed.auth).toEqual(installer); + }); + + it("CONTROL: accepts the installer form on `provider: 'mcp'` over http, with the endpoints left to discovery", () => { + const auth = { type: 'oauth2', grant: 'authorization_code', clientSource: 'installer' }; + expect(DeclarativeConnectorEntrySchema.parse(entry(auth, mcpHttp)).auth).toEqual(auth); + }); + + it('keeps a packaged `clientId` accepted without `clientSource` (the declared form, for a private app)', () => { + // The existing accept set does not move: the same input parses before and + // after the amendment. Refusing it would be a narrowing beside this widening. + expect(DeclarativeConnectorEntrySchema.safeParse(entry(authorizationCode)).success).toBe(true); + }); + + it('refuses a packaged `clientId` beside `clientSource`, located at `auth.clientId`', () => { + expect(located(entry({ ...installer, clientId: 'client-123' }))).toEqual(['custom @ auth.clientId']); + expect(located(entry({ ...installer, clientId: 'client-123', credentialRef: 'VENDOR_CLIENT_SECRET' }))).toEqual([ + 'custom @ auth.clientId', + ]); + expect(located(entry({ ...installer, clientId: 'client-123' }, mcpHttp))).toEqual(['custom @ auth.clientId']); + }); + + it('refuses a client-secret reference with the installer form: the secret is entered on the connection with its client', () => { + expect(located(entry({ ...installer, credentialRef: 'VENDOR_CLIENT_SECRET' }))).toEqual(['custom @ auth.credentialRef']); + }); + + it('still requires the endpoints off MCP-over-http: the installer owns the client, not the authorization server', () => { + expect(located(entry({ type: 'oauth2', grant: 'authorization_code', clientSource: 'installer' }))).toEqual([ + 'custom @ auth.tokenUrl', + 'custom @ auth.authorizationUrl', + ]); + }); + + it('refuses an unknown `clientSource` value at `auth.clientSource`', () => { + expect(located(entry({ ...installer, clientSource: 'publisher' }))).toEqual(['invalid_value @ auth.clientSource']); + }); + + it('refuses `clientSource` on `client_credentials`, which keeps no connection to hold the client', () => { + const result = DeclarativeConnectorEntrySchema.safeParse(entry({ ...clientCredentials, clientSource: 'installer' })); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => [i.code, i.path.join('.'), (i as { keys?: string[] }).keys])).toEqual([ + ['unrecognized_keys', 'auth', ['clientSource']], + ]); + }); + + it('the definition carries no client and no secret: the declared keys are the endpoints, the scopes and the source', () => { + const parsed = DeclarativeConnectorEntrySchema.parse(entry(installer)); + expect(Object.keys(parsed.auth as object).sort()).toEqual( + ['authorizationUrl', 'clientSource', 'grant', 'scopes', 'tokenUrl', 'type'], + ); + }); + + // 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', () => { + const shape = (ConnectorInstanceOAuth2AuthorizationCodeAuthSchema as unknown as { + shape: Record; + }).shape; + expect(shape.clientSource.description).toMatch(/^\[PLANNED — not enforced\] /); + 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. + 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'); + }); +}); + describe('ADR-0143 D2.2 — `app-credential` instance auth', () => { it('CONTROL: accepts each vendor with an app id and the app secret as a reference', () => { for (const vendor of ['feishu', 'dingtalk', 'wecom']) { diff --git a/packages/spec/src/integration/connector-credential-redaction.test.ts b/packages/spec/src/integration/connector-credential-redaction.test.ts index e25e4bbe5b2..bb0f220d033 100644 --- a/packages/spec/src/integration/connector-credential-redaction.test.ts +++ b/packages/spec/src/integration/connector-credential-redaction.test.ts @@ -355,7 +355,7 @@ describe('the connector redactor — purity and the untouched remainder', () => * until it is confirmed to be a reference or not a secret, and listed. */ const DECLARED_NOT_A_SECRET = [ - 'appId', 'authorizationUrl', 'clientId', 'credentialRef', 'grant', 'headerName', 'keyId', + 'appId', 'authorizationUrl', 'clientId', 'clientSource', 'credentialRef', 'grant', 'headerName', 'keyId', 'paramName', 'resource', 'scheme', 'scopes', 'tokenUrl', 'type', 'username', 'vendor', 'vendorPublicKeyRef', ]; diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 9566b84066d..eebb0a8518c 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -1361,20 +1361,46 @@ export const DeclarativeConnectorEntrySchema = lazySchema(() => // authorization-server metadata, dynamic client registration) and keeps // the registered client in the connection record, never in metadata. The // rule needs the provider, so it lives here rather than on the auth shape. - if (entry.auth?.type === 'oauth2' && !isMcpHttpInstance(entry)) { - const missing: Array<[key: 'tokenUrl' | 'clientId' | 'authorizationUrl', why: string]> = []; - if (entry.auth.tokenUrl === undefined) missing.push(['tokenUrl', 'its token endpoint']); - if (entry.auth.clientId === undefined) missing.push(['clientId', 'its pre-registered client id']); - if (entry.auth.grant === 'authorization_code' && entry.auth.authorizationUrl === undefined) { - missing.push(['authorizationUrl', "its authorization endpoint (`grant: 'authorization_code'`)"]); - } - for (const [key, why] of missing) { + // + // ADR-0143 D2.1, amended 2026-11-02: an `authorization_code` instance + // that declares `clientSource: 'installer'` takes its client from the + // administrator of each deployment, entered on the instance's connection. + // It names no `clientId` on any provider, and a `clientId` beside it is + // refused, because the definition would then name two clients. The + // endpoints stay the definition's (or the MCP flow's): the installer owns + // the client, not the vendor's authorization server. This exclusion sits + // here with the other client-key rules and not on the auth shape, because + // the published JSON Schema has no form for a shape-level exclusion + // (`shared/refinement-projection.ts`), while this refinement is already one + // the published file marks as not stated. + if (entry.auth?.type === 'oauth2') { + const installerClient = entry.auth.grant === 'authorization_code' && entry.auth.clientSource === 'installer'; + if (installerClient && entry.auth.clientId !== undefined) { ctx.addIssue({ code: 'custom', - path: ['auth', key], - message: `Connector instance '${entry.name}' declares \`auth: { type: 'oauth2' }\` without \`${key}\`: name ${why}. Only \`provider: 'mcp'\` over an http transport may omit the endpoints and the client id, because that provider discovers them and registers a client itself (ADR-0143 D2.1).`, + path: ['auth', 'clientId'], + message: `Connector instance '${entry.name}' declares \`clientSource: 'installer'\` and a packaged \`clientId\`. An installer-supplied client is registered with the vendor by the administrator of each deployment, against that deployment's callback, and entered on the instance's connection, so the definition names no client. Delete \`clientId\` (and its \`credentialRef\`), or delete \`clientSource\` if the package's author registers the callback of every deployment that installs it, as for a private app (ADR-0143 D2.1).`, }); } + if (!isMcpHttpInstance(entry)) { + const missing: Array<[key: 'tokenUrl' | 'clientId' | 'authorizationUrl', why: string]> = []; + if (entry.auth.tokenUrl === undefined) missing.push(['tokenUrl', 'its token endpoint']); + if (entry.auth.clientId === undefined && !installerClient) { + missing.push(['clientId', entry.auth.grant === 'authorization_code' + ? "its pre-registered client id, or declare `clientSource: 'installer'` so the administrator of each deployment enters their own client on the connection" + : 'its pre-registered client id']); + } + if (entry.auth.grant === 'authorization_code' && entry.auth.authorizationUrl === undefined) { + missing.push(['authorizationUrl', "its authorization endpoint (`grant: 'authorization_code'`)"]); + } + for (const [key, why] of missing) { + ctx.addIssue({ + code: 'custom', + path: ['auth', key], + message: `Connector instance '${entry.name}' declares \`auth: { type: 'oauth2' }\` without \`${key}\`: name ${why}. \`provider: 'mcp'\` over an http transport may omit the endpoints and the client id, because that provider discovers its endpoints and can register a client itself (ADR-0143 D2.1).`, + }); + } + } } // ADR-0143 D2.3 — a `signed` instance is signed by the HTTP providers // (`openapi`, `rest`), which apply the host's request signer to every diff --git a/packages/spec/src/shared/connector-auth.zod.ts b/packages/spec/src/shared/connector-auth.zod.ts index 23924211862..d6e27a392fc 100644 --- a/packages/spec/src/shared/connector-auth.zod.ts +++ b/packages/spec/src/shared/connector-auth.zod.ts @@ -288,8 +288,12 @@ export const ConnectorOAuth2GrantSchema = z.enum(['authorization_code', 'client_ .describe('OAuth2 grant (RFC 6749): `authorization_code` (an administrator connects once; PKCE S256 always) or `client_credentials` (no user step).'); export type ConnectorOAuth2Grant = z.input; -/** The `oauth2` instance keys both grants share (ADR-0143 D2.1). */ -function oauth2SharedShape() { +/** + * The `oauth2` instance keys both grants share (ADR-0143 D2.1). `clientIdNote` + * is the grant's own sentence on where else a client may come from, so each + * grant's reference row says only what holds for it. + */ +function oauth2SharedShape(clientIdNote: string) { return { type: z.literal('oauth2'), tokenUrl: z.string().url().optional().describe( @@ -299,9 +303,7 @@ function oauth2SharedShape() { resource: z.string().url().optional().describe( 'Resource indicator (RFC 8707) — the API the token is for, where the authorization server asks for one (some call it the audience).', ), - clientId: z.string().min(1).optional().describe( - 'OAuth2 client id (not a secret). Required (except `provider: \'mcp\'` over http, where the provider may register a client dynamically).', - ), + clientId: z.string().min(1).optional().describe(`OAuth2 client id of a pre-registered client (not a secret). ${clientIdNote}`), }; } @@ -321,6 +323,36 @@ const OAUTH2_GUIDANCE: Readonly> = { * connection record and refreshes per dispatch (ADR-0143 D3, D4). PKCE with * `S256` always, with no switch to turn it off. `credentialRef` (the client * secret) is optional: a public client has none. + * + * The OAuth client comes from one of three places (ADR-0143 D2.1, amended + * 2026-11-02): + * + * - **The definition** — a pre-registered `clientId`, and the `credentialRef` + * of its secret. Whoever publishes the package fixes the client, so the + * vendor accepts it only on deployments whose callback that client's owner + * registered: an app built by the organization that also deploys it (a + * private app), or an MCP server without dynamic registration whose client + * the same author registered. + * - **The installer** — `clientSource: '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, where a dynamically registered client is kept too (D3). The + * definition names no client: a `credentialRef` without its `clientId` is + * 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. + * - **Dynamic registration** — neither, on `provider: 'mcp'` over http: the + * provider runs the MCP authorization flow and registers a client per + * connection. + * + * A packaged `clientId` stays accepted. Refusing it would narrow what a + * private app may write today, and the schema cannot tell a private app from + * a listing; the rule that an open-edition listing ships no client of its + * publisher's belongs to the listing's review. */ export const ConnectorInstanceOAuth2AuthorizationCodeAuthSchema = lazySchema(() => strictObject({ surface: "this `oauth2` connector auth (`grant: 'authorization_code'`)", @@ -329,16 +361,35 @@ export const ConnectorInstanceOAuth2AuthorizationCodeAuthSchema = lazySchema(() // A different word for the same intent, out of reach of edit distance. aliases: { audience: 'resource' }, }, { - ...oauth2SharedShape(), + ...oauth2SharedShape( + "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: z.literal('authorization_code').describe('An administrator connects once; the platform refreshes the grant per dispatch.'), 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.", + ), 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 public client. Never an inline secret.', + '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.', ), }).refine(dependentRequired({ credentialRef: ['clientId'] }), { - message: 'A client secret belongs to a pre-registered client: declare its `clientId` beside `credentialRef`, or omit both on `provider: \'mcp\'` over http to let the provider register a client.', + message: 'A client secret belongs to a pre-registered client: declare its `clientId` beside `credentialRef`, or omit both, ' + + "either on `provider: 'mcp'` over http to let the provider register a client, or with `clientSource: 'installer'`, " + + 'where the administrator enters the client and its secret on the connection.', path: ['credentialRef'], })); export type ConnectorInstanceOAuth2AuthorizationCodeAuth = z.input; @@ -358,10 +409,16 @@ export const ConnectorInstanceOAuth2ClientCredentialsAuthSchema = lazySchema(() authorizationUrl: "Not authorable on `grant: 'client_credentials'` — that grant has no authorization step; the client " + "exchanges its credentials at `tokenUrl` directly. Delete the key, or use `grant: 'authorization_code'` if an " + 'administrator should connect the instance.', + clientSource: "Not authorable on `grant: 'client_credentials'` — an installer-supplied client is entered on the " + + "instance's connection, and only an `authorization_code` instance keeps one. Here the client is `clientId` with " + + "the `credentialRef` naming its secret, which the installing administrator sets in the secrets layer. Delete the " + + "key, or use `grant: 'authorization_code'` if an administrator should connect the instance with their own client.", }, aliases: { audience: 'resource' }, }, { - ...oauth2SharedShape(), + ...oauth2SharedShape( + "Required (except `provider: 'mcp'` over http, where the provider may register a client dynamically).", + ), grant: z.literal('client_credentials').describe('No user step: the client exchanges its own credentials for a token.'), credentialRef: z.string().min(1).optional().describe( 'Secrets-layer reference resolved to the client secret; declared together with `clientId` (a `client_credentials` client is confidential). Never an inline secret.', @@ -377,16 +434,18 @@ export type ConnectorInstanceOAuth2ClientCredentialsAuth = z.input