Skip to content
30 changes: 30 additions & 0 deletions .changeset/28866-installer-oauth-client.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) No metadata key, schema, accepted value, stored row, export or error code is removed or renamed: the begin leg of the connector connection door now refuses, with the existing 400 VALIDATION_ERROR, a request body it used to drop unread, so nothing stored changes and there is nothing for objectstack migrate meta to rewrite, and the two contract types only gain OPTIONAL members no existing caller has to send. -->

**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.
17 changes: 16 additions & 1 deletion content/docs/automation/connectors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<origin>/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,
Expand Down
8 changes: 4 additions & 4 deletions content/docs/references/integration/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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']`
Expand Down Expand Up @@ -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. |

---
Expand Down Expand Up @@ -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. |


Expand Down Expand Up @@ -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']`
Expand Down
Loading
Loading