diff --git a/.changeset/26702-connector-oauth2-connections.md b/.changeset/26702-connector-oauth2-connections.md new file mode 100644 index 00000000000..fb999801582 --- /dev/null +++ b/.changeset/26702-connector-oauth2-connections.md @@ -0,0 +1,23 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +'@objectstack/runtime': minor +--- + +feat(service-automation): `oauth2` connector instances are served per dispatch, and an administrator connects an `authorization_code` one for their organization (ADR-0143 S1, part of #26702) + +Clause-②: yes +Metadata-protocol: added + +ADR-0143 S0 (#28137) let a declarative connector instance declare `auth: { type: 'oauth2', ... }`, and this server held every such instance degraded. This slice serves it. + +- **`@objectstack/service-automation`**: an `oauth2` instance is materialized with a `credentialHandle` and no static `auth` (D4). The handle obtains the access token from `tokenUrl` (a confidential client authenticates with HTTP Basic, a public client sends its `client_id`), keeps it in memory only, refreshes it within the 30-second skew margin and once on a `401`, and shares one token request among concurrent dispatches. With a cluster service a refresh runs under the cluster lock. An unreachable or `5xx` token endpoint is `CONNECTOR_UPSTREAM_UNAVAILABLE`. + - `client_credentials` needs nothing more than the client secret its `credentialRef` names. + - `authorization_code` keeps its refresh token in a new object, `sys_connector_connection` (D3): one row per organization and instance, the token a `Field.secret`, never an access token, never a sign-in account (D6). An instance nobody connected is degraded "not connected", never a failed boot. A refused grant (`invalid_grant`) marks the row `reconnect_required` and degrades the instance until it is connected again. A dispatch uses its run's organization's connection; a run with no organization uses the only one, or is refused `422` when there are several. + - A provider opts in with `registerConnectorProvider(key, factory, { credentialHandle: true })`, saying it asks the handle on every request. An `oauth2` instance on any other provider stays degraded, so it is never called without its token. The built-in `rest`, `openapi` and `mcp` providers do not opt in yet (S2), and `app-credential` stays degraded until S2. + - A new plugin option, `connectorOAuthRedirectUri`, fixes the redirect URI; otherwise it is `OS_BASE_URL` (or `OS_AUTH_URL`) plus `/api/v1/automation/connectors/oauth/callback`. +- **`@objectstack/spec`**: `IAutomationService` gains four optional members, `getConnectorConnection`, `beginConnectorConnection`, `completeConnectorConnection` and `revokeConnectorConnection`, with their request and result types (`ConnectorConnectionState`, `ConnectorConnectionBegin`, …). `sys_connector_connection` joins the `service-automation` platform object names. +- **`@objectstack/runtime`**: the connection door (D5), every route needing `manage_metadata`: + - `GET` / `POST` / `DELETE /api/v1/automation/connectors/:name/connection` answer the organization's connection status, begin a connect (a fresh `state` and an `S256` PKCE challenge bound to the caller's user and organization, answered as the vendor's authorization URL), or revoke it. They take no query parameter. + - `GET /api/v1/automation/connectors/oauth/callback` is the vendor's redirect. Its query set is closed (`code`, `state`, `error`, `error_description`, `error_uri`, `iss`), each parameter once. The `state` is used once, and a callback from another user or organization is refused before its code is exchanged. + - No answer, refusal or log line carries a token, a code, a verifier or a client secret. diff --git a/content/docs/automation/connectors.mdx b/content/docs/automation/connectors.mdx index 7bebaa796de..49f31da03d5 100644 --- a/content/docs/automation/connectors.mdx +++ b/content/docs/automation/connectors.mdx @@ -470,13 +470,57 @@ What the publish door checks, each refusal located at its key under `auth`: - **`app-credential`** takes `vendor` (`feishu`, `dingtalk` or `wecom`), `appId` and a required `credentialRef`. An inline `appSecret` is refused by name. -A server that does not yet run the per-dispatch token resolution holds such an -instance **degraded**, with a reason that names the grant-bearing kind, and -never calls the upstream without its credential. A datasource that borrows -from it (`external.connector`) is refused with `422`. Until the server serves -them, declare a static kind (`bearer`, `api-key` or `basic`) to dispatch now; -don't emulate a grant by putting a short-lived access token behind a `bearer` -ref. +**How the server serves them.** An `oauth2` instance is served through a +**credential handle** the provider asks on every request: the access token is +obtained from `tokenUrl` (the client authenticating with HTTP Basic, or by +`client_id` alone as a public client), cached in memory only, refreshed when +it is within 30 seconds of its expiry, and refreshed once more, with one +retry, when the upstream answers `401`. Concurrent requests share one token +request, and with a cluster service a refresh runs under the cluster lock, so +a rotating refresh token is never spent twice. + +- **`client_credentials`** needs nothing more: the client secret its + `credentialRef` names re-derives the token at any time. +- **`authorization_code`** needs an administrator to **connect** it, once per + organization. Until then the instance is **degraded** "not connected" (never + a failed boot). The connect is three calls, each needing `manage_metadata`: + + ```http + POST /api/v1/automation/connectors/:name/connection → { authorizationUrl, expiresAt } + GET /api/v1/automation/connectors/oauth/callback?code=…&state=… (the vendor redirects here) + DELETE /api/v1/automation/connectors/:name/connection → revoke + ``` + + `GET …/:name/connection` answers the organization's status + (`not_connected`, `connected`, `reconnect_required`), never a token. The + `state` and the PKCE verifier are bound to the user and organization that + began the connect and are used once; a callback from anyone else is refused + before its code is exchanged. The redirect URI is the deployment's + (`OS_BASE_URL` or `OS_AUTH_URL` plus `/api/v1/automation/connectors/oauth/callback`, + or the automation plugin's `connectorOAuthRedirectUri`), never the + request's; register that URI with the vendor. The refresh token is kept + encrypted in `sys_connector_connection`, one row per organization; it is + not a sign-in account, and signing in never touches it. A run dispatches + with its own organization's connection; a run with no organization uses the + instance's only connection, and is refused when there are several. When the + vendor refuses the grant (`invalid_grant`), the connection is marked + **reconnect required** and the instance degraded until an administrator + connects it again. A revoke deletes the stored connection only: the server + does not yet revoke the refresh token at the vendor (RFC 7009), so it stays + valid there until it expires or is revoked in the vendor's console. + +What is not served yet is held **degraded** with a reason that names it, and +never called without its credential: `app-credential` (its vendor exchanges +are not run yet), an `oauth2` instance on a provider that does not yet ask the +credential handle (the built-in `rest`, `openapi` and `mcp` providers do not +yet), an `mcp` instance that left its endpoints to discovery, and an `oauth2` +instance whose `tokenUrl` or `authorizationUrl` is not `https://` (plain +`http://` is accepted only to a loopback host, so the client secret and the +refresh token never cross a network in the clear). A datasource +that borrows from a grant-bearing instance (`external.connector`) is refused +with `422`. Until then, declare a static kind (`bearer`, `api-key` or `basic`) +to dispatch now; don't emulate a grant by putting a short-lived access token +behind a `bearer` ref. ### Request signing: `signed` @@ -599,7 +643,9 @@ retry): |:---|:---| | `provider` names no installed factory | **Hard boot error** naming the entry, the provider, and the plugin that supplies it — add the executor to `plugins:` | | Invalid `providerConfig` (missing `baseUrl` / `spec` / `transport`, wrong shape) | **Hard boot error** from the provider factory | -| `auth` is a grant-bearing kind (`oauth2`, `app-credential`) on a server that does not yet run its per-dispatch resolution | **Not fatal**: the instance registers **degraded**, naming the kind, and the upstream is never called without its credential; a datasource borrowing it is refused with `422` | +| `auth` is a grant-bearing kind (`oauth2`, `app-credential`) this server does not serve yet (`app-credential`; `oauth2` on a provider that does not ask the credential handle) | **Not fatal**: the instance registers **degraded**, naming the kind, and the upstream is never called without its credential; a datasource borrowing it is refused with `422` | +| An `authorization_code` instance nobody connected, or whose grant the vendor refused | **Not fatal**: the instance registers **degraded**, "not connected" or "reconnect required" — connect it (`POST …/connectors/:name/connection`), and it materializes without a restart | +| The token endpoint is unreachable or answers `5xx` / `429` | **Not fatal**: the dispatch fails `CONNECTOR_UPSTREAM_UNAVAILABLE` and is retried on the existing backoff; the connection stays connected | | `auth` is `signed` on a provider that does not say it applies the request signer | **Hard boot error** naming the instance, the scheme and the provider: its calls would go out unsigned — declare it through `rest` or `openapi` | | `credentialRef` resolves to nothing | **Not fatal**: the instance registers **degraded**, naming the reference — set it (`PUT …/credential-refs/:ref`), and it materializes without a restart | | The upstream refuses the credential (`401` / `403`) | **Not fatal**: the instance registers **degraded**. A stored credential is marked `rejected` and waits until it is set again (or a restart); an environment or vault credential is retried on the backoff | diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index b95a7ccacc6..62b29794ac3 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -202,6 +202,7 @@ One of the largest consumers of the flag; | 55b | Walled environment-metadata refusal does not fire | metadata-core, rest, runtime | Get: under `isolated` / `group`, a write of the environment's metadata, of any type, at the `/meta` write doors (save, reset, publish, rollback) and `POST /meta/_migrate-stored` on both transports, at every `/packages` write door (create, the manifest `PATCH`, enable, disable, publish, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`, delete), at the `/automation` definition writes (create, update, delete, clone), and at the permission-set clone door. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3, D12 items 7–8, D11), because one organization's write would change what every organization resolves. The seeders, the package door and migrations write the environment's metadata this way. Under `single` the refusal is inert for every caller | `walledCatalogWriteVerdict`, `walledCatalogRefusal`, `refuseWalledCatalogPackageWrite`, `refuseWalledFlowDefinitionWrite` | | 57 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A) — and starting, through the same doors, a flow whose `requiredPermissions` the caller does not hold (ruling record 6377848497, letter A). The three doors ask one check, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `mayReadRunState`, `refuseUngrantedFlowWrite`, `isRunStarterOrRunStateReader`, `refuseUngrantedRunLifecycleWrite`, `flowStartRefusal` | | 57b | Connector credential door's capability and posture gates pass | runtime | Get: listing, setting and clearing the environment's stored connector credentials (`/automation/connectors/credential-refs`) without `manage_metadata`, and under the `group` / `isolated` postures, where the door refuses every other caller `403 PERMISSION_DENIED` naming the posture, the platform operator included, because the store is environment-level. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more about the value: no answer of this door carries it | `refuseUngrantedConnectorCredentialAccess`, `refuseWalledConnectorCredentialAccess` | +| 57c | Connector connection door's capability gate passes | runtime | Get: reading, beginning, completing (the OAuth callback) and revoking an organization's connection of an `oauth2` `authorization_code` connector instance (`/automation/connectors/:name/connection`, `/automation/connectors/oauth/callback`, ADR-0143 D5) without `manage_metadata`. No posture refuses it: a connection belongs to its organization. The bypass is the in-process caller's: inbound HTTP never carries the flag. Get nothing more: no answer of this door carries a token, and the callback's `state` is still bound to the user and organization that began the connect | `refuseUngrantedConnectorConnectionAccess` | | 58 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `assertDeploymentAuthority` | | 59 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization, and the declared-name guard does not run (a new row or a rename taking a declared webhook's name, 409) — the system writers are the platform's machinery (the legacy-secret sweep, the ownership ceremony, migrations) | `isOrganizationWrite`, `bindWebhookProvenanceStamp`, `bindDeclaredWebhookNameGuard` | | 60 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `stampSystemInsertOwner`, called from `packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes` | @@ -628,6 +629,7 @@ and CI refuses one that differs from what the tree produces. | `packages/runtime/src/domains/automation.ts#handleAutomationRequest` | 1 | 52 | | `packages/runtime/src/domains/automation.ts#isRunStarterOrRunStateReader` | 1 | 57 | | `packages/runtime/src/domains/automation.ts#mayReadRunState` | 1 | 57 | +| `packages/runtime/src/domains/automation.ts#refuseUngrantedConnectorConnectionAccess` | 1 | 57c | | `packages/runtime/src/domains/automation.ts#refuseUngrantedConnectorCredentialAccess` | 1 | 57b | | `packages/runtime/src/domains/automation.ts#refuseUngrantedFlowWrite` | 1 | 57 | | `packages/runtime/src/domains/automation.ts#refuseUngrantedRunLifecycleWrite` | 1 | 57 | diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 34bd3e12f25..ec46df82faa 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -93,7 +93,7 @@ what moved this page's population from 225 to 227; nothing about the two sites changed, only whether this instrument could see them. **The expensive failure direction is a keyword.** Sites whose receiver the author -typed `any` have no type to read, and there are 62 of them — just over a fifth +typed `any` have no type to read, and there are 63 of them — just over a fifth of the population, concentrated in exactly the seed and bootstrap paths this control exists for. Scoring an unreadable receiver as "not an engine" would have dropped every one of them silently, with a clean exit and a smaller number that @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **51 of the 252 sites are spelled that way**. A +forwarding shim cannot, and **51 of the 257 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **252** | +| 175 write call sites | quoted in the merged changeset | **257** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **174 of 252** decidable, **78** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 145 decidably elevated, 0 decidably not, 99 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **179 of 257** decidable, **78** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 147 decidably elevated, 0 decidably not, 102 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -210,18 +210,18 @@ be stated is what this instrument counts, which is written above and re-runnable at any commit. Two structural facts do plausibly widen this reading against any hand or regex -one, and both are counted in the generated tables below: the 62 sites reached -through an erased (`any`) receiver, and the 58 that name their object through a +one, and both are counted in the generated tables below: the 63 sites reached +through an erased (`any`) receiver, and the 63 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -145 of 252 (57%) as decidably elevated, with 99 more whose elevation is a +147 of 257 (57%) as decidably elevated, with 102 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 252`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 257`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **29 further sites** have an options argument this @@ -233,29 +233,29 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **252** | -| …whose object name is statically decidable | 174 | +| write call sites on the application surface | **257** | +| …whose object name is statically decidable | 179 | | …whose object name is chosen at run time | 78 | -| …against an object with tenancy ENABLED | 173 | +| …against an object with tenancy ENABLED | 178 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 193 | +| threading a tenant context | 198 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 51 | | …of those, against a decidably tenancy-enabled object | 29 | -| threading a decidably ELEVATED (`isSystem`) context | 145 | +| threading a decidably ELEVATED (`isSystem`) context | 147 | | threading a context that is decidably NOT elevated | 0 | -| threading a context whose elevation is a run-time fact | 99 | +| threading a context whose elevation is a run-time fact | 102 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 190 | -| receiver erased, placed by the object NAME | 52 | +| receiver carried a readable engine type | 194 | +| receiver erased, placed by the object NAME | 53 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | | object name spelled inline | 116 | -| object name spelled through a `const` | 58 | +| object name spelled through a `const` | 63 | | object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 71 | @@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-30 at `26e3fb296`. +Measured on 2026-10-31 at `30dc6587c`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 813 | -| engine-shaped types recognised | 95 | -| declared objects in the registry | 123 | -| same-named calls subtracted as non-engine | 190 | +| tracked non-test sources scanned | 829 | +| engine-shaped types recognised | 96 | +| declared objects in the registry | 124 | +| same-named calls subtracted as non-engine | 212 | {/* END GENERATED: tenant-audit-census */} diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 7b6f0b423e1..50380aab887 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 252 | -| Object name statically decidable | 174 | +| Write call sites | 257 | +| Object name statically decidable | 179 | | Object name chosen at run time | 78 | -| Against a tenancy-enabled object | 173 | +| Against a tenancy-enabled object | 178 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 193 | +| Threading a tenant context | 198 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 51 | | …and decidably tenancy-enabled | 29 | -| Threading a decidably elevated context | 145 | +| Threading a decidably elevated context | 147 | | Threading a decidably non-elevated context | 0 | -| Threading a context of undecidable elevation | 99 | +| Threading a context of undecidable elevation | 102 | ## Subtractions the census could NOT defend — enforced @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-30 at `26e3fb296`. +Measured on 2026-10-31 at `30dc6587c`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 813 | -| engine-shaped types recognised | 95 | -| declared objects in the registry | 123 | -| same-named calls subtracted as non-engine | 190 | +| tracked non-test sources scanned | 829 | +| engine-shaped types recognised | 96 | +| declared objects in the registry | 124 | +| same-named calls subtracted as non-engine | 212 | ## Every site @@ -202,6 +202,10 @@ Measured on 2026-10-30 at `26e3fb296`. | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `delete` | `objectName` | undecidable | context, elevation undecidable | 1 | | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `insert` | `objectName` | undecidable | context, elevation undecidable | 1 | | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `update` | `objectName` | undecidable | context, elevation undecidable | 1 | +| `packages/services/service-automation/src/connector-connections.ts` | `delete` | `sys_connector_connection` | enabled | context, elevation undecidable | 1 | +| `packages/services/service-automation/src/connector-connections.ts` | `insert` | `sys_connector_connection` | enabled | context, elevation undecidable | 1 | +| `packages/services/service-automation/src/connector-connections.ts` | `update` | `sys_connector_connection` | enabled | context, elevation undecidable | 1 | +| `packages/services/service-automation/src/connector-connections.ts` | `update` | `sys_connector_connection` | enabled | elevated | 2 | | `packages/services/service-automation/src/connector-credentials.ts` | `delete` | `sys_connector_credential` | enabled | context, elevation undecidable | 1 | | `packages/services/service-automation/src/connector-credentials.ts` | `insert` | `sys_connector_credential` | enabled | context, elevation undecidable | 1 | | `packages/services/service-automation/src/connector-credentials.ts` | `update` | `sys_connector_credential` | enabled | context, elevation undecidable | 1 | diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index 5b8cfe6126f..beeb0d73342 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -768,6 +768,17 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ `${CENSUS_MAIN}: systemFields.tenant: false; ${D7}: a declarative connector's stored credential is environment-level, ` + 'read by the reference its instances name, never by organization', }, + // ADR-0143 D3 — a connector connection is made by an organization's + // administrator and dispatched with by that organization's runs (Q1 = A): + // every row is written with the organization that connected it, so a NULL + // row has no producer. + { + object: 'sys_connector_connection', + census: PMAIN, + fate: 'attribution', + citation: `${CENSUS_MAIN} (in reach, added since the first census); ${D10_SINGLE}`, + anchors: [], + }, // Inbound mail (`@objectstack/service-mailbox`): every row is written by the // system with the mailbox's organization stamped (ADR-0131 D9), so a NULL // row has no producer today; the parents below are where one would take it. diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 313743268e3..e83fd476a66 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -1766,6 +1766,35 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu } }); + // ADR-0143 D5 — the connector connection door, registered + // before `/automation/:name` for the reason above. The callback + // is a literal path, so it is registered ahead of `/:name/connection`. + server!.get(`${base}/automation/connectors/oauth/callback`, async (req: any, res: any) => { + try { + const result = await dispatcher.dispatch('GET', '/automation/connectors/oauth/callback', undefined, req.query, { request: req }); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + + for (const verb of ['get', 'post', 'delete'] as const) { + server![verb](`${base}/automation/connectors/:name/connection`, async (req: any, res: any) => { + try { + const result = await dispatcher.dispatch( + verb.toUpperCase(), + `/automation/connectors/${encodeURIComponent(req.params.name)}/connection`, + undefined, + req.query, + { request: req }, + ); + sendResult(result, res); + } catch (err: any) { + errorResponse(err, res); + } + }); + } + server!.get(`${base}/automation/_status`, async (req: any, res: any) => { try { const result = await dispatcher.dispatch('GET', '/automation/_status', 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 new file mode 100644 index 00000000000..26810413f8b --- /dev/null +++ b/packages/runtime/src/domains/automation-connector-connection-door.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D5 — the connector connection door: `GET` / `POST` / `DELETE +// /automation/connectors/:name/connection` and `GET /automation/connectors/ +// oauth/callback`. +// +// What is pinned here is the DOOR: who may reach it (`manage_metadata`, under +// every posture — a connection belongs to its organization), what it accepts +// (no query parameter on the connection routes; the closed authorization- +// response set on the callback, each once), how the service's refusals reach +// the wire, and that a token never travels back out. The connect flow, the +// store and the dispatch behind it are pinned in service-automation +// (`connector-connection-door.test.ts`). + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; +import { CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS } from './automation.js'; + +/** An authorization code as a callback carries it — a dummy. */ +const AUTH_CODE = 'dummy-authorization-code'; + +const STATE = { connector: 'crm', organizationId: 'org_a', status: 'connected', scopes: 'crm.read' }; +const BEGUN = { connector: 'crm', organizationId: 'org_a', authorizationUrl: 'https://idp.example.com/authorize?state=s', expiresAt: '2026-10-31T00:10:00.000Z' }; + +interface Harness { + dispatcher: HttpDispatcher; + get: ReturnType; + begin: ReturnType; + complete: ReturnType; + revoke: ReturnType; +} + +function boot(opts: { posture?: 'single' | 'group' | 'isolated'; service?: Record; complete?: (req: any) => Promise } = {}): Harness { + const get = vi.fn(async () => STATE); + const begin = vi.fn(async () => BEGUN); + const complete = vi.fn(opts.complete ?? (async () => STATE)); + const revoke = vi.fn(async () => ({ connector: 'crm', organizationId: 'org_a', status: 'not_connected' })); + const services: Record = { + automation: opts.service ?? { + handlerReady: true, + getConnectorConnection: get, + beginConnectorConnection: begin, + completeConnectorConnection: complete, + revokeConnectorConnection: revoke, + }, + tenancy: { posture: opts.posture ?? 'single' }, + }; + const resolve = (name: string): unknown => services[name]; + const kernel = { getService: resolve, getServiceAsync: async (name: string) => resolve(name), context: { getService: resolve } }; + return { dispatcher: new HttpDispatcher(kernel as never), get, begin, complete, revoke }; +} + +const ADMIN = (): HttpProtocolContext => ({ + request: {}, + executionContext: { userId: 'u_admin', tenantId: 'org_a', positions: [], permissions: [], systemPermissions: ['manage_metadata'] }, +} as HttpProtocolContext); + +const MEMBER = (): HttpProtocolContext => ({ + request: {}, + executionContext: { userId: 'u_member', tenantId: 'org_a', positions: [], permissions: [], systemPermissions: [] }, +} as HttpProtocolContext); + +const ANONYMOUS = (): HttpProtocolContext => ({ request: {}, executionContext: {} } as HttpProtocolContext); + +const statusOf = (r: unknown): unknown => (r as any)?.status; +const codeOf = (r: unknown): unknown => (r as any)?.body?.error?.code; +const messageOf = (r: unknown): string => String((r as any)?.body?.error?.message ?? ''); + +const connection = (h: Harness, verb: 'GET' | 'POST' | 'DELETE', ctx: HttpProtocolContext, name = 'crm', query?: unknown) => + h.dispatcher.handleAutomation(`/connectors/${encodeURIComponent(name)}/connection`, verb, undefined, ctx, query); +const callback = (h: Harness, ctx: HttpProtocolContext, query?: unknown) => + h.dispatcher.handleAutomation('/connectors/oauth/callback', 'GET', undefined, ctx, query); + +describe('ADR-0143 D5 — connector connection door: who may reach it', () => { + it('answers an anonymous caller 401 before anything else', async () => { + const h = boot(); + for (const call of [() => connection(h, 'GET', ANONYMOUS()), () => callback(h, ANONYMOUS(), { code: AUTH_CODE, state: 's' })]) { + expect(statusOf((await call()).response)).toBe(401); + } + expect(h.get).not.toHaveBeenCalled(); + expect(h.complete).not.toHaveBeenCalled(); + }); + + it.each([ + ['GET', (h: Harness) => connection(h, 'GET', MEMBER())], + ['POST', (h: Harness) => connection(h, 'POST', MEMBER())], + ['DELETE', (h: Harness) => connection(h, 'DELETE', MEMBER())], + ['GET callback', (h: Harness) => callback(h, MEMBER(), { code: AUTH_CODE, state: 's' })], + ] as const)('%s without `manage_metadata` is refused 403 and never reaches the service', async (_verb, call) => { + const h = boot(); + const { response } = await call(h); + expect(statusOf(response)).toBe(403); + expect(codeOf(response)).toBe('PERMISSION_DENIED'); + expect(messageOf(response)).toContain('`manage_metadata`'); + for (const member of [h.get, h.begin, h.complete, h.revoke]) expect(member).not.toHaveBeenCalled(); + }); + + it('a walled posture does not refuse the holder: a connection belongs to its organization', async () => { + for (const posture of ['group', 'isolated'] as const) { + const h = boot({ posture }); + expect(statusOf((await connection(h, 'GET', ADMIN())).response)).toBe(200); + } + }); +}); + +describe('ADR-0143 D5 — connector connection door: what it accepts', () => { + it('GET / POST / DELETE hand the decoded name and the caller to their members, and answer what they answer', async () => { + const h = boot(); + const got = await connection(h, 'GET', ADMIN(), 'vendor/crm'); + expect((got.response as any).body.data).toEqual(STATE); + expect(h.get).toHaveBeenCalledWith({ connector: 'vendor/crm', context: expect.objectContaining({ userId: 'u_admin', tenantId: 'org_a' }) }); + const begun = await connection(h, 'POST', ADMIN()); + expect((begun.response as any).body.data).toEqual(BEGUN); + expect(h.begin).toHaveBeenCalledWith({ connector: 'crm', context: expect.objectContaining({ userId: 'u_admin' }) }); + const revoked = await connection(h, 'DELETE', ADMIN()); + expect((revoked.response as any).body.data).toMatchObject({ status: 'not_connected' }); + expect(h.revoke).toHaveBeenCalledTimes(1); + }); + + it('route rule 5: the connection routes take no query parameter, refused 400 naming it', async () => { + const h = boot(); + for (const verb of ['GET', 'POST', 'DELETE'] as const) { + const { response } = await connection(h, verb, ADMIN(), 'crm', { redirect_uri: 'https://evil.example.com' }); + expect(statusOf(response)).toBe(400); + expect(codeOf(response)).toBe('VALIDATION_ERROR'); + expect(messageOf(response)).toContain('redirect_uri'); + } + for (const member of [h.get, h.begin, h.revoke]) expect(member).not.toHaveBeenCalled(); + }); + + it('the callback takes exactly the authorization-response set, and hands it over as given', async () => { + expect([...CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS]).toEqual(['code', 'state', 'error', 'error_description', 'error_uri', 'iss']); + const h = boot(); + const query = { code: 'dummy-code', state: 'dummy-state', iss: 'https://idp.example.com' }; + const { response } = await callback(h, ADMIN(), query); + expect(statusOf(response)).toBe(200); + expect((response as any).body.data).toEqual(STATE); + expect(h.complete).toHaveBeenCalledWith({ query, context: expect.objectContaining({ userId: 'u_admin', tenantId: 'org_a' }) }); + // Every member of the set is accepted (preservation pin for the closed set). + const all = Object.fromEntries(CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS.map((k) => [k, `v-${k}`])); + expect(statusOf((await callback(h, ADMIN(), all)).response)).toBe(200); + }); + + it('the callback refuses an unknown parameter (a redirect_uri above all) and a repeated one, before the service', async () => { + const h = boot(); + const unknown = await callback(h, ADMIN(), { code: AUTH_CODE, state: 's', redirect_uri: 'https://evil.example.com' }); + expect(statusOf(unknown.response)).toBe(400); + expect(messageOf(unknown.response)).toContain('redirect_uri'); + const repeated = await callback(h, ADMIN(), { code: AUTH_CODE, state: ['s1', 's2'] }); + expect(statusOf(repeated.response)).toBe(400); + expect(codeOf(repeated.response)).toBe('VALIDATION_ERROR'); + expect(messageOf(repeated.response)).toContain('`state` is repeated'); + expect(h.complete).not.toHaveBeenCalled(); + }); + + it("answers the service's own refusals with their status and code, never the code it was handed", async () => { + const refusal = (message: string, code: string, status: number) => Object.assign(new Error(message), { code, status }); + for (const [code, status] of [['VALIDATION_ERROR', 400], ['RESOURCE_NOT_FOUND', 404], ['SERVICE_UNAVAILABLE', 503]] as const) { + const h = boot({ complete: async () => { throw refusal(`refused ${code}`, code, status); } }); + const { response } = await callback(h, ADMIN(), { code: 'dummy-code-26702', state: 's' }); + expect(statusOf(response)).toBe(status); + expect(codeOf(response)).toBe(code); + expect(messageOf(response)).toBe(`refused ${code}`); + expect(JSON.stringify(response)).not.toContain('dummy-code-26702'); + } + }); + + it('a service without the optional members answers 501, never a 200 or a fall-through', async () => { + const h = boot({ service: { handlerReady: true } }); + for (const call of [ + () => connection(h, 'GET', ADMIN()), + () => connection(h, 'POST', ADMIN()), + () => connection(h, 'DELETE', ADMIN()), + () => callback(h, ADMIN(), { code: AUTH_CODE, state: 's' }), + ]) { + const { response } = await call(); + expect(statusOf(response)).toBe(501); + expect(codeOf(response)).toBe('NOT_IMPLEMENTED'); + } + }); +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 53e3de08b8c..561af647d0c 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -898,6 +898,135 @@ async function respondToConnectorCredentialDoor( } } +/** + * ADR-0143 D5 — the connector connection door's segments under `/connectors`: + * `GET` / `POST` / `DELETE /connectors/:name/connection` (status, connect or + * reconnect, revoke) and `GET /connectors/oauth/callback` (the authorization + * server's redirect). + */ +const CONNECTOR_CONNECTION_SEGMENT = 'connection'; +const CONNECTOR_OAUTH_SEGMENT = 'oauth'; +const CONNECTOR_OAUTH_CALLBACK_SEGMENT = 'callback'; + +/** ADR-0143 D5 — which routes are the connector connection door, matched exactly as its arms match. */ +function isConnectorConnectionDoor(parts: string[], method: string): boolean { + if (parts[0] !== 'connectors' || parts.length !== 3) return false; + if (parts[1] === CONNECTOR_OAUTH_SEGMENT && parts[2] === CONNECTOR_OAUTH_CALLBACK_SEGMENT) return method === 'GET'; + if (parts[2] !== CONNECTOR_CONNECTION_SEGMENT || parts[1] === CONNECTOR_CREDENTIAL_REFS_SEGMENT) return false; + return method === 'GET' || method === 'POST' || method === 'DELETE'; +} + +/** + * ADR-0143 D5 · route rule 5 — the connection routes' closed query set: + * none. The instance is named in the path, the organization is the caller's. + */ +export const CONNECTOR_CONNECTION_DOOR_QUERY_PARAMS: readonly string[] = Object.freeze([]); + +/** + * ADR-0143 D5 · route rule 5 — the callback's closed query set: what an + * authorization response carries (RFC 6749 §4.1.2 and §4.1.2.1) plus its + * issuer identifier (RFC 9207). Anything else is refused, never dropped. + */ +export const CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS: readonly string[] = Object.freeze([ + 'code', 'state', 'error', 'error_description', 'error_uri', 'iss', +]); + +const CONNECTOR_CONNECTION_DENY_MESSAGE = + `Connecting, reconnecting or revoking a connector instance requires the \`${FLOW_AUTHORING_CAPABILITY}\` capability.`; + +/** + * ADR-0143 D5 — "who may connect, reconnect or revoke is a capability check + * the server slice declares": `manage_metadata`, the capability the + * credential door above asks for the same instances, fail-closed, only + * `isSystem` bypassing. The callback is gated too: the browser that lands on + * it is the administrator's, carrying the session that began the connect. + */ +function refuseUngrantedConnectorConnectionAccess( + deps: DomainHandlerDeps, + context: HttpProtocolContext, +): HttpDispatcherResult | undefined { + const ec: any = context?.executionContext; + if (ec?.isSystem) return undefined; + if (new Set(ec?.systemPermissions ?? []).has(FLOW_AUTHORING_CAPABILITY)) return undefined; + return { + handled: true, + response: deps.error(CONNECTOR_CONNECTION_DENY_MESSAGE, FLOW_WRITE_DENY_STATUS, { code: FLOW_WRITE_DENY_CODE }), + }; +} + +const CONNECTOR_CONNECTION_UNSUPPORTED_MESSAGE = (member: string): string => + 'Connector connections are not supported by the automation service this deployment mounts — it does not ' + + `implement \`${member}\`, an optional member of \`IAutomationService\`. Nothing was read or changed.`; + +/** The callback's parameters, each one value: a repeated one is refused naming it, never read as either. */ +function callbackQueryOf(query: unknown): { query: Record } | { refusal: string } { + const out: Record = {}; + for (const [key, value] of Object.entries((query ?? {}) as Record)) { + if (Array.isArray(value)) return { refusal: `The callback query parameter \`${key}\` is repeated; it is read only once.` }; + if (typeof value === 'string') out[key] = value; + } + return { query: out }; +} + +/** + * ADR-0143 D5 — the connector connection door's four arms. Reached only past + * the capability gate above. ⛔ No token leaves here: every arm answers the + * connection's status. + */ +async function respondToConnectorConnectionDoor( + deps: DomainHandlerDeps, + automationService: unknown, + parts: string[], + method: string, + context: HttpProtocolContext, + query: unknown, +): Promise { + const isCallback = parts[1] === CONNECTOR_OAUTH_SEGMENT && parts[2] === CONNECTOR_OAUTH_CALLBACK_SEGMENT; + const queryRefusal = unknownQueryParamRefusalMessage( + query, + isCallback ? CONNECTOR_OAUTH_CALLBACK_QUERY_PARAMS : CONNECTOR_CONNECTION_DOOR_QUERY_PARAMS, + ); + if (queryRefusal !== undefined) { + return { handled: true, response: deps.error(queryRefusal, 400, { code: 'VALIDATION_ERROR' }) }; + } + const svc = automationService as Pick< + IAutomationService, + 'getConnectorConnection' | 'beginConnectorConnection' | 'completeConnectorConnection' | 'revokeConnectorConnection' + >; + const executionContext = (context as any)?.executionContext; + try { + if (isCallback) { + const read = callbackQueryOf(query); + if ('refusal' in read) return { handled: true, response: deps.error(read.refusal, 400, { code: 'VALIDATION_ERROR' }) }; + if (typeof svc.completeConnectorConnection !== 'function') { + return { handled: true, response: deps.error(CONNECTOR_CONNECTION_UNSUPPORTED_MESSAGE('completeConnectorConnection'), 501) }; + } + const state = await svc.completeConnectorConnection({ query: read.query, context: executionContext }); + return { handled: true, response: deps.success(state) }; + } + let connector: string; + try { + connector = decodeURIComponent(parts[1]); + } catch { + return { + handled: true, + response: deps.error('The connector instance name in the path is not a valid URL-encoded segment.', 400, { code: 'VALIDATION_ERROR' }), + }; + } + const member = method === 'GET' ? 'getConnectorConnection' : method === 'POST' ? 'beginConnectorConnection' : 'revokeConnectorConnection'; + const call = svc[member] as ((request: { connector: string; context?: unknown }) => 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 }); + return { handled: true, response: deps.success(answer) }; + } catch (err) { + const refusal = connectorCredentialRefusal(deps, err); + if (refusal) return refusal; + throw err; + } +} + /** [#13953] The path segment naming the cancel door (ADR-0044's operator verb). */ const RUN_CANCEL_SEGMENT = 'cancel'; /** [#13953] The path segment naming the repair door (#13909's operator verb). */ @@ -2955,6 +3084,15 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str if (refusal) return refusal; } + // ADR-0143 D5 — CONNECTOR CONNECTION GATE: `manage_metadata`, ahead of the + // service probe and of any query check, as above. No walled refusal: a + // connection belongs to its organization (Q1 = A), so it is served under + // every tenancy posture. + if (isConnectorConnectionDoor(parts, m)) { + const refusal = refuseUngrantedConnectorConnectionAccess(deps, context); + if (refusal) return refusal; + } + // [ADR-0126 §5] ACTIVATION-WRITE GATE — the install-wide enable/disable // switch needs the platform operator in a walled posture. Placed directly // AFTER the authoring gate and BEFORE the service probe / body checks, for @@ -3170,6 +3308,11 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str if (isConnectorCredentialDoor(parts, m)) { return respondToConnectorCredentialDoor(deps, automationService, parts, m, body, context, query); } + // 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); + } if (parts[0] === 'connectors' && parts.length === 1 && m === 'GET') { // [#7360] The `/actions` note above applies verbatim to this filter: diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index b8110610caf..7d4773ceb9c 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -279,7 +279,7 @@ export const NON_DISPATCH_MOUNT_PREFIXES = [ /** * The ledger. * - * CENSUS (generated): this list holds 109 rows. + * CENSUS (generated): this list holds 113 rows. * * ⛔ THAT NUMBER IS WRITTEN BY A TOOL — never by hand. * `pnpm check:route-ledger-census` counts the rows below and fails when the two @@ -542,6 +542,17 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ note: "Store the value for one declared reference, then reconcile: every instance naming it re-materializes without a restart. Body is the closed `{ value }`. Answers the reference's listing entry after the reconcile, never the value. Refused, before anything is written: an undeclared reference 404 `RESOURCE_NOT_FOUND`, one an environment variable of the same name supplies 409 `RESOURCE_CONFLICT` (env wins in the chain, so a stored value would never be read), an empty or oversized value, one starting with `secret:` (the data engine would store it as a handle to another secret) or an unknown body key 400 `VALIDATION_ERROR`, and no crypto provider 503 `SERVICE_UNAVAILABLE`. Writes in one process run one at a time, with their reconcile; a concurrent first write from another replica answers 409 `RESOURCE_CONFLICT`. Same gates and SDK posture as the GET row above. Pinned in `domains/automation-connector-credential-door.test.ts`" }, { route: 'DELETE /automation/connectors/credential-refs/:ref', domain: '/automation', disposition: 'server-only', note: "Remove the stored value for one reference, then reconcile: every instance naming it is torn down and registered degraded, naming the reference. 404 `RESOURCE_NOT_FOUND` when nothing is stored for it. Same gates and SDK posture as the GET row above. Pinned in `domains/automation-connector-credential-door.test.ts`" }, + // ADR-0143 D5 — the connector connection door: an administrator connects an + // `oauth2` `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_metadata`, fail-closed, only `isSystem` bypassing; 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. 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`" }, + { 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'. 404 `RESOURCE_NOT_FOUND` when it was not connected. 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', + note: "The authorization server's redirect (ADR-0143 D5). Closed query set: `code`, `state`, `error`, `error_description`, `error_uri`, `iss`; an unknown or repeated one is 400 `VALIDATION_ERROR`. The `state` is taken once and must have been begun by the same user in the same organization, else 400 before any code is exchanged; an `error` answer is 400 naming its registered code only. The code is exchanged with the PKCE verifier and the connection stored; answers the connection's status. 503 when the token endpoint cannot be reached. Same capability gate as the rows above: the browser landing here is the administrator's. Pinned in `domains/automation-connector-connection-door.test.ts`" }, { route: 'GET /automation/_status', domain: '/automation', disposition: 'sdk', client: 'automation.getRuntimeStatus' }, { route: 'POST /automation/:name/trigger', domain: '/automation', disposition: 'sdk', client: 'automation.execute' }, { route: 'POST /automation/:name/toggle', domain: '/automation', disposition: 'sdk', client: 'automation.toggle', diff --git a/packages/services/service-automation/src/activation-ledger-registration.test.ts b/packages/services/service-automation/src/activation-ledger-registration.test.ts index cc6404ef075..a3795ba5dac 100644 --- a/packages/services/service-automation/src/activation-ledger-registration.test.ts +++ b/packages/services/service-automation/src/activation-ledger-registration.test.ts @@ -60,15 +60,17 @@ async function registeredObjectNames(): Promise { } describe('#12359 — the automation service is NOT the activation ledger\'s registrant', () => { - it('registers its own four objects and nothing else', async () => { + it('registers its own five objects and nothing else', async () => { // Equality, not `not.toContain`: an assertion that only names the // absent object would stay green while this manifest grew a FIFTH // registration nobody reviewed, which is the same class of drift. // [#20790] The third is reviewed: `sys_flow_credential`, the write-only // flow credential channel this service owns. [#27237] So is the fourth: - // `sys_connector_credential`, the connector credential store. + // `sys_connector_credential`, the connector credential store. [ADR-0143 + // D3] And the fifth: `sys_connector_connection`, the connection record + // of an `authorization_code` connector instance. expect(await registeredObjectNames()).toEqual([ - 'sys_automation_run', 'sys_flow_dispatch', 'sys_flow_credential', 'sys_connector_credential', + 'sys_automation_run', 'sys_flow_dispatch', 'sys_flow_credential', 'sys_connector_credential', 'sys_connector_connection', ]); }); diff --git a/packages/services/service-automation/src/connector-connection-door.test.ts b/packages/services/service-automation/src/connector-connection-door.test.ts new file mode 100644 index 00000000000..097e59948f9 --- /dev/null +++ b/packages/services/service-automation/src/connector-connection-door.test.ts @@ -0,0 +1,612 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0143 D3–D6 — connecting an `oauth2` `authorization_code` instance, end to + * end: the connection record on a real ObjectQL over SQLite (so the + * `Field.secret` seam is the engine's), a real token endpoint on a local HTTP + * server, and a provider that asks the credential handle on every request. + * + * - D3: one row per organization and instance; the refresh token encrypted + * and masked; the access token never stored; + * - D4: per-dispatch refresh, once on a 401; `invalid_grant` → "reconnect + * required"; an unreachable token endpoint → CONNECTOR_UPSTREAM_UNAVAILABLE; + * never authorized → "not connected", never a failed boot; the connection + * selected by the run's organization; + * - D5: state and PKCE bound to the user and organization that began the + * connect, single use; the redirect URI fixed per deployment; + * - D6: a grant is never a sign-in account; + * - no token, code, verifier or client secret in a log line, a descriptor, + * a degraded reason, a door answer, an engine read, or `sys_metadata`. + * + * The values below are obvious dummies. + */ +import { createHash } from 'node:crypto'; +import { createServer, type IncomingMessage, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { ObjectQL } from '@objectstack/objectql'; +import { collectMaskedReadFields } from '@objectstack/objectql/core'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysSecret } from '@objectstack/platform-objects'; +import { callWithBorrowedCredential } from '@objectstack/types'; +import type { CryptoContext, CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; +import type { Connector, ConnectorProviderContext, ConnectorProviderFactory } from '@objectstack/spec/integration'; + +import { AutomationServicePlugin } from './plugin.js'; +import type { AutomationEngine } from './engine.js'; +import { SysConnectorCredential } from './sys-connector-credential.object.js'; +import { SysConnectorConnection } from './sys-connector-connection.object.js'; +import { SysFlowCredential } from './sys-flow-credential.object.js'; +import { CONNECTOR_CONNECTION_OBJECT } from './connector-connections.js'; + +const CLIENT_SECRET = 'dummy-client-secret-26702'; +const GOOD_CODE = 'dummy-code-26702'; +const REDIRECT_URI = 'https://app.example.com/api/v1/automation/connectors/oauth/callback'; +const SYSTEM = { isSystem: true } as const; +const ADMIN_A = { userId: 'usr_admin', tenantId: 'org_a' }; +const ADMIN_B = { userId: 'usr_admin', tenantId: 'org_b' }; + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +// ─── The authorization server ──────────────────────────────────────── + +interface TokenRequest { + authorization?: string; + form: Record; +} + +const idp = { + server: undefined as Server | undefined, + url: '', + requests: [] as TokenRequest[], + /** Answer to a refresh_token grant; default rotate. */ + refresh: 'rotate' as 'rotate' | 'invalid_grant' | 'down' | 'no_rotate', + issued: 0, +}; + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve) => { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => resolve(body)); + }); +} + +beforeAll(async () => { + idp.server = createServer(async (req, res) => { + const form = Object.fromEntries(new URLSearchParams(await readBody(req))); + idp.requests.push({ authorization: req.headers.authorization, form }); + const answer = (status: number, body: unknown) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + idp.issued += 1; + const n = idp.issued; + if (form.grant_type === 'authorization_code') { + if (form.code !== GOOD_CODE) return answer(400, { error: 'invalid_grant' }); + return answer(200, { + access_token: `dummy-at-exchange-${n}`, token_type: 'Bearer', expires_in: 3600, refresh_token: `dummy-rt-${n}`, scope: 'crm.read', + }); + } + if (form.grant_type === 'refresh_token') { + if (idp.refresh === 'down') return answer(503, {}); + if (idp.refresh === 'invalid_grant') return answer(400, { error: 'invalid_grant', error_description: `${form.refresh_token} revoked` }); + return answer(200, { + access_token: `dummy-at-refresh-${n}`, + token_type: 'Bearer', + expires_in: 3600, + ...(idp.refresh === 'rotate' ? { refresh_token: `dummy-rt-${n}` } : {}), + }); + } + return answer(400, { error: 'unsupported_grant_type' }); + }); + await new Promise((r) => idp.server!.listen(0, '127.0.0.1', () => r())); + idp.url = `http://127.0.0.1:${(idp.server!.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((r) => idp.server!.close(() => r())); +}); + +// ─── The deployment ────────────────────────────────────────────────── + +function fakeCrypto(): ICryptoProvider { + let n = 0; + return { + async encrypt(plain: string, _ctx: CryptoContext): Promise { + n += 1; + return { id: `sec_${n}`, kmsKeyId: 'test', alg: 'test-rev', version: 1, ciphertext: [...plain].reverse().join('') }; + }, + async decrypt(handle: CryptoHandle): Promise { + return [...handle.ciphertext].reverse().join(''); + }, + async rotateKey(handle: CryptoHandle): Promise { + return { ...handle, version: handle.version + 1 }; + }, + digest: (plain: string) => `d:${plain.length}`, + keyedDigest: async (plain: string) => `k:${plain.length}`, + }; +} + +/** The declared `authorization_code` instance. */ +function crm(over: Record = {}) { + return { + name: 'crm', + label: 'CRM', + type: 'api', + provider: 'granted', + providerConfig: {}, + auth: { + type: 'oauth2', + grant: 'authorization_code', + authorizationUrl: 'https://idp.example.com/authorize', + tokenUrl: `${idp.url}/token`, + clientId: 'client-26702', + credentialRef: 'CRM_CLIENT_SECRET', + scopes: ['crm.read'], + }, + _packageId: 'com.example.crm', + ...over, + }; +} + +/** + * A provider in the shape S2's will have: registered as asking the handle, and + * every request presents what `callWithBorrowedCredential` hands it for the + * run's organization. The upstream accepts the tokens in `accepted` (every + * token, when it is `undefined`) and records what it was sent. + */ +function grantedProvider() { + const state = { + materialized: [] as ConnectorProviderContext[], + sent: [] as string[], + accepted: undefined as Set | undefined, + }; + const factory: ConnectorProviderFactory = (ctx) => { + state.materialized.push(ctx); + return { + def: { + name: ctx.name, + label: ctx.label, + type: 'api', + authentication: { type: 'none' }, + actions: [{ key: 'request', label: 'Request' }], + } as unknown as Connector, + handlers: { + request: async (_input, actionCtx) => callWithBorrowedCredential( + ctx.credentialHandle!, + async (auth) => { + const token = auth.type === 'bearer' ? auth.token : ''; + state.sent.push(token); + return state.accepted === undefined || state.accepted.has(token) + ? { status: 200, ok: true } + : { status: 401, ok: false }; + }, + { organizationId: (actionCtx as { automation?: { tenantId?: string } })?.automation?.tenantId }, + ), + }, + }; + }; + return { factory, state }; +} + +const engines: ObjectQL[] = []; +const kernels: LiteKernel[] = []; +afterEach(async () => { + for (const k of kernels.splice(0)) await k.shutdown().catch(() => {}); + for (const e of engines.splice(0)) { + try { await (e as any).destroy?.(); } catch { /* noop */ } + } + idp.requests.length = 0; + idp.refresh = 'rotate'; + idp.issued = 0; + vi.restoreAllMocks(); +}); + +function captureOutput(): { text: () => string } { + const chunks: string[] = []; + const keep = ((c: string | Uint8Array) => { chunks.push(String(c)); return true; }) as never; + vi.spyOn(process.stdout, 'write').mockImplementation(keep); + vi.spyOn(process.stderr, 'write').mockImplementation(keep); + for (const m of ['log', 'info', 'warn', 'error', 'debug'] as const) { + vi.spyOn(console, m).mockImplementation((...args: unknown[]) => { chunks.push(args.map(String).join(' ')); }); + } + return { text: () => chunks.join('\n') }; +} + +async function dataEngine(): Promise { + const ql = new ObjectQL(); + engines.push(ql); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.connect(); + ql.registerDriver(driver, true); + await ql.init(); + for (const object of [SysSecret, SysConnectorCredential, SysFlowCredential, SysConnectorConnection]) { + (ql as any).registry.registerObject(object as never, 'test'); + } + await ql.syncSchemas(); + ql.setCryptoProvider(fakeCrypto()); + return ql; +} + +async function boot(declared: unknown[] = [crm()], opts: { redirectUri?: string | null; cluster?: unknown; noData?: boolean } = {}) { + const ql = await dataEngine(); + const provider = grantedProvider(); + const kernel = new LiteKernel({ logger: { level: 'debug', format: 'json' } } as never); + kernels.push(kernel); + kernel.use(new AutomationServicePlugin({ + suspendedRunStore: 'memory', + credentialResolver: (ref) => (ref === 'CRM_CLIENT_SECRET' ? CLIENT_SECRET : undefined), + ...(opts.redirectUri === null ? {} : { connectorOAuthRedirectUri: opts.redirectUri ?? REDIRECT_URI }), + })); + kernel.use({ + name: 'test.harness', + type: 'standard' as const, + version: '1.0.0', + dependencies: ['com.objectstack.service-automation'], + async init(ctx: any) { + ctx.registerService('objectql', { registry: { listItems: (t: string) => (t === 'connector' ? declared : []) } }); + if (!opts.noData) ctx.registerService('data', ql); + if (opts.cluster) ctx.registerService('cluster', opts.cluster); + ctx.getService('automation').registerConnectorProvider('granted', provider.factory, { credentialHandle: true }); + }, + async start() {}, + } as never); + await kernel.bootstrap(); + await flush(); + const engine = kernel.getService('automation') as AutomationEngine; + const call = async (organizationId?: string) => engine.resolveConnectorAction('crm', 'request')!( + {}, + { automation: organizationId === undefined ? {} : { tenantId: organizationId } } as never, + ); + /** Begin as `context`, and complete as `as` (default the same caller) with `query` over the state. */ + const connect = async (context = ADMIN_A, opts2: { as?: typeof ADMIN_A; code?: string; query?: Record } = {}) => { + const begun = await engine.beginConnectorConnection({ connector: 'crm', context: context as never }); + const state = new URL(begun.authorizationUrl).searchParams.get('state')!; + const query = opts2.query ?? { code: opts2.code ?? GOOD_CODE, state }; + return { begun, state, done: () => engine.completeConnectorConnection({ query: { state, ...query }, context: (opts2.as ?? context) as never }) }; + }; + return { ql, engine, provider, call, connect, kernel }; +} + +const rowsOf = async (ql: ObjectQL) => (await ql.find(CONNECTOR_CONNECTION_OBJECT, { context: SYSTEM } as never)) as any[]; + +// ─── D4 boot, D5 flow ──────────────────────────────────────────────── + +describe('ADR-0143 D4/D5 — connect an instance nobody authorized yet', () => { + it('boots degraded "not connected"; begin → callback → connected and dispatching, no restart', async () => { + const { engine, provider, call, connect, ql } = await boot(); + // Never authorized: degraded, named, the factory never asked — and the boot did not fail. + expect(provider.state.materialized).toHaveLength(0); + expect(engine.getConnectorDegradedReason('crm')).toContain("connector instance 'crm' is not connected"); + expect(await engine.getConnectorConnection({ connector: 'crm', context: ADMIN_A as never })) + .toEqual({ connector: 'crm', organizationId: 'org_a', status: 'not_connected' }); + + const { begun, done } = await connect(); + const url = new URL(begun.authorizationUrl); + expect(url.origin + url.pathname).toBe('https://idp.example.com/authorize'); + expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('client_id')).toBe('client-26702'); + expect(url.searchParams.get('scope')).toBe('crm.read'); + expect(begun).toMatchObject({ connector: 'crm', organizationId: 'org_a', expiresAt: expect.any(String) }); + // Beginning writes nothing to the connection record. + expect(await rowsOf(ql)).toHaveLength(0); + + const state = await done(); + expect(state).toMatchObject({ connector: 'crm', organizationId: 'org_a', status: 'connected', scopes: 'crm.read', connectedBy: 'usr_admin' }); + + // The code exchange: the client authenticated with Basic, the verifier + // is the one whose S256 the authorization request carried, and the + // redirect URI is repeated (RFC 6749 §4.1.3). + expect(idp.requests).toHaveLength(1); + const exchange = idp.requests[0]; + expect(exchange.authorization).toBe(`Basic ${Buffer.from(`client-26702:${CLIENT_SECRET}`).toString('base64')}`); + expect(exchange.form).toMatchObject({ grant_type: 'authorization_code', code: GOOD_CODE, redirect_uri: REDIRECT_URI }); + expect(createHash('sha256').update(exchange.form.code_verifier).digest('base64url')).toBe(url.searchParams.get('code_challenge')); + + // Materialized with the handle and no static auth; the first dispatch + // presents the access token the exchange answered (no refresh). + expect(engine.getConnectorDegradedReason('crm')).toBeUndefined(); + expect(provider.state.materialized).toHaveLength(1); + expect(provider.state.materialized[0].auth).toBeUndefined(); + expect(await call('org_a')).toMatchObject({ status: 200 }); + expect(provider.state.sent).toEqual(['dummy-at-exchange-1']); + expect(idp.requests).toHaveLength(1); + + // One row: the refresh token masked on the engine's read, revision 1. + const rows = await rowsOf(ql); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ organization_id: 'org_a', connector_name: 'crm', status: 'connected', revision: 1 }); + expect(rows[0].refresh_token).not.toBe('dummy-rt-1'); + expect(await (ql as any).resolveSecretField(CONNECTOR_CONNECTION_OBJECT, rows[0].id, 'refresh_token')).toBe('dummy-rt-1'); + }); + + it('⛔ a callback completed by another user, or in another organization, is refused before its code is used', async () => { + const { engine, connect, ql } = await boot(); + for (const as of [{ userId: 'usr_attacker', tenantId: 'org_a' }, ADMIN_B]) { + const { done } = await connect(ADMIN_A, { as }); + await expect(done()).rejects.toMatchObject({ status: 400, code: 'VALIDATION_ERROR' }); + } + expect(idp.requests).toHaveLength(0); + expect(await rowsOf(ql)).toHaveLength(0); + expect(engine.getConnectorDegradedReason('crm')).toContain('is not connected'); + }); + + it('⛔ a state is used once: a replayed callback is refused, and so is a state never begun', async () => { + const { engine, connect } = await boot(); + const { done, state } = await connect(); + await done(); + await expect(done()).rejects.toMatchObject({ status: 400, message: expect.stringContaining('already used') }); + await expect(engine.completeConnectorConnection({ query: { state: `${state}x`, code: GOOD_CODE }, context: ADMIN_A as never })) + .rejects.toMatchObject({ status: 400 }); + await expect(engine.completeConnectorConnection({ query: { code: GOOD_CODE }, context: ADMIN_A as never })) + .rejects.toMatchObject({ status: 400, message: expect.stringContaining('carries no state') }); + expect(idp.requests).toHaveLength(1); + }); + + it('⛔ an expired state is refused', async () => { + const { connect } = await boot(); + const { done } = await connect(); + const later = Date.now() + 601_000; + vi.spyOn(Date, 'now').mockReturnValue(later); + await expect(done()).rejects.toMatchObject({ status: 400, message: expect.stringContaining('expired') }); + expect(idp.requests).toHaveLength(0); + }); + + it("an `error` answer is refused naming only a registered code — never the server's own text — and connects nothing", async () => { + const { connect, ql } = await boot(); + const denied = await (await connect(ADMIN_A, { query: { error: 'access_denied', error_description: 'dummy-echo-text' } })).done() + .catch((e) => e); + expect(denied).toMatchObject({ status: 400 }); + expect(denied.message).toContain('(access_denied)'); + expect(denied.message).not.toContain('dummy-echo-text'); + const odd = await (await connect(ADMIN_A, { query: { error: 'dummy-unregistered-code' } })).done().catch((e) => e); + expect(odd.message).not.toContain('dummy-unregistered-code'); + const bad = await (await connect(ADMIN_A, { code: 'dummy-wrong-code' })).done().catch((e) => e); + expect(bad).toMatchObject({ status: 400, message: expect.stringContaining('refused the code (invalid_grant)') }); + expect(await rowsOf(ql)).toHaveLength(0); + }); + + it('the door refuses what has no connection to make, an unknown instance, a caller with no organization or no user', async () => { + const clientCredentials = crm({ name: 'svc', auth: { ...crm().auth, grant: 'client_credentials', authorizationUrl: undefined } }); + const { engine } = await boot([crm(), clientCredentials]); + await expect(engine.beginConnectorConnection({ connector: 'svc', context: ADMIN_A as never })) + .rejects.toMatchObject({ status: 400, message: expect.stringContaining('does not use the oauth2 authorization_code grant') }); + await expect(engine.beginConnectorConnection({ connector: 'nope', context: ADMIN_A as never })) + .rejects.toMatchObject({ status: 404 }); + await expect(engine.beginConnectorConnection({ connector: 'crm', context: { userId: 'usr_admin' } as never })) + .rejects.toMatchObject({ status: 400, message: expect.stringContaining('needs an active organization') }); + await expect(engine.beginConnectorConnection({ connector: 'crm', context: { tenantId: 'org_a' } as never })) + .rejects.toMatchObject({ status: 400, message: expect.stringContaining('needs a signed-in user') }); + }); + + it('⛔ the door refuses an instance whose tokenUrl or authorizationUrl is not https off loopback, naming the field', async () => { + const plain = crm({ name: 'plain', auth: { ...crm().auth, authorizationUrl: 'http://idp.example.com/authorize' } }); + const { engine } = await boot([crm(), plain]); + for (const call of [ + () => engine.getConnectorConnection({ connector: 'plain', context: ADMIN_A as never }), + () => engine.beginConnectorConnection({ connector: 'plain', context: ADMIN_A as never }), + ]) { + const err = (await call().catch((e: Error) => e)) as Error & { status?: number }; + expect(err.status).toBe(400); + expect(err.message).toContain('`authorizationUrl` not over https'); + expect(err.message).not.toContain('idp.example.com'); + } + // The loopback token endpoint of `crm()` itself is accepted. + await expect(engine.getConnectorConnection({ connector: 'crm', context: ADMIN_A as never })) + .resolves.toMatchObject({ status: 'not_connected' }); + }); + + it('⛔ a store this deployment cannot read is a 503 at the door, never "not connected"', async () => { + const none = await boot([crm()], { noData: true }); + await expect(none.engine.getConnectorConnection({ connector: 'crm', context: ADMIN_A as never })) + .rejects.toMatchObject({ status: 503, code: 'SERVICE_UNAVAILABLE', message: expect.stringContaining('no data engine') }); + + const { engine, ql } = await boot(); + const find = ql.find.bind(ql); + vi.spyOn(ql, 'find').mockImplementation(async (object: string, ...rest: unknown[]) => { + if (object === CONNECTOR_CONNECTION_OBJECT) throw new Error('dummy-store-down-26702'); + return (find as any)(object, ...rest); + }); + const err = (await engine.getConnectorConnection({ connector: 'crm', context: ADMIN_A as never }).catch((e: Error) => e)) as Error & { status?: number }; + expect(err.status).toBe(503); + expect(err.message).toContain("connection of connector instance 'crm' cannot be read"); + expect(err.message).not.toContain('dummy-store-down-26702'); + }); + + it('the redirect URI is fixed per deployment: with none configured, begin is a 503 and is never taken from the request', async () => { + const saved = { a: process.env.OS_AUTH_URL, b: process.env.OS_BASE_URL }; + delete process.env.OS_AUTH_URL; + delete process.env.OS_BASE_URL; + try { + const { engine } = await boot([crm()], { redirectUri: null }); + await expect(engine.beginConnectorConnection({ connector: 'crm', context: ADMIN_A as never })) + .rejects.toMatchObject({ status: 503, message: expect.stringContaining('never taken from the request') }); + process.env.OS_BASE_URL = 'https://os.example.com/'; + const begun = await engine.beginConnectorConnection({ connector: 'crm', context: ADMIN_A as never }); + expect(new URL(begun.authorizationUrl).searchParams.get('redirect_uri')) + .toBe('https://os.example.com/api/v1/automation/connectors/oauth/callback'); + } finally { + if (saved.a === undefined) delete process.env.OS_AUTH_URL; else process.env.OS_AUTH_URL = saved.a; + if (saved.b === undefined) delete process.env.OS_BASE_URL; else process.env.OS_BASE_URL = saved.b; + } + }); +}); + +// ─── D4 dispatch ───────────────────────────────────────────────────── + +describe('ADR-0143 D4 — dispatch with the connection', () => { + it('a 401 refreshes once and retries; a rotated refresh token is stored atomically under a new revision', async () => { + const { call, connect, provider, ql } = await boot(); + await (await connect()).done(); + provider.state.accepted = new Set(['dummy-at-refresh-2']); + expect(await call('org_a')).toMatchObject({ status: 200 }); + expect(provider.state.sent).toEqual(['dummy-at-exchange-1', 'dummy-at-refresh-2']); + expect(idp.requests[1].form).toEqual({ grant_type: 'refresh_token', refresh_token: 'dummy-rt-1' }); + const [row] = await rowsOf(ql); + expect(row.revision).toBe(2); + expect(await (ql as any).resolveSecretField(CONNECTOR_CONNECTION_OBJECT, row.id, 'refresh_token')).toBe('dummy-rt-2'); + }); + + it('a token past its expiry (less the skew margin) is refreshed before it is sent', async () => { + const { call, connect, provider } = await boot(); + await (await connect()).done(); + const later = Date.now() + 3_600_000; + vi.spyOn(Date, 'now').mockReturnValue(later); + await call('org_a'); + expect(provider.state.sent).toEqual(['dummy-at-refresh-2']); + }); + + it('an unreachable token endpoint is CONNECTOR_UPSTREAM_UNAVAILABLE and the connection stays connected', async () => { + const { call, connect, provider, engine, ql } = await boot(); + await (await connect()).done(); + provider.state.accepted = new Set(); + idp.refresh = 'down'; + await expect(call('org_a')).rejects.toMatchObject({ code: 'CONNECTOR_UPSTREAM_UNAVAILABLE' }); + expect((await rowsOf(ql))[0].status).toBe('connected'); + expect(engine.getConnectorDegradedReason('crm')).toBeUndefined(); + }); + + it('a refused grant degrades the instance "reconnect required"; a reconnect brings it back', async () => { + const { call, connect, provider, engine, ql } = await boot(); + await (await connect()).done(); + provider.state.accepted = new Set(); + idp.refresh = 'invalid_grant'; + await expect(call('org_a')).rejects.toMatchObject({ status: 503, message: expect.stringContaining('Reconnect required') }); + await vi.waitFor(() => expect(engine.getConnectorDegradedReason('crm')).toContain("needs to be reconnected for organization 'org_a'")); + const [row] = await rowsOf(ql); + expect(row).toMatchObject({ status: 'reconnect_required' }); + expect(await (ql as any).resolveSecretField(CONNECTOR_CONNECTION_OBJECT, row.id, 'refresh_token')).toBeNull(); + expect(await engine.getConnectorConnection({ connector: 'crm', context: ADMIN_A as never })) + .toMatchObject({ status: 'reconnect_required', statusReason: expect.stringContaining('Reconnect required') }); + + idp.refresh = 'rotate'; + provider.state.accepted = undefined; + await (await connect()).done(); + expect(engine.getConnectorDegradedReason('crm')).toBeUndefined(); + expect(await call('org_a')).toMatchObject({ status: 200 }); + expect((await rowsOf(ql))[0]).toMatchObject({ status: 'connected', revision: 2 }); + }); + + it("the run's organization selects the connection; a run with none is refused when several exist", async () => { + const { call, connect, provider } = await boot(); + await (await connect(ADMIN_A)).done(); + await (await connect(ADMIN_B)).done(); + await call('org_b'); + await call('org_a'); + expect(provider.state.sent).toEqual(['dummy-at-exchange-2', 'dummy-at-exchange-1']); + await expect(call()).rejects.toMatchObject({ status: 422, message: expect.stringContaining('connected for 2 organizations') }); + await expect(call('org_c')).rejects.toMatchObject({ status: 503, message: expect.stringContaining("not connected for organization 'org_c'") }); + }); + + it('revoke removes the connection and holds the instance "not connected" again; a second revoke is a 404', async () => { + const { call, connect, engine, ql } = await boot(); + await (await connect()).done(); + 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); + expect(engine.getConnectorDegradedReason('crm')).toContain('is not connected'); + expect(engine.resolveConnectorAction('crm', 'request')).toBeUndefined(); + await expect(engine.revokeConnectorConnection({ connector: 'crm', context: ADMIN_A as never })).rejects.toMatchObject({ status: 404 }); + void call; + }); + + it('with a cluster service: a begun connect waits in its KV (any replica completes it) and a refresh runs under its lock', async () => { + const kvHeld = new Map(); + const lockKeys: string[] = []; + const cluster = { + kv: { + async set(key: string, value: unknown) { kvHeld.set(key, { value, version: kvHeld.size + 1 }); }, + async get(key: string) { return kvHeld.get(key); }, + async delete(key: string, opts?: { ifVersion?: number }) { + const e = kvHeld.get(key); + if (!e || (opts?.ifVersion !== undefined && e.version !== opts.ifVersion)) return false; + return kvHeld.delete(key); + }, + }, + lock: { + async withLock(key: string, fn: () => Promise) { lockKeys.push(key); return fn(); }, + }, + }; + const { call, connect, provider } = await boot([crm()], { cluster }); + const { done, state } = await connect(); + expect(kvHeld.size).toBe(1); + expect([...kvHeld.keys()][0]).not.toContain(state); + await done(); + expect(kvHeld.size).toBe(0); + provider.state.accepted = new Set(['dummy-at-refresh-2']); + await call('org_a'); + expect(lockKeys).toEqual(['connector-connection:crm:org_a:refresh']); + }); +}); + +// ─── No leak, D6 ───────────────────────────────────────────────────── + +describe('ADR-0143 D3/D6 — what a grant leaves behind', () => { + it('⛔ no token, code, verifier or client secret in a log line, a descriptor, a reason, a door answer or an engine read', async () => { + const out = captureOutput(); + const { engine, call, connect, provider, ql } = await boot(); + const { begun, done } = await connect(); + const answers = [JSON.stringify(begun.organizationId), 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 verifier = idp.requests[0].form.code_verifier; + + 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)) { + for (const secret of [CLIENT_SECRET, GOOD_CODE, verifier, 'dummy-rt-1', 'dummy-rt-2', 'dummy-at-exchange-1', 'dummy-at-refresh-2']) { + expect(text.includes(secret), `${where} carries ${secret}`).toBe(false); + } + } + // The logs did record the connect — the pin is not passing on silence. + expect(out.text()).toContain("connector instance 'crm' connected for organization 'org_a' by user 'usr_admin'"); + expect(out.text()).toContain("refused the grant of organization 'org_a'"); + }); + + it('⛔ writes only its own record — never sys_metadata, never a sign-in account (D6) — and reads no account either', async () => { + const writes: string[] = []; + const reads: string[] = []; + const { connect, call, provider, ql, engine } = await boot(); + for (const method of ['insert', 'update', 'delete'] as const) { + const real = (ql as any)[method].bind(ql); + vi.spyOn(ql as any, method).mockImplementation((object: unknown, ...rest: unknown[]) => { + writes.push(String(object)); + return real(object, ...rest); + }); + } + const realFind = (ql as any).find.bind(ql); + vi.spyOn(ql as any, 'find').mockImplementation((object: unknown, ...rest: unknown[]) => { + reads.push(String(object)); + return realFind(object, ...rest); + }); + await (await connect()).done(); + provider.state.accepted = new Set(['dummy-at-refresh-2']); + await call('org_a'); + await engine.revokeConnectorConnection({ connector: 'crm', context: ADMIN_A as never }); + + expect(writes.length).toBeGreaterThan(0); + expect(new Set(writes.filter((o) => o !== 'sys_secret'))).toEqual(new Set([CONNECTOR_CONNECTION_OBJECT])); + for (const object of [...writes, ...reads]) { + expect(object).not.toMatch(/metadata|account|session|user/); + } + }); + + it('the refresh token is the one masked field, so the audit copy masks it; the object has no data door', () => { + expect(collectMaskedReadFields(SysConnectorConnection as never)).toEqual(['refresh_token']); + expect(SysConnectorConnection.enable).toMatchObject({ apiEnabled: false, apiMethods: [] }); + expect(Object.keys(SysConnectorConnection.fields)).not.toContain('access_token'); + }); +}); diff --git a/packages/services/service-automation/src/connector-connections.ts b/packages/services/service-automation/src/connector-connections.ts new file mode 100644 index 00000000000..70a97903571 --- /dev/null +++ b/packages/services/service-automation/src/connector-connections.ts @@ -0,0 +1,284 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # Connector connections — the store behind `sys_connector_connection` (ADR-0143 D3) + * + * One row per `(organization, connector instance)` an administrator connected + * through the `authorization_code` grant (Q1 = A, Q2 = B). The row holds the + * refresh token (a `Field.secret()`, the #7799 channel `sys_connector_credential` + * uses), the grant's status, scopes and expiry, and a revision the per-dispatch + * refresh writes a rotated token over (D2.1, D4). + * + * ⛔ A refresh token leaves this module only as the return of + * {@link ConnectorConnectionStore.resolveRefreshToken}, whose one caller sends + * it to the instance's token endpoint. Every other read names the + * organization, the instance, a status and a revision — never a token. + */ + +import { + ConnectorCredentialUnavailableError, + isSecretProtectionFailure, + optionalText, + rowsOf, + type ConnectorCredentialEngine, +} from './connector-credentials.js'; + +/** The object a connection lives in (`sys-connector-connection.object.ts`). */ +export const CONNECTOR_CONNECTION_OBJECT = 'sys_connector_connection'; + +/** Its `type: 'secret'` column. */ +export const CONNECTOR_CONNECTION_REFRESH_TOKEN_FIELD = 'refresh_token'; + +/** A connection's status (`not connected` is the absence of a row). */ +export type ConnectorConnectionRowStatus = 'connected' | 'reconnect_required'; + +/** System context — the store is plugin-owned; no caller's grants apply to it. */ +const SYSTEM_CONTEXT = { isSystem: true, positions: [], permissions: [] } as const; + +/** One stored row, as far as anything reads it: never the refresh token. */ +export interface ConnectorConnectionRecord { + id: string; + organizationId: string; + connectorName: string; + status: ConnectorConnectionRowStatus; + statusReason?: string; + /** The granted scopes, as the authorization server answered them. */ + scopes?: string; + /** When the last access token obtained for this connection expires. */ + expiresAt?: string; + revision: number; + /** Whether a refresh token is stored — never what it is. */ + hasRefreshToken: boolean; + connectedAt?: string; + connectedBy?: string; +} + +/** The columns a read asks for — `refresh_token` is never among them; its presence is read from the masked value. */ +const RECORD_FIELDS = [ + 'id', 'organization_id', 'connector_name', 'status', 'status_reason', 'scopes', 'expires_at', + 'revision', 'connected_at', 'connected_by', CONNECTOR_CONNECTION_REFRESH_TOKEN_FIELD, +]; + +function toRecord(row: Record): ConnectorConnectionRecord | undefined { + if (typeof row.id !== 'string' && typeof row.id !== 'number') return undefined; + if (typeof row.organization_id !== 'string' || typeof row.connector_name !== 'string') return undefined; + const revision = Number(row.revision); + // The engine masks a `secret` column on every read; a masked value says a + // token is stored, and an empty one says none is — the value never comes back. + const masked = row[CONNECTOR_CONNECTION_REFRESH_TOKEN_FIELD]; + return { + id: String(row.id), + organizationId: row.organization_id, + connectorName: row.connector_name, + status: row.status === 'reconnect_required' ? 'reconnect_required' : 'connected', + statusReason: optionalText(row.status_reason), + scopes: optionalText(row.scopes), + expiresAt: optionalText(row.expires_at), + revision: Number.isFinite(revision) ? revision : 0, + hasRefreshToken: typeof masked === 'string' && masked.length > 0, + connectedAt: optionalText(row.connected_at), + connectedBy: optionalText(row.connected_by), + }; +} + +/** What a connect, or a refresh that rotated or renewed the grant, writes. */ +export interface ConnectorGrantWrite { + /** The refresh token to keep; `undefined` keeps the stored one (a server that does not rotate). */ + refreshToken?: string; + scopes?: string; + expiresAt?: Date; +} + +/** + * The `sys_connector_connection` store. One per automation plugin. It never + * caches a token: {@link resolveRefreshToken} reads the row at the moment of use. + */ +export class ConnectorConnectionStore { + constructor(private readonly resolveEngine: () => ConnectorCredentialEngine | undefined) {} + + private engineOrThrow(what: string): ConnectorCredentialEngine { + const engine = this.resolveEngine(); + if (!engine) { + throw new ConnectorCredentialUnavailableError( + `${what}: no data engine is available to the automation service, and a connector connection is kept only ` + + 'in the encrypted connection store.', + ); + } + return engine; + } + + /** + * As {@link read}, for the connection door: a store this deployment cannot + * read — no data engine, or one whose read fails — is a 503 + * `ConnectorCredentialUnavailableError`, never "not connected". The + * engine's own message is not carried (it may name the row it was handed). + */ + async readOrRefuse(organizationId: string, connectorName: string): Promise { + const what = `The connection of connector instance '${connectorName}' cannot be read`; + this.engineOrThrow(what); + try { + return await this.read(organizationId, connectorName); + } catch { + throw new ConnectorCredentialUnavailableError(`${what}: the connection store did not answer the read.`); + } + } + + /** The row for one instance in one organization, or `undefined` when it is not connected there. */ + async read(organizationId: string, connectorName: string): Promise { + const engine = this.resolveEngine(); + if (!engine) return undefined; + const found = await engine.find(CONNECTOR_CONNECTION_OBJECT, { + where: { organization_id: organizationId, connector_name: connectorName }, + fields: RECORD_FIELDS, + context: SYSTEM_CONTEXT, + }); + for (const row of rowsOf(found)) { + const record = toRecord(row); + if (record) return record; + } + return undefined; + } + + /** Every organization's row for one instance. */ + async listFor(connectorName: string): Promise { + const engine = this.resolveEngine(); + if (!engine) return []; + const found = await engine.find(CONNECTOR_CONNECTION_OBJECT, { + where: { connector_name: connectorName }, + fields: RECORD_FIELDS, + context: SYSTEM_CONTEXT, + }); + return rowsOf(found).map(toRecord).filter((r): r is ConnectorConnectionRecord => r !== undefined); + } + + /** + * The refresh token of `record`, read now, or `undefined` when the + * authorization server issued none. Throws + * {@link ConnectorCredentialUnavailableError} when a stored one cannot be + * read back (no privileged dereference, no crypto provider) — never read as + * "no token", which would turn a deployment fault into "reconnect required". + */ + async resolveRefreshToken(record: ConnectorConnectionRecord): Promise { + if (!record.hasRefreshToken) return undefined; + const engine = this.engineOrThrow(`The connection of connector instance '${record.connectorName}' cannot be read`); + if (typeof engine.resolveSecretField !== 'function') { + throw new ConnectorCredentialUnavailableError( + `The connection of connector instance '${record.connectorName}' is stored encrypted, but this data engine cannot ` + + 'dereference an encrypted field.', + ); + } + let plain: string | null; + try { + plain = await engine.resolveSecretField(CONNECTOR_CONNECTION_OBJECT, record.id, CONNECTOR_CONNECTION_REFRESH_TOKEN_FIELD); + } catch { + throw new ConnectorCredentialUnavailableError( + `The connection of connector instance '${record.connectorName}' is stored but could not be decrypted. Register the ` + + 'crypto provider it was written with on the data engine.', + ); + } + return typeof plain === 'string' && plain.length > 0 ? plain : undefined; + } + + /** + * Store what a completed connect produced for `(organizationId, + * connectorName)`, replacing any earlier connection there (a reconnect), + * and raise its revision. + */ + async connect(args: { organizationId: string; connectorName: string; userId?: string } & ConnectorGrantWrite): Promise { + const engine = this.engineOrThrow(`Connector instance '${args.connectorName}' was not connected`); + const existing = await this.read(args.organizationId, args.connectorName); + const context = { ...SYSTEM_CONTEXT, ...(args.userId ? { userId: args.userId } : {}) }; + const row = { + grant: 'authorization_code', + [CONNECTOR_CONNECTION_REFRESH_TOKEN_FIELD]: args.refreshToken ?? null, + status: 'connected', + status_reason: null, + scopes: args.scopes ?? null, + expires_at: args.expiresAt ? args.expiresAt.toISOString() : null, + revision: (existing?.revision ?? 0) + 1, + connected_at: new Date().toISOString(), + connected_by: args.userId ?? null, + }; + try { + if (existing) { + await engine.update(CONNECTOR_CONNECTION_OBJECT, { id: existing.id, ...row }, { context }); + } else { + await engine.insert( + CONNECTOR_CONNECTION_OBJECT, + { organization_id: args.organizationId, connector_name: args.connectorName, ...row }, + { context }, + ); + } + } catch (err) { + if (isSecretProtectionFailure(err)) { + throw new ConnectorCredentialUnavailableError( + `Connector instance '${args.connectorName}' was not connected: the encrypted connection store needs a crypto ` + + 'provider this deployment has not registered. Nothing was kept.', + ); + } + // A foreign message may carry the row it was handed. + throw new ConnectorCredentialUnavailableError( + `Connector instance '${args.connectorName}' was not connected: the connection store refused the write. Nothing was kept.`, + ); + } + const stored = await this.read(args.organizationId, args.connectorName); + if (!stored) { + throw new ConnectorCredentialUnavailableError( + `Connector instance '${args.connectorName}' was not connected: the connection did not read back after its write.`, + ); + } + return stored; + } + + /** + * Record what a refresh over `record` produced: a rotated refresh token + * (D2.1, atomically), the new expiry and scopes. Written only over the + * revision `record` carries — `false`, with nothing written, when another + * writer moved the row since (a reconnect, a revoke, a refresh on another + * replica). The caller runs this inside the connection's refresh lock. + */ + async recordRefresh(record: ConnectorConnectionRecord, write: ConnectorGrantWrite): Promise { + const engine = this.engineOrThrow(`The connection of connector instance '${record.connectorName}' was not renewed`); + const current = await this.read(record.organizationId, record.connectorName); + if (!current || current.revision !== record.revision) return false; + const rotated = write.refreshToken !== undefined; + await engine.update( + CONNECTOR_CONNECTION_OBJECT, + { + id: current.id, + ...(rotated ? { [CONNECTOR_CONNECTION_REFRESH_TOKEN_FIELD]: write.refreshToken, revision: current.revision + 1 } : {}), + ...(write.scopes !== undefined ? { scopes: write.scopes } : {}), + expires_at: write.expiresAt ? write.expiresAt.toISOString() : null, + }, + { context: SYSTEM_CONTEXT }, + ); + return true; + } + + /** + * Record that the authorization server refused the grant `record` held + * (revoked, or expired with no usable refresh). `reason` is the platform's + * sentence, never the server's text. Left alone when the row moved since. + */ + async markReconnectRequired(record: ConnectorConnectionRecord, reason: string): Promise { + const engine = this.resolveEngine(); + if (!engine) return; + const current = await this.read(record.organizationId, record.connectorName); + if (!current || current.revision !== record.revision) return; + await engine.update( + CONNECTOR_CONNECTION_OBJECT, + { id: current.id, status: 'reconnect_required', status_reason: reason, [CONNECTOR_CONNECTION_REFRESH_TOKEN_FIELD]: null }, + { context: SYSTEM_CONTEXT }, + ); + } + + /** Remove the connection of one instance in one organization. `false` when it was not connected there. */ + async revoke(organizationId: string, connectorName: string, userId?: string): Promise { + const engine = this.engineOrThrow(`The connection of connector instance '${connectorName}' was not revoked`); + const existing = await this.read(organizationId, connectorName); + if (!existing) return false; + const context = { ...SYSTEM_CONTEXT, ...(userId ? { userId } : {}) }; + await engine.delete(CONNECTOR_CONNECTION_OBJECT, { where: { id: existing.id }, context }); + return true; + } +} diff --git a/packages/services/service-automation/src/connector-credentials.ts b/packages/services/service-automation/src/connector-credentials.ts index a5bc6faf007..3b97361f300 100644 --- a/packages/services/service-automation/src/connector-credentials.ts +++ b/packages/services/service-automation/src/connector-credentials.ts @@ -155,11 +155,12 @@ export class ConnectorCredentialUnavailableError extends Error { } /** True when `err` is the engine's fail-closed refusal to persist a `secret` field. */ -function isSecretProtectionFailure(err: unknown): boolean { +export function isSecretProtectionFailure(err: unknown): boolean { return /Cannot persist secret field/i.test(String((err as Error)?.message ?? err ?? '')); } -function rowsOf(found: unknown): Record[] { +/** The rows of a data engine `find` answer, whichever envelope it came in. */ +export function rowsOf(found: unknown): Record[] { if (Array.isArray(found)) return found as Record[]; const env = found as { data?: unknown; records?: unknown; value?: unknown } | null | undefined; for (const candidate of [env?.data, env?.records, env?.value]) { @@ -168,7 +169,8 @@ function rowsOf(found: unknown): Record[] { return []; } -function optionalText(v: unknown): string | undefined { +/** A non-empty text column, a datetime column read as ISO, or `undefined`. */ +export function optionalText(v: unknown): string | undefined { if (typeof v === 'string' && v.length > 0) return v; if (v instanceof Date) return v.toISOString(); return undefined; diff --git a/packages/services/service-automation/src/connector-materialization.test.ts b/packages/services/service-automation/src/connector-materialization.test.ts index 630e9cf2b8b..9126935e867 100644 --- a/packages/services/service-automation/src/connector-materialization.test.ts +++ b/packages/services/service-automation/src/connector-materialization.test.ts @@ -90,6 +90,8 @@ function makeFakeProvider() { interface BootOptions { providerKey?: string; providerFactory?: ConnectorProviderFactory; + /** The registration options of {@link providerFactory} (ADR-0143 D4's credential-handle opt-in). */ + providerOptions?: { credentialHandle?: boolean }; /** Names registered as plugin connectors during init() (for the conflict rule). */ registerLivePlugin?: string[]; credentialResolver?: CredentialResolver; @@ -120,7 +122,7 @@ async function boot(declared: unknown[], opts: BootOptions = {}): Promise { - const grantBearing: Array<[string, ConnectorInstanceAuth]> = [ - ['oauth2', { type: 'oauth2', grant: 'client_credentials', tokenUrl: 'https://idp.example.com/token', clientId: 'c', credentialRef: 'SECRET' }], - ['app-credential', { type: 'app-credential', vendor: 'feishu', appId: 'cli_x', credentialRef: 'SECRET' }], - ]; - - it.each(grantBearing)('holds a `%s` instance degraded with the reason, without calling its factory', async (kind, auth) => { - const { factory, calls } = makeFakeProvider(); - const kernel = await boot([providerConnector('vendor', { auth })], { - providerFactory: factory, - credentialResolver: () => 'resolved-secret', - }); - const engine = automationOf(kernel); +// ADR-0143 D2.2, D4 — a grant-bearing instance is served only through the +// per-dispatch credential handle, by a provider registered as asking it on +// every request. Anything this server cannot serve yet is held degraded with a +// named reason and its factory is never invoked — never materialized without +// its credential, never called upstream unauthenticated. A boot does not fail +// on it: it is not an authoring fault. +describe('ADR-0143 D4 — a grant-bearing instance is served through its credential handle, or held degraded', () => { + const CLIENT_CREDENTIALS: ConnectorInstanceAuth = { + type: 'oauth2', grant: 'client_credentials', tokenUrl: 'https://idp.example.com/token', clientId: 'c', credentialRef: 'SECRET', + }; + const AUTHORIZATION_CODE: ConnectorInstanceAuth = { + type: 'oauth2', + grant: 'authorization_code', + authorizationUrl: 'https://idp.example.com/authorize', + tokenUrl: 'https://idp.example.com/token', + clientId: 'c', + credentialRef: 'SECRET', + }; + + function expectHeld(engine: AutomationEngine, calls: unknown[], ...reason: string[]) { expect(calls).toHaveLength(0); const desc = engine.getConnectorDescriptors().find((d) => d.name === 'vendor'); expect(desc?.state).toBe('degraded'); expect(desc?.actions).toEqual([]); - expect(engine.getConnectorDegradedReason('vendor')).toContain(`auth type '${kind}'`); - expect(engine.getConnectorDegradedReason('vendor')).toContain('never called without its credential'); + for (const r of reason) expect(engine.getConnectorDegradedReason('vendor')).toContain(r); expect(engine.resolveConnectorAction('vendor', 'ping')).toBeUndefined(); + } + + it('holds an `app-credential` instance degraded (its vendor exchanges are not run yet), even on an opted-in provider', async () => { + const { factory, calls } = makeFakeProvider(); + const kernel = await boot( + [providerConnector('vendor', { auth: { type: 'app-credential', vendor: 'feishu', appId: 'cli_x', credentialRef: 'SECRET' } })], + { providerFactory: factory, providerOptions: { credentialHandle: true }, credentialResolver: () => 'resolved-secret' }, + ); + expectHeld(automationOf(kernel), calls, "auth type 'app-credential'", 'never called without its credential'); + await kernel.shutdown(); + }); + + it('holds an `oauth2` instance degraded on a provider that does not ask the credential handle', async () => { + const { factory, calls } = makeFakeProvider(); + const kernel = await boot([providerConnector('vendor', { auth: CLIENT_CREDENTIALS })], { + providerFactory: factory, + credentialResolver: () => 'resolved-secret', + }); + expectHeld(automationOf(kernel), calls, "provider 'fake' does not yet ask the per-dispatch credential handle", 'never called without its credential'); + await kernel.shutdown(); + }); + + it('holds an `oauth2` instance degraded when it declares no endpoints or client to send a token request with', async () => { + const { factory, calls } = makeFakeProvider(); + const kernel = await boot( + [providerConnector('vendor', { auth: { type: 'oauth2', grant: 'authorization_code', credentialRef: 'SECRET' } })], + { providerFactory: factory, providerOptions: { credentialHandle: true }, credentialResolver: () => 'resolved-secret' }, + ); + expectHeld(automationOf(kernel), calls, '`tokenUrl`, `clientId`, `authorizationUrl`', 'not served'); + await kernel.shutdown(); + }); + + it.each([ + ['tokenUrl', { tokenUrl: 'http://idp.example.com/token' }], + ['authorizationUrl', { authorizationUrl: 'http://idp.example.com/authorize' }], + ['tokenUrl', { tokenUrl: 'ftp://idp.example.com/token' }], + ] as const)('⛔ holds an `oauth2` instance degraded when its `%s` is not https off loopback, naming the field', async (field, over) => { + const { factory, calls } = makeFakeProvider(); + const kernel = await boot([providerConnector('vendor', { auth: { ...AUTHORIZATION_CODE, ...over } as ConnectorInstanceAuth })], { + providerFactory: factory, + providerOptions: { credentialHandle: true }, + credentialResolver: () => 'resolved-secret', + }); + expectHeld(automationOf(kernel), calls, `\`${field}\` not over https`, 'not served'); + // The reason names the field, never the URL it was given. + expect(automationOf(kernel).getConnectorDegradedReason('vendor')).not.toContain('idp.example.com'); + await kernel.shutdown(); + }); + + it('serves an `oauth2` instance whose token endpoint is plain http on a loopback host', async () => { + const { factory, calls } = makeFakeProvider(); + const kernel = await boot([providerConnector('vendor', { auth: { ...CLIENT_CREDENTIALS, tokenUrl: 'http://127.0.0.1:8080/token' } as ConnectorInstanceAuth })], { + providerFactory: factory, + providerOptions: { credentialHandle: true }, + credentialResolver: () => 'resolved-secret', + }); + expect(calls).toHaveLength(1); + expect(automationOf(kernel).getConnectorDescriptors().find((d) => d.name === 'vendor')?.state).toBe('ready'); + await kernel.shutdown(); + }); + + it('serves a `client_credentials` instance on an opted-in provider: a credential handle, and no static auth', async () => { + const { factory, calls } = makeFakeProvider(); + const kernel = await boot([providerConnector('vendor', { auth: CLIENT_CREDENTIALS })], { + providerFactory: factory, + providerOptions: { credentialHandle: true }, + credentialResolver: () => 'resolved-secret', + }); + expect(calls).toHaveLength(1); + expect(calls[0].auth).toBeUndefined(); + expect(calls[0].credentialHandle).toMatchObject({ connector: 'vendor' }); + expect(typeof calls[0].credentialHandle?.get).toBe('function'); + expect(automationOf(kernel).getConnectorDescriptors().find((d) => d.name === 'vendor')?.state).toBe('ready'); + await kernel.shutdown(); + }); + + it("holds an `authorization_code` instance nobody connected degraded \"not connected\" — the boot does not fail", async () => { + const { factory, calls } = makeFakeProvider(); + const kernel = await boot([providerConnector('vendor', { auth: AUTHORIZATION_CODE })], { + providerFactory: factory, + providerOptions: { credentialHandle: true }, + credentialResolver: () => 'resolved-secret', + }); + expectHeld(automationOf(kernel), calls, "connector instance 'vendor' is not connected", '/connectors/vendor/connection'); await kernel.shutdown(); }); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 099bc293118..61143c42496 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -10,7 +10,7 @@ import type { FlowFunctionEffect, FlowRunSummary, } from '@objectstack/spec/automation'; -import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, RunListResult, ScreenSpec, ScreenFieldSpec, ConnectorSourcePullRequest, ConnectorSourcePullResult, ConnectorCredentialRef, ConnectorCredentialSetRequest, ConnectorCredentialClearRequest } from '@objectstack/spec/contracts'; +import type { AutomationContext, AutomationResult, ResumeSignal, IAutomationService, RunListResult, ScreenSpec, ScreenFieldSpec, ConnectorSourcePullRequest, ConnectorSourcePullResult, ConnectorCredentialRef, ConnectorCredentialSetRequest, ConnectorCredentialClearRequest, ConnectorConnectionState, ConnectorConnectionRequest, ConnectorConnectionBegin, ConnectorConnectionCallbackRequest } from '@objectstack/spec/contracts'; import type { MetadataActivationWriteOptions } from '@objectstack/core'; import type { FlowRegistration, FlowRegistrationRefusal, FlowInnerRefusal } from '@objectstack/spec/api'; import { RESUME_AUTHORITY_SERVICE } from '@objectstack/spec/contracts'; @@ -684,10 +684,15 @@ export interface RegisteredConnector { * upstream refused (#27237); * - `credential`: setting the stored credential `ref` again — a value stored * through the credential door that the upstream refused (#27237). + * - `connection`: an administrator connecting the instance — an + * `authorization_code` instance never authorized, or whose grant the + * authorization server refused (ADR-0143 D4). */ export type ConnectorHold = | { readonly kind: 'declaration' } - | { readonly kind: 'credential'; readonly ref: string }; + | { readonly kind: 'credential'; readonly ref: string } + // ADR-0143 D4 — an `authorization_code` instance with no working connection. + | { readonly kind: 'connection' }; // The connector **provider** contract (ADR-0097) — ConnectorProviderFactory, // ConnectorProviderContext, ConnectorMaterialization — lives in @@ -2576,6 +2581,19 @@ export interface ConnectorCredentialDoor { attachClusterPubSub(pubsub: IPubSub, nodeId: string): () => void; } +/** + * ADR-0143 D3 / D5 — the connector connection door the automation plugin + * attaches: the four `IAutomationService` connection members, served where the + * declared instances, the connection store and the grant handles live. Every + * answer carries a status, never a token. + */ +export interface ConnectorConnectionDoor { + get(request: ConnectorConnectionRequest): Promise; + begin(request: ConnectorConnectionRequest): Promise; + complete(request: ConnectorConnectionCallbackRequest): Promise; + revoke(request: ConnectorConnectionRequest): Promise; +} + /** * [#20790] One credential a registered flow holds, by class: a literal in its * definition (a packaged flow's source), or held by the credential channel. @@ -2725,6 +2743,8 @@ export class AutomationEngine implements IAutomationService { private connectorPullSource?: (request: ConnectorSourcePullRequest) => Promise; /** [#27237] The connector credential door — see {@link setConnectorCredentialDoor}. */ private connectorCredentialDoor?: ConnectorCredentialDoor; + /** ADR-0143 D5 — the connector connection door — see {@link setConnectorConnectionDoor}. */ + private connectorConnectionDoor?: ConnectorConnectionDoor; /** [#22450] The lazy `i18n` service reader — see {@link setI18nServiceSource}. */ private i18nServiceSource?: () => RefusalI18nService | undefined; /** @@ -2844,6 +2864,8 @@ export class AutomationEngine implements IAutomationService { private connectors = new Map(); /** Connector provider factories keyed by provider name (ADR-0097 §2 — `openapi`/`mcp`/`rest`/…). */ private connectorProviders = new Map(); + /** ADR-0143 D4 — provider keys registered with `credentialHandle: true`. */ + private readonly credentialHandleProviders = new Set(); /** Bridge to the host function registry for `script`-node calls (#1870), if wired. */ private functionResolver: FlowFunctionResolver | null = null; /** Bridge to the host object registry for schema-aware condition validation at @@ -4199,14 +4221,32 @@ export class AutomationEngine implements IAutomationService { * factory at boot for every declarative `connectors:` entry naming that * provider, turning stack metadata into a live connector. Re-registering a * key replaces (mirrors {@link registerNodeExecutor}). - */ - registerConnectorProvider(providerKey: string, factory: ConnectorProviderFactory): void { + * + * ADR-0143 D4 — `options.credentialHandle: true` says the factory reads + * `ConnectorProviderContext.credentialHandle` and asks it on every + * upstream request. Only such a provider is handed a grant-bearing + * (`oauth2`) instance; any other keeps that instance degraded, naming the + * provider, so nothing is called upstream without its credential. + * Re-registering a key without it withdraws it. + */ + registerConnectorProvider( + providerKey: string, + factory: ConnectorProviderFactory, + options?: { readonly credentialHandle?: boolean }, + ): void { // The body lives in ./flow-definitions.ts (#25222). const host: ThisParameterType = { connectorProviders: this.connectorProviders, logger: this.logger, }; registerConnectorProvider.call(host, providerKey, factory); + if (options?.credentialHandle === true) this.credentialHandleProviders.add(providerKey); + else this.credentialHandleProviders.delete(providerKey); + } + + /** ADR-0143 D4 — whether the provider under `providerKey` was registered as asking the credential handle. */ + providerAppliesCredentialHandle(providerKey: string): boolean { + return this.credentialHandleProviders.has(providerKey); } /** Unregister a connector provider factory (hot-unplug). */ @@ -4217,6 +4257,7 @@ export class AutomationEngine implements IAutomationService { logger: this.logger, }; unregisterConnectorProvider.call(host, providerKey); + this.credentialHandleProviders.delete(providerKey); } /** Resolve a provider factory by key, or `undefined` when none is installed. */ @@ -4886,6 +4927,43 @@ export class AutomationEngine implements IAutomationService { return this.requireConnectorCredentialDoor(`clearConnectorCredential('${request.ref}')`).clear(request); } + /** ADR-0143 D5 — attach (or detach) the connector connection door; the automation plugin does it at `init()`. */ + setConnectorConnectionDoor(door: ConnectorConnectionDoor | undefined): void { + this.connectorConnectionDoor = door; + } + + /** ADR-0143 D5 — the door, or the refusal a bare engine answers: nothing here can connect an instance. */ + private requireConnectorConnectionDoor(verb: string): ConnectorConnectionDoor { + if (this.connectorConnectionDoor) return this.connectorConnectionDoor; + throw Object.assign( + new Error( + `[Automation] ${verb}: this automation engine has no connector connection door attached — it is attached by ` + + 'AutomationServicePlugin at init(); a bare engine has no declared connectors and no connection store.', + ), + { code: 'SERVICE_UNAVAILABLE', status: 503 }, + ); + } + + /** ADR-0143 D3 — `IAutomationService.getConnectorConnection`: a status, never a token. */ + async getConnectorConnection(request: ConnectorConnectionRequest): Promise { + return this.requireConnectorConnectionDoor(`getConnectorConnection('${request.connector}')`).get(request); + } + + /** ADR-0143 D5 — `IAutomationService.beginConnectorConnection`: the authorization URL to send the browser to. */ + async beginConnectorConnection(request: ConnectorConnectionRequest): Promise { + return this.requireConnectorConnectionDoor(`beginConnectorConnection('${request.connector}')`).begin(request); + } + + /** ADR-0143 D5 — `IAutomationService.completeConnectorConnection`: the redirect's code exchanged and stored. */ + async completeConnectorConnection(request: ConnectorConnectionCallbackRequest): Promise { + return this.requireConnectorConnectionDoor('completeConnectorConnection()').complete(request); + } + + /** ADR-0143 D5 — `IAutomationService.revokeConnectorConnection`: the organization's connection removed. */ + async revokeConnectorConnection(request: ConnectorConnectionRequest): Promise { + return this.requireConnectorConnectionDoor(`revokeConnectorConnection('${request.connector}')`).revoke(request); + } + /** [#20790] Does the credential channel hold the LIVE credential at this position? */ holdsFlowCredential(flowName: string, nodeId: string, key: string): boolean { return this.flowCredentialSource?.holds(flowName, nodeId, key) ?? false; diff --git a/packages/services/service-automation/src/oauth2-grant/authorization.test.ts b/packages/services/service-automation/src/oauth2-grant/authorization.test.ts new file mode 100644 index 00000000000..3b213d35304 --- /dev/null +++ b/packages/services/service-automation/src/oauth2-grant/authorization.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D5 — the begin half of the connect flow: PKCE (S256 always), the +// state, the authorization URL, and the single-use store a begun connect waits in. + +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import type { IKV } from '@objectstack/spec/contracts'; +import { + PENDING_AUTHORIZATION_TTL_SECONDS, + buildAuthorizationUrl, + createAuthorizationState, + createPkcePair, + kvPendingAuthorizationStore, + memoryPendingAuthorizationStore, + type PendingAuthorization, +} from './authorization.js'; + +const pending = (over: Partial = {}): PendingAuthorization => ({ + connector: 'crm', + organizationId: 'org_a', + userId: 'usr_1', + verifier: 'dummy-verifier', + redirectUri: 'https://app.example.com/api/v1/automation/connectors/oauth/callback', + expiresAt: Date.now() + 60_000, + ...over, +}); + +/** A minimal versioned KV, as the cluster service's is. */ +function fakeKv() { + const held = new Map(); + let version = 0; + const kv = { + async set(key: string, value: unknown, opts?: { ttl?: number }) { + version += 1; + held.set(key, { value, version, ttl: opts?.ttl }); + }, + async get(key: string) { + const e = held.get(key); + return e ? { value: e.value, version: e.version } : undefined; + }, + async delete(key: string, opts?: { ifVersion?: number }) { + const e = held.get(key); + if (!e || (opts?.ifVersion !== undefined && opts.ifVersion !== e.version)) return false; + held.delete(key); + return true; + }, + }; + return { kv: kv as unknown as IKV, held }; +} + +describe('ADR-0143 D5 — PKCE and state', () => { + it('the challenge is the S256 of a 256-bit verifier (RFC 7636 §4.2)', () => { + const { verifier, challenge } = createPkcePair(); + expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(challenge).toBe(createHash('sha256').update(verifier, 'ascii').digest('base64url')); + expect(createPkcePair().verifier).not.toBe(verifier); + }); + + it('a state is 256 bits and fresh each time', () => { + const a = createAuthorizationState(); + expect(a).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(createAuthorizationState()).not.toBe(a); + }); + + it('the authorization URL asks for a code with the S256 challenge, the state and the fixed redirect URI', () => { + const url = new URL(buildAuthorizationUrl({ + authorizationUrl: 'https://idp.example.com/authorize?tenant=x', + clientId: 'client-1', + redirectUri: 'https://app.example.com/cb', + state: 'st', + challenge: 'ch', + scopes: ['read', 'write'], + resource: 'https://api.example.com', + })); + expect(url.origin + url.pathname).toBe('https://idp.example.com/authorize'); + expect(Object.fromEntries(url.searchParams)).toEqual({ + tenant: 'x', + response_type: 'code', + client_id: 'client-1', + redirect_uri: 'https://app.example.com/cb', + state: 'st', + code_challenge: 'ch', + code_challenge_method: 'S256', + scope: 'read write', + resource: 'https://api.example.com', + }); + }); +}); + +describe('ADR-0143 D5 — a begun connect is taken once', () => { + it('in memory: a second take finds nothing', async () => { + const store = memoryPendingAuthorizationStore(); + await store.put('st', pending()); + expect(await store.take('st')).toMatchObject({ connector: 'crm', userId: 'usr_1' }); + expect(await store.take('st')).toBeUndefined(); + expect(await store.take('never-begun')).toBeUndefined(); + }); + + it('in the cluster KV: kept for the TTL under the state\'s digest, never under the raw state', async () => { + const { kv, held } = fakeKv(); + const store = kvPendingAuthorizationStore(kv); + await store.put('raw-state-value', pending()); + const [key, entry] = [...held][0]; + expect(key).not.toContain('raw-state-value'); + expect(key).toContain(createHash('sha256').update('raw-state-value').digest('hex')); + expect(entry.ttl).toBe(PENDING_AUTHORIZATION_TTL_SECONDS); + expect(await store.take('raw-state-value')).toMatchObject({ organizationId: 'org_a' }); + expect(await store.take('raw-state-value')).toBeUndefined(); + }); + + it('in the cluster KV: of two callbacks racing on two replicas, only the one whose delete lands holds it', async () => { + const { kv } = fakeKv(); + const store = kvPendingAuthorizationStore(kv); + await store.put('st', pending()); + // The other replica read the same entry and deleted it first. + const realDelete = kv.delete.bind(kv); + (kv as any).delete = async (key: string, opts: Parameters[1]) => { + await realDelete(key); + return realDelete(key, opts); + }; + expect(await store.take('st')).toBeUndefined(); + }); +}); diff --git a/packages/services/service-automation/src/oauth2-grant/authorization.ts b/packages/services/service-automation/src/oauth2-grant/authorization.ts new file mode 100644 index 00000000000..3bd6fd9553e --- /dev/null +++ b/packages/services/service-automation/src/oauth2-grant/authorization.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # Connecting an `authorization_code` instance (ADR-0143 D5) + * + * The two halves of the connect flow the automation plugin runs behind + * `POST /automation/connectors/:name/connection` and + * `GET /automation/connectors/oauth/callback`: + * + * 1. **begin** — a fresh `state` and a PKCE pair (RFC 7636, `S256` always, + * D2.1), the pending authorization kept under the state's digest, and the + * authorization URL the administrator's browser is sent to; + * 2. **complete** — the callback's `state` is taken (once: a second use + * finds nothing), checked against the caller who began it, and its code + * exchanged at the token endpoint with the verifier. + * + * **Where the pending authorization lives.** Not in the connection record: + * D3 lists what is persisted, and a verifier is not on it. It is kept in the + * cluster's ephemeral KV when the deployment has one — so the callback may + * land on any replica — with a ten-minute TTL, and in process memory + * otherwise (a single-node deployment). The KV key is the SHA-256 of the + * state, so the raw state is never a key anyone can list. + * + * **What it is bound to.** The organization and the user that began it, and + * the instance. A callback completed by anyone else — another user's browser + * carrying a code an attacker obtained for their own vendor account — is + * refused before the code is exchanged. The redirect URI is fixed per + * deployment by the caller and never taken from the request. + */ + +import { createHash, randomBytes } from 'node:crypto'; +import type { IKV } from '@objectstack/spec/contracts'; + +/** How long a begun connect may wait for its callback. */ +export const PENDING_AUTHORIZATION_TTL_SECONDS = 600; + +/** What a begun connect remembers until its callback. ⛔ Never leaves the server. */ +export interface PendingAuthorization { + readonly connector: string; + readonly organizationId: string; + readonly userId: string; + /** The PKCE verifier (RFC 7636 §4.1) — sent only to the token endpoint, with the code. */ + readonly verifier: string; + /** The redirect URI the authorization request named; the code exchange must repeat it (RFC 6749 §4.1.3). */ + readonly redirectUri: string; + /** Epoch ms after which the callback is refused. */ + readonly expiresAt: number; +} + +/** Where pending authorizations wait for their callback. `take` is single-use. */ +export interface PendingAuthorizationStore { + put(state: string, pending: PendingAuthorization): Promise; + /** The pending authorization `state` names, removed as it is read; `undefined` when there is none (or it was taken). */ + take(state: string): Promise; +} + +const base64url = (bytes: Buffer): string => bytes.toString('base64url'); + +/** A PKCE pair (RFC 7636 §4.1–4.2): a 256-bit verifier and its `S256` challenge. */ +export function createPkcePair(): { verifier: string; challenge: string } { + const verifier = base64url(randomBytes(32)); + const challenge = base64url(createHash('sha256').update(verifier, 'ascii').digest()); + return { verifier, challenge }; +} + +/** A fresh, unguessable `state` (RFC 6749 §10.12). */ +export function createAuthorizationState(): string { + return base64url(randomBytes(32)); +} + +const stateKey = (state: string): string => + `connector-oauth2:pending:${createHash('sha256').update(state, 'utf8').digest('hex')}`; + +/** The store over the cluster's ephemeral KV. */ +export function kvPendingAuthorizationStore(kv: IKV): PendingAuthorizationStore { + return { + async put(state, pending) { + await kv.set(stateKey(state), pending, { ttl: PENDING_AUTHORIZATION_TTL_SECONDS }); + }, + async take(state) { + const key = stateKey(state); + const entry = await kv.get(key); + if (!entry) return undefined; + // Only the caller whose delete lands holds it: a replayed callback, + // or two racing on two replicas, finds nothing. + const deleted = await kv.delete(key, { ifVersion: entry.version }); + return deleted ? entry.value : undefined; + }, + }; +} + +/** The in-process store, for a deployment with no cluster service. */ +export function memoryPendingAuthorizationStore(now: () => number = Date.now): PendingAuthorizationStore { + const held = new Map(); + return { + async put(state, pending) { + for (const [key, value] of held) if (value.expiresAt <= now()) held.delete(key); + held.set(stateKey(state), pending); + }, + async take(state) { + const key = stateKey(state); + const pending = held.get(key); + held.delete(key); + return pending; + }, + }; +} + +/** The authorization request URL (RFC 6749 §4.1.1, RFC 7636 §4.3, RFC 8707). */ +export function buildAuthorizationUrl(args: { + authorizationUrl: string; + clientId: string; + redirectUri: string; + state: string; + challenge: string; + scopes?: readonly string[]; + resource?: string; +}): string { + const url = new URL(args.authorizationUrl); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', args.clientId); + url.searchParams.set('redirect_uri', args.redirectUri); + url.searchParams.set('state', args.state); + url.searchParams.set('code_challenge', args.challenge); + url.searchParams.set('code_challenge_method', 'S256'); + if (args.scopes && args.scopes.length > 0) url.searchParams.set('scope', args.scopes.join(' ')); + if (args.resource) url.searchParams.set('resource', args.resource); + return url.toString(); +} diff --git a/packages/services/service-automation/src/oauth2-grant/endpoint-transport.test.ts b/packages/services/service-automation/src/oauth2-grant/endpoint-transport.test.ts new file mode 100644 index 00000000000..0ec981b39e5 --- /dev/null +++ b/packages/services/service-automation/src/oauth2-grant/endpoint-transport.test.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D4 / D5 — an `oauth2` instance sends its client secret and refresh +// token only over https, or over plain http to a loopback host. + +import { describe, expect, it } from 'vitest'; +import { insecureOAuth2EndpointFields, isSecureOAuth2Endpoint } from './endpoint-transport.js'; + +describe('ADR-0143 D4 — which oauth2 endpoint a secret may be sent to', () => { + it.each([ + 'https://idp.example.com/token', + 'HTTPS://IDP.EXAMPLE.COM/token', + 'http://localhost:8080/token', + 'http://idp.localhost/token', + 'http://127.0.0.1:9000/token', + 'http://127.255.0.1/token', + 'http://[::1]:9000/token', + ])('accepts %s', (url) => { + expect(isSecureOAuth2Endpoint(url)).toBe(true); + }); + + it.each([ + 'http://idp.example.com/token', + 'http://10.0.0.5/token', + 'http://128.0.0.1/token', + 'http://127.0.0.1.example.com/token', + 'http://localhost.example.com/token', + 'ftp://idp.example.com/token', + 'javascript:alert(1)', + 'not a url', + ])('refuses %s', (url) => { + expect(isSecureOAuth2Endpoint(url)).toBe(false); + }); + + it('names the refused fields, in order, and ignores the ones not declared', () => { + expect(insecureOAuth2EndpointFields({ tokenUrl: 'http://idp.example.com/t', authorizationUrl: 'http://idp.example.com/a' })) + .toEqual(['tokenUrl', 'authorizationUrl']); + expect(insecureOAuth2EndpointFields({ tokenUrl: 'https://idp.example.com/t' })).toEqual([]); + expect(insecureOAuth2EndpointFields({})).toEqual([]); + }); +}); diff --git a/packages/services/service-automation/src/oauth2-grant/endpoint-transport.ts b/packages/services/service-automation/src/oauth2-grant/endpoint-transport.ts new file mode 100644 index 00000000000..80eec8885ed --- /dev/null +++ b/packages/services/service-automation/src/oauth2-grant/endpoint-transport.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0143 D4 / D5 — which endpoint an `oauth2` instance may send its secrets + * to. The token request carries the client secret (HTTP Basic) and the refresh + * token, and the authorization URL is where an administrator's browser is sent, + * so both must be `https:`. The spec admits any URL (`z.string().url()`), so the + * server holds the line: plain `http:` is accepted only to a loopback host, + * where nothing crosses a network, and every other scheme is refused. + */ + +/** The `oauth2` endpoint fields this check covers, in the order a reason names them. */ +export const OAUTH2_ENDPOINT_FIELDS = ['tokenUrl', 'authorizationUrl'] as const; + +/** True for `localhost`, a `*.localhost` name, any `127.0.0.0/8` address and `[::1]`. */ +function isLoopbackHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + if (host === 'localhost' || host.endsWith('.localhost')) return true; + if (host === '[::1]') return true; + return /^127(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(host); +} + +/** + * True when `url` is an endpoint an `oauth2` instance may send its secrets to: + * `https:`, or `http:` to a loopback host. A value that does not parse is not. + */ +export function isSecureOAuth2Endpoint(url: string): boolean { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol === 'https:') return true; + return parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname); +} + +/** + * The endpoint fields of `auth` that {@link isSecureOAuth2Endpoint} refuses, + * by name. The URLs themselves are never echoed: a reason names the field. + */ +export function insecureOAuth2EndpointFields(auth: Partial>): string[] { + return OAUTH2_ENDPOINT_FIELDS.filter((field) => { + const value = auth[field]; + return typeof value === 'string' && !isSecureOAuth2Endpoint(value); + }); +} 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 new file mode 100644 index 00000000000..35c320d2b5a --- /dev/null +++ b/packages/services/service-automation/src/oauth2-grant/grant-handle.test.ts @@ -0,0 +1,337 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D4 — the per-dispatch credential of an `oauth2` instance: which +// connection a request uses, when a token is refreshed (skew margin, once on a +// 401), single flight, the cluster lock, and how a refused grant reads. The +// connection store is a double here; `connector-connection-door.test.ts` runs +// the same handle over a real engine. The values below are obvious dummies. + +import { describe, expect, it, vi } from 'vitest'; +import { callWithBorrowedCredential, BORROWED_CREDENTIAL_SKEW_MS } from '@objectstack/types'; +import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration'; +import type { ILock } from '@objectstack/spec/contracts'; +import type { ConnectorConnectionRecord, ConnectorConnectionStore, ConnectorGrantWrite } from '../connector-connections.js'; +import { createOAuth2GrantHandle, type OAuth2GrantConfig } from './grant-handle.js'; + +const TOKEN_URL = 'https://idp.example.com/oauth/token'; + +/** The token endpoint double: records each request's form, answers from `next`. */ +function tokenEndpoint() { + const requests: URLSearchParams[] = []; + let n = 0; + const state = { + requests, + /** What the next answer is; default a fresh bearer token valid for an hour, with a rotated refresh token. */ + answer: (): { status: number; body: unknown } => ({ + status: 200, + body: { access_token: `dummy-at-${n}`, token_type: 'Bearer', expires_in: 3600, refresh_token: `dummy-rt-${n}` }, + }), + /** Resolve answers only when released, to hold requests in flight. */ + gate: undefined as Promise | undefined, + }; + const fetchImpl = (async (_url: string, init: RequestInit) => { + requests.push(new URLSearchParams(String(init.body))); + n += 1; + if (state.gate) await state.gate; + const { status, body } = state.answer(); + return new Response(JSON.stringify(body), { status }); + }) as unknown as typeof fetch; + return { state, fetch: fetchImpl }; +} + +/** An in-memory connection store with the semantics of `ConnectorConnectionStore`. */ +function fakeConnections(rows: Array<{ org: string; refreshToken?: string; status?: ConnectorConnectionRecord['status'] }>) { + const held = new Map(); + for (const r of rows) { + held.set(r.org, { + id: `row_${r.org}`, + organizationId: r.org, + connectorName: 'crm', + status: r.status ?? 'connected', + revision: 1, + hasRefreshToken: r.refreshToken !== undefined, + refreshToken: r.refreshToken, + }); + } + const strip = (r: ConnectorConnectionRecord & { refreshToken?: string }): ConnectorConnectionRecord => { + const { refreshToken: _omit, ...rest } = r; + return { ...rest }; + }; + const calls = { read: 0, markReconnectRequired: [] as string[], recordRefresh: [] as ConnectorGrantWrite[] }; + const store = { + async read(org: string) { + calls.read += 1; + const r = held.get(org); + return r ? strip(r) : undefined; + }, + async listFor() { + return [...held.values()].map(strip); + }, + async resolveRefreshToken(record: ConnectorConnectionRecord) { + return held.get(record.organizationId)?.refreshToken; + }, + async recordRefresh(record: ConnectorConnectionRecord, write: ConnectorGrantWrite) { + const r = held.get(record.organizationId); + if (!r || r.revision !== record.revision) return false; + calls.recordRefresh.push(write); + if (write.refreshToken !== undefined) { + r.refreshToken = write.refreshToken; + r.revision += 1; + } + return true; + }, + async markReconnectRequired(record: ConnectorConnectionRecord, reason: string) { + const r = held.get(record.organizationId); + if (!r || r.revision !== record.revision) return; + calls.markReconnectRequired.push(record.organizationId); + r.status = 'reconnect_required'; + r.statusReason = reason; + r.refreshToken = undefined; + r.hasRefreshToken = false; + }, + }; + return { store: store as unknown as ConnectorConnectionStore, held, calls }; +} + +const AUTH_CODE: OAuth2GrantConfig = { grant: 'authorization_code', tokenUrl: TOKEN_URL, clientId: 'client-1', clientSecret: 'dummy-client-secret' }; +const CLIENT_CREDENTIALS: OAuth2GrantConfig = { ...AUTH_CODE, grant: 'client_credentials', scopes: ['read'], resource: 'https://api.example.com' }; + +describe('ADR-0143 D4 — client_credentials', () => { + it('exchanges the client secret once, serves the cached token, and re-derives it inside the skew margin', async () => { + const idp = tokenEndpoint(); + let clock = 0; + const handle = createOAuth2GrantHandle({ + connector: 'crm', config: CLIENT_CREDENTIALS, connections: fakeConnections([]).store, fetch: idp.fetch, now: () => clock, + }); + const first = await handle.get(); + expect(first.auth).toEqual({ type: 'bearer', token: 'dummy-at-1' }); + expect(first.expiresAt).toBe(3_600_000); + expect(Object.fromEntries(idp.state.requests[0])).toEqual({ + grant_type: 'client_credentials', scope: 'read', resource: 'https://api.example.com', + }); + // An org on the request selects nothing: the token belongs to the instance. + expect((await handle.get({ organizationId: 'org_b' })).auth).toEqual(first.auth); + expect(idp.state.requests).toHaveLength(1); + + clock = 3_600_000 - BORROWED_CREDENTIAL_SKEW_MS + 1; // inside the margin + expect((await handle.get()).auth).toEqual({ type: 'bearer', token: 'dummy-at-2' }); + expect(idp.state.requests).toHaveLength(2); + }); + + it('single flight: concurrent requests share one token request', async () => { + const idp = tokenEndpoint(); + let release!: () => void; + idp.state.gate = new Promise((r) => { release = r; }); + const handle = createOAuth2GrantHandle({ connector: 'crm', config: CLIENT_CREDENTIALS, connections: fakeConnections([]).store, fetch: idp.fetch }); + const all = Promise.all([handle.get(), handle.get(), handle.refresh(), handle.get()]); + release(); + const got = await all; + expect(idp.state.requests).toHaveLength(1); + expect(new Set(got.map((g) => (g.auth as { token: string }).token))).toEqual(new Set(['dummy-at-1'])); + }); + + it('a 401 refreshes once and retries once; a second 401 is the 503 refusal naming the instance', async () => { + const idp = tokenEndpoint(); + const handle = createOAuth2GrantHandle({ connector: 'crm', config: CLIENT_CREDENTIALS, connections: fakeConnections([]).store, fetch: idp.fetch }); + const sent: string[] = []; + const accept = new Set(['dummy-at-2']); + const upstream = async (auth: { type: string; token?: string }) => { + sent.push(auth.token!); + return { status: accept.has(auth.token!) ? 200 : 401 }; + }; + expect(await callWithBorrowedCredential(handle, upstream as never)).toEqual({ status: 200 }); + expect(sent).toEqual(['dummy-at-1', 'dummy-at-2']); + + accept.clear(); + const refused = (await callWithBorrowedCredential(handle, upstream as never).catch((e: Error) => e)) as Error; + expect(refused).toMatchObject({ status: 503, code: 'SERVICE_UNAVAILABLE' }); + expect(String(refused.message)).toContain("connector instance 'crm'"); + expect(String(refused.message)).not.toContain('dummy-at'); + }); +}); + +describe('ADR-0143 D4 — authorization_code: which connection a request uses', () => { + it("a request names its organization, and gets that organization's connection", async () => { + const idp = tokenEndpoint(); + const { store } = fakeConnections([{ org: 'org_a', refreshToken: 'dummy-rt-a' }, { org: 'org_b', refreshToken: 'dummy-rt-b' }]); + const handle = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, fetch: idp.fetch }); + await handle.get({ organizationId: 'org_b' }); + expect(Object.fromEntries(idp.state.requests[0])).toEqual({ grant_type: 'refresh_token', refresh_token: 'dummy-rt-b' }); + }); + + it('a request with no organization uses the only connection, and is refused (422) when there are several', async () => { + const idp = tokenEndpoint(); + const one = createOAuth2GrantHandle({ + connector: 'crm', config: AUTH_CODE, connections: fakeConnections([{ org: 'org_a', refreshToken: 'dummy-rt-a' }]).store, fetch: idp.fetch, + }); + await one.get(); + expect(idp.state.requests[0].get('refresh_token')).toBe('dummy-rt-a'); + + const two = createOAuth2GrantHandle({ + connector: 'crm', + config: AUTH_CODE, + connections: fakeConnections([{ org: 'org_a', refreshToken: 'dummy-rt-a' }, { org: 'org_b', refreshToken: 'dummy-rt-b' }]).store, + fetch: idp.fetch, + }); + const refused = await two.get().catch((e) => e); + expect(refused).toMatchObject({ status: 422, code: 'VALIDATION_ERROR' }); + expect(refused.message).toContain('connected for 2 organizations'); + expect(idp.state.requests).toHaveLength(1); // nothing was spent on the refused one + }); + + it('not connected, or reconnect required → 503 naming the organization; no token request', async () => { + const idp = tokenEndpoint(); + const handle = createOAuth2GrantHandle({ + connector: 'crm', + config: AUTH_CODE, + connections: fakeConnections([{ org: 'org_r', status: 'reconnect_required' }]).store, + fetch: idp.fetch, + }); + await expect(handle.get({ organizationId: 'org_none' })).rejects.toMatchObject({ + status: 503, message: expect.stringContaining("is not connected for organization 'org_none'"), + }); + await expect(handle.get({ organizationId: 'org_r' })).rejects.toMatchObject({ + status: 503, message: expect.stringContaining("needs to be reconnected for organization 'org_r'"), + }); + expect(idp.state.requests).toHaveLength(0); + }); +}); + +describe('ADR-0143 D4 — authorization_code: refresh', () => { + it('a rotated refresh token is written over the revision it read, and the new token is cached under the new revision', async () => { + const idp = tokenEndpoint(); + const { store, held, calls } = fakeConnections([{ org: 'org_a', refreshToken: 'dummy-rt-a' }]); + const handle = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, fetch: idp.fetch, now: () => 0 }); + expect((await handle.get({ organizationId: 'org_a' })).auth).toEqual({ type: 'bearer', token: 'dummy-at-1' }); + expect(held.get('org_a')).toMatchObject({ refreshToken: 'dummy-rt-1', revision: 2 }); + expect(calls.recordRefresh[0]).toMatchObject({ refreshToken: 'dummy-rt-1', expiresAt: new Date(3_600_000) }); + // Served from the cache: the row's revision is the one cached. + await handle.get({ organizationId: 'org_a' }); + expect(idp.state.requests).toHaveLength(1); + // A 401's refresh spends the ROTATED token, not the first one. + await handle.refresh({ organizationId: 'org_a' }); + expect(idp.state.requests[1].get('refresh_token')).toBe('dummy-rt-1'); + }); + + it('a server that does not rotate keeps the stored refresh token and the revision', async () => { + const idp = tokenEndpoint(); + idp.state.answer = () => ({ status: 200, body: { access_token: 'dummy-at-x', token_type: 'Bearer', expires_in: 60 } }); + const { store, held } = fakeConnections([{ org: 'org_a', refreshToken: 'dummy-rt-a' }]); + const handle = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, fetch: idp.fetch }); + await handle.get({ organizationId: 'org_a' }); + expect(held.get('org_a')).toMatchObject({ refreshToken: 'dummy-rt-a', revision: 1 }); + await handle.get({ organizationId: 'org_a' }); + expect(idp.state.requests).toHaveLength(1); + }); + + it('single flight per organization: concurrent requests share one refresh, so a rotating grant is spent once', async () => { + const idp = tokenEndpoint(); + let release!: () => void; + idp.state.gate = new Promise((r) => { release = r; }); + const { store } = fakeConnections([{ org: 'org_a', refreshToken: 'dummy-rt-a' }, { org: 'org_b', refreshToken: 'dummy-rt-b' }]); + const handle = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, fetch: idp.fetch }); + const all = Promise.all([ + handle.get({ organizationId: 'org_a' }), + handle.refresh({ organizationId: 'org_a' }), + handle.get({ organizationId: 'org_a' }), + handle.get({ organizationId: 'org_b' }), + ]); + await vi.waitFor(() => expect(idp.state.requests).toHaveLength(2)); + release(); + await all; + expect(idp.state.requests.map((r) => r.get('refresh_token')).sort()).toEqual(['dummy-rt-a', 'dummy-rt-b']); + }); + + it('refreshes under the cluster lock and reads the row again inside it; a lock not obtained is UPSTREAM_UNAVAILABLE', async () => { + const idp = tokenEndpoint(); + const { store, held } = fakeConnections([{ org: 'org_a', refreshToken: 'dummy-rt-a' }]); + const keys: string[] = []; + let obtain = true; + const lock = { + async withLock(key: string, fn: () => Promise, opts: { ttlMs: number; waitMs: number }) { + keys.push(key); + expect(opts).toEqual({ ttlMs: 30_000, waitMs: 30_000 }); + if (!obtain) return null; + // Another replica rotated the token while this one waited. + held.get('org_a')!.refreshToken = 'dummy-rt-from-other-replica'; + return fn(); + }, + } as unknown as ILock; + const handle = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, lock: () => lock, fetch: idp.fetch }); + await handle.get({ organizationId: 'org_a' }); + expect(keys).toEqual(['connector-connection:crm:org_a:refresh']); + expect(idp.state.requests[0].get('refresh_token')).toBe('dummy-rt-from-other-replica'); + + obtain = false; + await expect(handle.refresh({ organizationId: 'org_a' })).rejects.toBeInstanceOf(ConnectorUpstreamUnavailableError); + expect(idp.state.requests).toHaveLength(1); + }); + + it('a row that moved while the request was out (a reconnect) caches nothing and is UPSTREAM_UNAVAILABLE', async () => { + const idp = tokenEndpoint(); + const { store, held } = fakeConnections([{ org: 'org_a', refreshToken: 'dummy-rt-a' }]); + const moving = (async (url: string, init: RequestInit) => { + held.get('org_a')!.revision = 7; + return idp.fetch(url as never, init); + }) as unknown as typeof fetch; + const handle = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, fetch: moving }); + await expect(handle.get({ organizationId: 'org_a' })).rejects.toBeInstanceOf(ConnectorUpstreamUnavailableError); + expect(held.get('org_a')).toMatchObject({ refreshToken: 'dummy-rt-a', revision: 7 }); + }); + + it('an unreachable token endpoint is UPSTREAM_UNAVAILABLE and leaves the connection connected', async () => { + const idp = tokenEndpoint(); + idp.state.answer = () => ({ status: 503, body: {} }); + const { store, held, calls } = fakeConnections([{ org: 'org_a', refreshToken: 'dummy-rt-a' }]); + const onGrantRefused = vi.fn(); + const handle = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, fetch: idp.fetch, onGrantRefused }); + await expect(handle.get({ organizationId: 'org_a' })).rejects.toBeInstanceOf(ConnectorUpstreamUnavailableError); + expect(held.get('org_a')?.status).toBe('connected'); + expect(calls.markReconnectRequired).toEqual([]); + expect(onGrantRefused).not.toHaveBeenCalled(); + }); + + it('`invalid_grant` marks the connection reconnect required, tells the plugin, and refuses the dispatch (503)', async () => { + const idp = tokenEndpoint(); + idp.state.answer = () => ({ status: 400, body: { error: 'invalid_grant', error_description: 'dummy-rt-a was revoked' } }); + const { store, held, calls } = fakeConnections([{ org: 'org_a', refreshToken: 'dummy-rt-a' }]); + const onGrantRefused = vi.fn(); + const handle = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, fetch: idp.fetch, onGrantRefused }); + const refused = await handle.get({ organizationId: 'org_a' }).catch((e) => e); + expect(refused).toMatchObject({ status: 503, code: 'SERVICE_UNAVAILABLE' }); + expect(refused.message).toContain('Reconnect required'); + expect(refused.message).not.toContain('dummy-rt-a'); + expect(calls.markReconnectRequired).toEqual(['org_a']); + expect(held.get('org_a')).toMatchObject({ status: 'reconnect_required', refreshToken: undefined }); + expect(onGrantRefused).toHaveBeenCalledWith('org_a'); + // The next dispatch reads the row: refused before any token request. + await expect(handle.get({ organizationId: 'org_a' })).rejects.toMatchObject({ status: 503 }); + expect(idp.state.requests).toHaveLength(1); + }); + + it('a connection with no refresh token cannot be renewed: reconnect required, no token request', async () => { + const idp = tokenEndpoint(); + const { store, calls } = fakeConnections([{ org: 'org_a' }]); + const handle = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, fetch: idp.fetch }); + await expect(handle.get({ organizationId: 'org_a' })).rejects.toMatchObject({ + status: 503, message: expect.stringContaining('issued no refresh token'), + }); + expect(calls.markReconnectRequired).toEqual(['org_a']); + expect(idp.state.requests).toHaveLength(0); + }); + + it('a seeded token (what the connect obtained) is served under its revision, and forgotten on revoke', async () => { + const idp = tokenEndpoint(); + const { store } = fakeConnections([{ org: 'org_a', refreshToken: 'dummy-rt-a' }]); + const handle = createOAuth2GrantHandle({ connector: 'crm', config: AUTH_CODE, connections: store, fetch: idp.fetch, now: () => 0 }); + handle.seed('org_a', 1, { accessToken: 'dummy-at-seeded', expiresAt: 3_600_000 }); + expect((await handle.get({ organizationId: 'org_a' })).auth).toEqual({ type: 'bearer', token: 'dummy-at-seeded' }); + expect(idp.state.requests).toHaveLength(0); + // A seed under a stale revision is not served. + handle.seed('org_a', 0, { accessToken: 'dummy-at-stale', expiresAt: 3_600_000 }); + expect((await handle.get({ organizationId: 'org_a' })).auth).toEqual({ type: 'bearer', token: 'dummy-at-1' }); + handle.forget('org_a'); + await handle.get({ organizationId: 'org_a' }); + expect(idp.state.requests).toHaveLength(2); + }); +}); diff --git a/packages/services/service-automation/src/oauth2-grant/grant-handle.ts b/packages/services/service-automation/src/oauth2-grant/grant-handle.ts new file mode 100644 index 00000000000..01a0882c667 --- /dev/null +++ b/packages/services/service-automation/src/oauth2-grant/grant-handle.ts @@ -0,0 +1,312 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # The per-dispatch credential of an `oauth2` instance (ADR-0143 D4) + * + * Implements the {@link ConnectorCredentialHandle} seam S0 typed on + * `ConnectorProviderContext.credentialHandle` (#28137): a provider asks it on + * every upstream request, and `callWithBorrowedCredential` (`@objectstack/types`) + * is the call rule — refresh before an expired token is sent (skew margin), + * refresh once on a `401` and retry once. + * + * Two grants, one shape each: + * + * - **`client_credentials`** — no connection record (D3): the token is + * exchanged from the client's own secret and re-derived whenever it + * expires. It belongs to the instance, so a run's organization does not + * select anything. + * - **`authorization_code`** — the connection of the run's organization + * (Q1 = A) in `sys_connector_connection`. A run with no organization uses + * the instance's only connection, or is refused naming the missing + * organization — never an arbitrary one (D4, last bullet). + * + * **Single flight** (D4): concurrent requests that need a new token share + * one token request — per instance for `client_credentials`, per organization + * for `authorization_code`. A refresh additionally runs under the cluster + * lock when the deployment has one, so two replicas never spend one rotating + * refresh token (an authorization server that detects reuse would revoke the + * whole grant). Inside the lock the row is read again, so a replica that waited + * refreshes with the token the first one stored. + * + * **Failure** follows D4's split: an unreachable or `5xx` token endpoint is + * `CONNECTOR_UPSTREAM_UNAVAILABLE` (thrown by `requestOAuth2Token`); a refused + * grant marks the connection `reconnect_required`, tells the plugin (which + * re-judges the instance's state), and refuses the dispatch "reconnect + * required". + * + * ⛔ Access tokens live in this handle's memory only. A value leaves it only as + * the return of `get` / `refresh`; every refusal names the instance and the + * organization, never a token. + */ + +import { + BORROWED_CREDENTIAL_SKEW_MS, + connectorCredentialRefusal, + type BorrowedConnectorCredential, + type ConnectorCredentialHandle, + type ConnectorCredentialRequest, +} from '@objectstack/types'; +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 OAuth2TokenResponse } from './token-endpoint.js'; + +/** What an `oauth2` instance's handle needs from its declaration, already judged servable. */ +export interface OAuth2GrantConfig { + readonly grant: 'authorization_code' | 'client_credentials'; + readonly tokenUrl: string; + readonly clientId: string; + /** The resolved client secret; absent for a public `authorization_code` client. */ + readonly clientSecret?: string; + readonly scopes?: readonly string[]; + readonly resource?: string; +} + +export interface OAuth2GrantHandleDeps { + /** The connector instance — named in every refusal. */ + readonly connector: string; + readonly config: OAuth2GrantConfig; + /** The connection store (`authorization_code` only). */ + readonly connections: ConnectorConnectionStore; + /** The cluster lock, when the deployment has a cluster service. Read at refresh time. */ + readonly lock?: () => ILock | undefined; + /** + * Told when the authorization server refused an organization's grant and + * the connection was marked `reconnect_required`, so the plugin re-judges + * the instance. A throw here is the listener's own. + */ + readonly onGrantRefused?: (organizationId: string) => void; + readonly timeoutMs?: number; + readonly fetch?: typeof fetch; + readonly now?: () => number; +} + +/** The handle, plus what the connect door needs to hand it a token it just obtained. */ +export interface OAuth2GrantHandle extends ConnectorCredentialHandle { + /** Keep the access token a completed connect obtained for `organizationId`, under the connection's `revision`. */ + seed(organizationId: string, revision: number, token: OAuth2TokenResponse): void; + /** Drop every cached access token (a revoke, a reconnect, the instance torn down). */ + forget(organizationId?: string): void; +} + +/** How long a refresh may hold the connection's cluster lock, and how long another replica waits for it. */ +export const OAUTH2_REFRESH_LOCK_TTL_MS = 30_000; + +interface CachedToken { + readonly revision: number; + readonly accessToken: string; + readonly expiresAt?: number; +} + +const toCredential = (token: CachedToken): BorrowedConnectorCredential => ({ + auth: { type: 'bearer', token: token.accessToken }, + ...(token.expiresAt === undefined ? {} : { expiresAt: token.expiresAt }), +}); + +const unavailable = (message: string): Error => connectorCredentialRefusal(message, 'SERVICE_UNAVAILABLE', 503); + +/** The dispatch refusal of an organization whose connection does not exist. */ +export function notConnectedReason(connector: string, organizationId?: string): string { + return `connector instance '${connector}' is not connected${organizationId ? ` for organization '${organizationId}'` : ''}: ` + + 'an administrator connects it (POST /api/v1/automation/connectors/' + connector + '/connection), and the next ' + + 'dispatch uses that connection (ADR-0143 D4)'; +} + +/** The dispatch refusal of an organization whose grant the authorization server refused. */ +export function reconnectRequiredReason(connector: string, organizationId: string): string { + return `connector instance '${connector}' needs to be reconnected for organization '${organizationId}': its authorization ` + + 'server refused the grant (revoked, or expired with no usable refresh). Reconnect required: an administrator connects ' + + 'it again, and the next dispatch uses the new connection (ADR-0143 D4)'; +} + +/** Create the handle of one `oauth2` instance. */ +export function createOAuth2GrantHandle(deps: OAuth2GrantHandleDeps): OAuth2GrantHandle { + const { connector, config } = deps; + const now = deps.now ?? Date.now; + const fresh = (token: CachedToken | undefined): token is CachedToken => + token !== undefined && (token.expiresAt === undefined || token.expiresAt - BORROWED_CREDENTIAL_SKEW_MS > now()); + const client = { clientId: config.clientId, ...(config.clientSecret === undefined ? {} : { clientSecret: config.clientSecret }) }; + const tokenRequest = (params: Record) => requestOAuth2Token({ + connector, + tokenUrl: config.tokenUrl, + client, + params, + ...(deps.timeoutMs === undefined ? {} : { timeoutMs: deps.timeoutMs }), + ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }), + now, + }); + + if (config.grant === 'client_credentials') { + let held: CachedToken | undefined; + let inflight: Promise | undefined; + const fill = (): Promise => { + inflight ??= (async () => { + const params: Record = { grant_type: 'client_credentials' }; + if (config.scopes && config.scopes.length > 0) params.scope = config.scopes.join(' '); + if (config.resource) params.resource = config.resource; + const token = await tokenRequest(params); + held = { revision: 0, accessToken: token.accessToken, ...(token.expiresAt === undefined ? {} : { expiresAt: token.expiresAt }) }; + return held; + })().finally(() => { inflight = undefined; }); + return inflight; + }; + return { + connector, + async get() { + return toCredential(fresh(held) ? held : await fill()); + }, + async refresh() { + held = undefined; + return toCredential(await fill()); + }, + seed() { /* a client_credentials instance is never connected */ }, + forget() { held = undefined; }, + }; + } + + // authorization_code + const cache = new Map(); + const inflight = new Map>(); + + /** The connection a request dispatches with (D4, Q1 = A). */ + const select = async (request?: ConnectorCredentialRequest): Promise => { + const organizationId = request?.organizationId; + let record: ConnectorConnectionRecord | undefined; + try { + if (organizationId !== undefined) { + record = await deps.connections.read(organizationId, connector); + } else { + const all = await deps.connections.listFor(connector); + if (all.length > 1) { + throw connectorCredentialRefusal( + `connector instance '${connector}' is connected for ${all.length} organizations, and this dispatch carries ` + + 'no organization to choose one by: run it in the organization whose connection it should use. A ' + + 'connection is never chosen for a run (ADR-0143 D4)', + 'VALIDATION_ERROR', + 422, + ); + } + record = all[0]; + } + } catch (err) { + if ((err as { status?: unknown })?.status === 422) throw err; + throw unavailable(`the connection of connector instance '${connector}' could not be read now; the next dispatch retries`); + } + if (!record) throw unavailable(notConnectedReason(connector, organizationId)); + if (record.status === 'reconnect_required') throw unavailable(reconnectRequiredReason(connector, record.organizationId)); + return record; + }; + + /** Mark the grant gone, tell the plugin, and answer the dispatch's refusal. */ + const grantRefused = async (record: ConnectorConnectionRecord, reason: string): Promise => { + cache.delete(record.organizationId); + try { + await deps.connections.markReconnectRequired(record, reason); + } catch { + /* the refusal stands either way; the row is re-judged on the next dispatch */ + } + try { + deps.onGrantRefused?.(record.organizationId); + } catch { + /* the listener's throw is its own */ + } + return unavailable(reason); + }; + + /** One refresh of `organizationId`'s connection, read again under the lock. */ + const refreshOnce = async (organizationId: string): Promise => { + const current = await deps.connections.read(organizationId, connector); + if (!current) throw unavailable(notConnectedReason(connector, organizationId)); + if (current.status === 'reconnect_required') throw unavailable(reconnectRequiredReason(connector, organizationId)); + let refreshToken: string | undefined; + try { + refreshToken = await deps.connections.resolveRefreshToken(current); + } catch { + throw unavailable(`the connection of connector instance '${connector}' for organization '${organizationId}' could not be ` + + 'read back now; the next dispatch retries'); + } + if (refreshToken === undefined) { + throw await grantRefused(current, reconnectRequiredReason(connector, organizationId) + + '. Its authorization server issued no refresh token, so an expired access token cannot be renewed'); + } + const params: Record = { grant_type: 'refresh_token', refresh_token: refreshToken }; + if (config.resource) params.resource = config.resource; + let token: OAuth2TokenResponse; + try { + token = await tokenRequest(params); + } catch (err) { + if (err instanceof OAuth2GrantRefusedError) throw await grantRefused(current, reconnectRequiredReason(connector, organizationId)); + throw err; + } + const written = await deps.connections.recordRefresh(current, { + ...(token.refreshToken === undefined ? {} : { refreshToken: token.refreshToken }), + ...(token.scope === undefined ? {} : { scopes: token.scope }), + ...(token.expiresAt === undefined ? {} : { expiresAt: new Date(token.expiresAt) }), + }); + if (!written) { + // A reconnect or revoke moved the row while the request was out: + // what came back belongs to a grant that is no longer this one. + throw new ConnectorUpstreamUnavailableError( + `the connection of connector instance '${connector}' for organization '${organizationId}' changed while it was ` + + 'being refreshed; the next dispatch uses the new one', + ); + } + const cached: CachedToken = { + revision: token.refreshToken === undefined ? current.revision : current.revision + 1, + accessToken: token.accessToken, + ...(token.expiresAt === undefined ? {} : { expiresAt: token.expiresAt }), + }; + cache.set(organizationId, cached); + return cached; + }; + + const refreshFor = (organizationId: string): Promise => { + let flight = inflight.get(organizationId); + if (!flight) { + flight = (async () => { + const lock = deps.lock?.(); + if (!lock) return refreshOnce(organizationId); + const result = await lock.withLock( + `connector-connection:${connector}:${organizationId}:refresh`, + () => refreshOnce(organizationId), + { ttlMs: OAUTH2_REFRESH_LOCK_TTL_MS, waitMs: OAUTH2_REFRESH_LOCK_TTL_MS }, + ); + if (result === null) { + throw new ConnectorUpstreamUnavailableError( + `the connection of connector instance '${connector}' for organization '${organizationId}' is being refreshed ` + + 'by another replica; the next dispatch retries', + ); + } + return result; + })().finally(() => { inflight.delete(organizationId); }); + inflight.set(organizationId, flight); + } + return flight; + }; + + return { + connector, + async get(request) { + const record = await select(request); + const held = cache.get(record.organizationId); + if (fresh(held) && held.revision === record.revision) return toCredential(held); + return toCredential(await refreshFor(record.organizationId)); + }, + async refresh(request) { + const record = await select(request); + cache.delete(record.organizationId); + return toCredential(await refreshFor(record.organizationId)); + }, + seed(organizationId, revision, token) { + cache.set(organizationId, { + revision, + accessToken: token.accessToken, + ...(token.expiresAt === undefined ? {} : { expiresAt: token.expiresAt }), + }); + }, + forget(organizationId) { + if (organizationId === undefined) cache.clear(); + else cache.delete(organizationId); + }, + }; +} diff --git a/packages/services/service-automation/src/oauth2-grant/index.ts b/packages/services/service-automation/src/oauth2-grant/index.ts new file mode 100644 index 00000000000..94946a0ab73 --- /dev/null +++ b/packages/services/service-automation/src/oauth2-grant/index.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `oauth2` instance kind's runtime (ADR-0143 D4, D5): the token endpoint + * call, the per-dispatch credential handle, and the connect flow's PKCE and + * pending state. The connection record they read and write is + * `../connector-connections.ts`. + */ + +export * from './token-endpoint.js'; +export * from './grant-handle.js'; +export * from './authorization.js'; +export * from './endpoint-transport.js'; diff --git a/packages/services/service-automation/src/oauth2-grant/token-endpoint.test.ts b/packages/services/service-automation/src/oauth2-grant/token-endpoint.test.ts new file mode 100644 index 00000000000..be7e2aa0ea5 --- /dev/null +++ b/packages/services/service-automation/src/oauth2-grant/token-endpoint.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D4 — the one token request a grant-bearing instance sends: client +// authentication, the four ways it fails, and the server text it never carries. +// The values below are obvious dummies. + +import { describe, expect, it } from 'vitest'; +import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration'; +import { + OAuth2ClientRefusedError, + OAuth2GrantRefusedError, + OAuth2TokenResponseError, + requestOAuth2Token, +} from './token-endpoint.js'; + +const TOKEN_URL = 'https://idp.example.com/oauth/token'; + +interface Sent { + url: string; + headers: Record; + body: URLSearchParams; +} + +/** A fetch that records what was sent and answers `status` with `body` as JSON. */ +function answering(status: number, body: unknown, sent: Sent[] = []): typeof fetch { + return (async (url: string, init: RequestInit) => { + sent.push({ url, headers: init.headers as Record, body: new URLSearchParams(String(init.body)) }); + return new Response(body === undefined ? 'not json' : JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + }) as unknown as typeof fetch; +} + +const request = (fetchImpl: typeof fetch, over: Partial[0]> = {}) => requestOAuth2Token({ + connector: 'crm', + tokenUrl: TOKEN_URL, + client: { clientId: 'client 1', clientSecret: 'dummy:secret&x' }, + params: { grant_type: 'client_credentials', scope: 'read write' }, + fetch: fetchImpl, + ...over, +}); + +describe('ADR-0143 D4 — the token request', () => { + it('authenticates a confidential client with HTTP Basic, each half form-encoded first (RFC 6749 §2.3.1)', async () => { + const sent: Sent[] = []; + await request(answering(200, { access_token: 'dummy-at', token_type: 'Bearer' }, sent)); + expect(sent).toHaveLength(1); + expect(sent[0].url).toBe(TOKEN_URL); + expect(sent[0].headers['content-type']).toBe('application/x-www-form-urlencoded'); + // `client 1` → `client+1`, `dummy:secret&x` → `dummy%3Asecret%26x`, so the + // colon inside the secret cannot split the pair. + expect(sent[0].headers.authorization).toBe(`Basic ${Buffer.from('client+1:dummy%3Asecret%26x').toString('base64')}`); + expect(sent[0].body.get('client_id')).toBeNull(); + expect(sent[0].body.get('grant_type')).toBe('client_credentials'); + expect(sent[0].body.get('scope')).toBe('read write'); + }); + + it('a public client sends its client_id in the body and no Authorization header (§3.2.1)', async () => { + const sent: Sent[] = []; + await request(answering(200, { access_token: 'dummy-at', token_type: 'bearer' }, sent), { client: { clientId: 'pub' } }); + expect(sent[0].headers.authorization).toBeUndefined(); + expect(sent[0].body.get('client_id')).toBe('pub'); + }); + + it('reads the answer: expiry measured from when the request was sent, a rotated refresh token, the granted scope', async () => { + let clock = 1_000_000; + const slow = (async (url: string, init: RequestInit) => { + clock += 5_000; // the network took five seconds + return answering(200, { access_token: 'dummy-at', token_type: 'Bearer', expires_in: 3600, refresh_token: 'dummy-rt', scope: 'read' })(url, init); + }) as unknown as typeof fetch; + const token = await request(slow, { now: () => clock }); + expect(token).toEqual({ accessToken: 'dummy-at', expiresAt: 1_000_000 + 3_600_000, refreshToken: 'dummy-rt', scope: 'read' }); + }); + + it('an `expires_in` sent as a string is read; a missing one leaves the expiry unknown', async () => { + const a = await request(answering(200, { access_token: 'a1', token_type: 'Bearer', expires_in: '60' }), { now: () => 0 }); + expect(a.expiresAt).toBe(60_000); + const b = await request(answering(200, { access_token: 'a2', token_type: 'Bearer' })); + expect(b).toEqual({ accessToken: 'a2' }); + }); +}); + +describe('ADR-0143 D4 — how a token request fails', () => { + it('no answer → CONNECTOR_UPSTREAM_UNAVAILABLE', async () => { + const down = (async () => { throw new TypeError('fetch failed'); }) as unknown as typeof fetch; + await expect(request(down)).rejects.toBeInstanceOf(ConnectorUpstreamUnavailableError); + }); + + it.each([429, 500, 503])('HTTP %i → CONNECTOR_UPSTREAM_UNAVAILABLE, even with an error body', async (status) => { + await expect(request(answering(status, { error: 'invalid_grant' }))).rejects.toBeInstanceOf(ConnectorUpstreamUnavailableError); + }); + + it('`invalid_grant` → the grant is refused (only a reconnect brings it back)', async () => { + await expect(request(answering(400, { error: 'invalid_grant' }))).rejects.toBeInstanceOf(OAuth2GrantRefusedError); + }); + + it.each([ + [400, { error: 'invalid_client' }], + [400, { error: 'unauthorized_client' }], + [401, undefined], + ])('HTTP %i %j → the client is refused', async (status, body) => { + await expect(request(answering(status, body))).rejects.toBeInstanceOf(OAuth2ClientRefusedError); + }); + + it('any other refusal → a 503 SERVICE_UNAVAILABLE naming the registered code', async () => { + await expect(request(answering(400, { error: 'invalid_scope' }))).rejects.toThrow(/\(invalid_scope\)/); + await expect(request(answering(400, { error: 'invalid_scope' }))).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + await expect(request(answering(400, { error: 'invalid_client' }))).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + await expect(request(answering(400, { error: 'invalid_scope' }))).rejects.toBeInstanceOf(OAuth2TokenResponseError); + }); + + it('a 2xx without an access_token, or with a token that is not a bearer token, is refused', async () => { + await expect(request(answering(200, { token_type: 'Bearer' }))).rejects.toThrow(/without an access_token/); + await expect(request(answering(200, { access_token: 'dummy-at', token_type: 'mac' }))).rejects.toThrow(/not a bearer token/); + await expect(request(answering(200, { access_token: 'dummy-at' }))).rejects.toThrow(/not a bearer token/); + }); + + it("⛔ never carries the authorization server's own text: an unregistered code or a description echoing a secret", async () => { + const echo = { error: 'dummy-echo-of-secret-9387', error_description: 'your secret dummy:secret&x is wrong' }; + for (const status of [400, 403]) { + const err = await request(answering(status, echo)).catch((e: Error) => e); + expect(err).toBeInstanceOf(OAuth2TokenResponseError); + expect((err as Error).message).toContain(`HTTP ${status}`); + expect((err as Error).message).not.toContain('dummy-echo-of-secret-9387'); + expect((err as Error).message).not.toContain('dummy:secret'); + } + const upstream = await request(answering(503, echo)).catch((e: Error) => e); + expect((upstream as Error).message).not.toContain('dummy'); + }); +}); diff --git a/packages/services/service-automation/src/oauth2-grant/token-endpoint.ts b/packages/services/service-automation/src/oauth2-grant/token-endpoint.ts new file mode 100644 index 00000000000..612e8043310 --- /dev/null +++ b/packages/services/service-automation/src/oauth2-grant/token-endpoint.ts @@ -0,0 +1,225 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # The OAuth2 token endpoint call (RFC 6749 §3.2, ADR-0143 D4) + * + * The one place a grant-bearing instance's token request is sent: a + * `client_credentials` exchange (§4.4), an authorization code exchange + * (§4.1.3, with the PKCE verifier, RFC 7636 §4.5) and a refresh (§6). + * + * **Client authentication** (S0 left the method to this slice; #28137): a + * confidential client authenticates with HTTP Basic (`client_secret_basic`, + * §2.3.1 — the method every authorization server MUST support), its id and + * secret form-encoded first as §2.3.1 requires. A public client has no secret + * and sends its `client_id` in the body (§3.2.1). + * + * **How a failure reads** — ADR-0143 D4's split between operation and grant: + * - no answer, a timeout, `429` or a `5xx` → {@link ConnectorUpstreamUnavailableError} + * (`CONNECTOR_UPSTREAM_UNAVAILABLE`), retried on the existing backoff; + * - `invalid_grant` (§5.2: the code or refresh token is invalid, expired or + * revoked) → {@link OAuth2GrantRefusedError}, which never leaves the + * service: the grant handle and the connect door turn it into their own + * refusal ("reconnect required", "the code was refused"); + * - `invalid_client` / `unauthorized_client` → {@link OAuth2ClientRefusedError}: + * the client id or secret is wrong, which the instance's configuration fixes; + * - anything else, or a `2xx` without a bearer `access_token` → + * {@link OAuth2TokenResponseError}. + * + * The last two carry the connector seam's `503 SERVICE_UNAVAILABLE` (the + * shape `connectorCredentialRefusal` stamps), a standard code: no code of + * this module's own reaches a dispatch. + * + * ⛔ The authorization server's own text is never carried: a token endpoint + * that echoes a secret or token back would carry it into a degraded reason, a + * log line or a refusal. A refusal names the instance and, at most, the + * registered `error` code of §5.2. + */ + +import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration'; + +/** A token endpoint answer, reduced to what the grant keeps. */ +export interface OAuth2TokenResponse { + /** The access token — cached in process, never persisted (D3). */ + readonly accessToken: string; + /** When it expires, in epoch ms; absent when the server did not say (§5.1 `expires_in` is optional). */ + readonly expiresAt?: number; + /** A refresh token, when the server issued or rotated one. */ + readonly refreshToken?: string; + /** The granted scope, when the server said (§5.1). */ + readonly scope?: string; +} + +/** The client that authenticates at the token endpoint. */ +export interface OAuth2Client { + readonly clientId: string; + /** Absent for a public client. */ + readonly clientSecret?: string; +} + +/** One token request. */ +export interface OAuth2TokenRequest { + /** The connector instance, named in every refusal. */ + readonly connector: string; + readonly tokenUrl: string; + readonly client: OAuth2Client; + /** The grant's own parameters (`grant_type` and the rest), form-encoded as given. */ + readonly params: Readonly>; + /** Per-request deadline in ms (the instance's `requestTimeoutMs`, else {@link DEFAULT_TOKEN_TIMEOUT_MS}). */ + readonly timeoutMs?: number; + /** The transport, for tests. Default the global `fetch`. */ + readonly fetch?: typeof fetch; + /** The clock, for tests. Default `Date.now`. */ + readonly now?: () => number; +} + +/** The token request deadline when the instance declares none. */ +export const DEFAULT_TOKEN_TIMEOUT_MS = 15_000; + +/** The `error` codes RFC 6749 §5.2 registers — the only server text a refusal may name. */ +const REGISTERED_TOKEN_ERRORS = new Set([ + 'invalid_request', 'invalid_client', 'invalid_grant', 'unauthorized_client', 'unsupported_grant_type', 'invalid_scope', +]); + +/** The authorization server refused the grant itself: revoked, expired, or a code already spent (§5.2 `invalid_grant`). */ +export class OAuth2GrantRefusedError extends Error { + constructor(connector: string) { + super( + `the authorization server of connector instance '${connector}' refused its grant (invalid_grant): it was revoked, ` + + 'or expired with no usable refresh', + ); + this.name = 'OAuth2GrantRefusedError'; + } +} + +/** + * The authorization server refused the client (§5.2 `invalid_client` / + * `unauthorized_client`). A dispatch it ends reads as the connector seam's + * `503 SERVICE_UNAVAILABLE`: the instance's configuration is wrong, which the + * caller cannot change. + */ +export class OAuth2ClientRefusedError extends Error { + readonly code = 'SERVICE_UNAVAILABLE'; + readonly status = 503; + readonly statusCode = 503; + constructor(connector: string, error: string) { + super( + `the authorization server of connector instance '${connector}' refused its client (${error}): check the instance's ` + + 'clientId and the client secret its credentialRef names', + ); + this.name = 'OAuth2ClientRefusedError'; + } +} + +/** Any other refusal, or an answer that is not a usable bearer token. A dispatch reads it as `503 SERVICE_UNAVAILABLE`, like the client's refusal. */ +export class OAuth2TokenResponseError extends Error { + readonly code = 'SERVICE_UNAVAILABLE'; + readonly status = 503; + readonly statusCode = 503; + constructor(message: string) { + super(message); + this.name = 'OAuth2TokenResponseError'; + } +} + +/** RFC 6749 §2.3.1 / Appendix B: each half form-encoded before the Basic pair is built. */ +function basicClientAuthorization(client: OAuth2Client & { clientSecret: string }): string { + const encode = (v: string) => new URLSearchParams({ v }).toString().slice(2); + return `Basic ${Buffer.from(`${encode(client.clientId)}:${encode(client.clientSecret)}`, 'utf8').toString('base64')}`; +} + +/** The §5.2 `error` member, when it is a registered code. */ +function registeredError(body: unknown): string | undefined { + const error = (body as { error?: unknown } | null)?.error; + return typeof error === 'string' && REGISTERED_TOKEN_ERRORS.has(error) ? error : undefined; +} + +/** + * Send one token request and read its answer. Throws one of the four errors + * the module header names; never returns a partial answer. + */ +export async function requestOAuth2Token(request: OAuth2TokenRequest): Promise { + const fetchImpl = request.fetch ?? fetch; + const now = request.now ?? Date.now; + const { connector, client } = request; + const body = new URLSearchParams(request.params); + const headers: Record = { + 'content-type': 'application/x-www-form-urlencoded', + accept: 'application/json', + }; + if (client.clientSecret !== undefined && client.clientSecret.length > 0) { + headers.authorization = basicClientAuthorization({ clientId: client.clientId, clientSecret: client.clientSecret }); + } else { + body.set('client_id', client.clientId); + } + + const sentAt = now(); + let response: Response; + try { + response = await fetchImpl(request.tokenUrl, { + method: 'POST', + headers, + body: body.toString(), + signal: AbortSignal.timeout(request.timeoutMs ?? DEFAULT_TOKEN_TIMEOUT_MS), + }); + } catch (err) { + throw new ConnectorUpstreamUnavailableError( + `the token endpoint of connector instance '${connector}' did not answer`, + { cause: err }, + ); + } + + let parsed: unknown; + try { + parsed = await response.json(); + } catch { + parsed = undefined; + } + + if (response.status === 429 || response.status >= 500) { + throw new ConnectorUpstreamUnavailableError( + `the token endpoint of connector instance '${connector}' answered HTTP ${response.status}`, + ); + } + if (!response.ok) { + const error = registeredError(parsed); + if (error === 'invalid_grant') throw new OAuth2GrantRefusedError(connector); + if (error === 'invalid_client' || error === 'unauthorized_client' || response.status === 401) { + throw new OAuth2ClientRefusedError(connector, error ?? `HTTP ${response.status}`); + } + throw new OAuth2TokenResponseError( + `the token endpoint of connector instance '${connector}' refused the token request ` + + `(${error ?? `HTTP ${response.status}`})`, + ); + } + + const answer = (parsed ?? {}) as { + access_token?: unknown; + token_type?: unknown; + expires_in?: unknown; + refresh_token?: unknown; + scope?: unknown; + }; + if (typeof answer.access_token !== 'string' || answer.access_token.length === 0) { + throw new OAuth2TokenResponseError( + `the token endpoint of connector instance '${connector}' answered without an access_token`, + ); + } + // §7.1: the type decides how the token is presented. Only a bearer token + // (RFC 6750) is one a provider can send; the comparison is case-insensitive. + if (typeof answer.token_type !== 'string' || answer.token_type.toLowerCase() !== 'bearer') { + throw new OAuth2TokenResponseError( + `the token endpoint of connector instance '${connector}' answered a token that is not a bearer token`, + ); + } + const expiresIn = typeof answer.expires_in === 'string' ? Number(answer.expires_in) : answer.expires_in; + return { + accessToken: answer.access_token, + ...(typeof expiresIn === 'number' && Number.isFinite(expiresIn) && expiresIn > 0 + // Measured from when the request was SENT, so the network time + // shortens the token's life rather than lengthening it. + ? { expiresAt: sentAt + expiresIn * 1000 } + : {}), + ...(typeof answer.refresh_token === 'string' && answer.refresh_token.length > 0 ? { refreshToken: answer.refresh_token } : {}), + ...(typeof answer.scope === 'string' && answer.scope.length > 0 ? { scope: answer.scope } : {}), + }; +} diff --git a/packages/services/service-automation/src/plugin-extension-api.ts b/packages/services/service-automation/src/plugin-extension-api.ts index bebdfc2a8ec..0ebd81c66b8 100644 --- a/packages/services/service-automation/src/plugin-extension-api.ts +++ b/packages/services/service-automation/src/plugin-extension-api.ts @@ -399,7 +399,9 @@ export function registerDegradedConnector( * degrade record and the `connector_action` refusal. */ export function describeHold(hold: ConnectorHold): string { - return hold.kind === 'credential' ? `credential '${hold.ref}' is set again` : 'its declaration is edited'; + if (hold.kind === 'credential') return `credential '${hold.ref}' is set again`; + if (hold.kind === 'connection') return 'an administrator connects it'; + return 'its declaration is edited'; } /** The body of {@link AutomationEngine.assertSameOriginOrFree}, moved unedited; that method's docblock is its contract. */ diff --git a/packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts b/packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts index bb44adb5a7a..959d8e3f624 100644 --- a/packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts +++ b/packages/services/service-automation/src/plugin-suspended-run-wiring.test.ts @@ -256,8 +256,9 @@ describe('durable suspended-run wiring (#4420)', () => { // degradation — it must not register the run objects nor complain. // [#20790] The one object it does register is the write-only flow // credential channel's, which every composition needs — [#27237] with - // the connector credential store beside it, for the same reason. - expect(h.registeredObjects()).toEqual(['sys_flow_credential', 'sys_connector_credential']); + // the connector credential store beside it, for the same reason, and + // [ADR-0143 D3] the connector connection record beside that. + expect(h.registeredObjects()).toEqual(['sys_flow_credential', 'sys_connector_credential', 'sys_connector_connection']); expect((h.engine as any).store).toBeUndefined(); expect(h.errors()).toBe(''); }); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 58cfaf79a26..5eafd9a535b 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -2,7 +2,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { effectiveTenancyPosture, isServiceNotRegisteredError, resolveLocalizationContext, resolveUserAuthzGrants } from '@objectstack/core'; -import type { IJobService, IPubSub } from '@objectstack/spec/contracts'; +import type { IClusterService, IJobService, IPubSub } from '@objectstack/spec/contracts'; import type { FlowFunctionEffect } from '@objectstack/spec/automation'; import type { Connector, @@ -28,9 +28,14 @@ import { SysAutomationRun } from './sys-automation-run.object.js'; import { SysFlowDispatch } from './sys-flow-dispatch.object.js'; import { SysFlowCredential } from './sys-flow-credential.object.js'; import { SysConnectorCredential } from './sys-connector-credential.object.js'; +import { SysConnectorConnection } from './sys-connector-connection.object.js'; import type { ConnectorCredentialRef, ConnectorCredentialSetRequest, + ConnectorConnectionBegin, + ConnectorConnectionCallbackRequest, + ConnectorConnectionRequest, + ConnectorConnectionState, ConnectorCredentialClearRequest, } from '@objectstack/spec/contracts'; import { @@ -60,6 +65,23 @@ import { import { CONNECTOR_CREDENTIAL_SOURCE_SERVICE } from '@objectstack/types'; import { createConnectorCredentialSource } from './connector-credential-source.js'; import { createConnectorRequestSigner } from './request-signing/index.js'; +import { ConnectorConnectionStore, type ConnectorConnectionRecord } from './connector-connections.js'; +import { + buildAuthorizationUrl, + createAuthorizationState, + createOAuth2GrantHandle, + createPkcePair, + insecureOAuth2EndpointFields, + kvPendingAuthorizationStore, + memoryPendingAuthorizationStore, + notConnectedReason, + OAuth2GrantRefusedError, + PENDING_AUTHORIZATION_TTL_SECONDS, + reconnectRequiredReason, + requestOAuth2Token, + type OAuth2GrantHandle, + type PendingAuthorizationStore, +} from './oauth2-grant/index.js'; import { CONNECTOR_CREDENTIALS_UI_BUNDLE } from './connector-credentials-ui.js'; import { FlowCredentialChannel, @@ -223,6 +245,15 @@ export interface AutomationServicePluginOptions { * as unavailable (`503`), never verifies it. */ credentialResolver?: CredentialResolver; + /** + * ADR-0143 D5 — the redirect URI an `authorization_code` connect names, + * fixed per deployment and never taken from a request: the absolute URL + * at which this deployment serves `GET /api/v1/automation/connectors/oauth/callback`. + * Absent, it is that path under the deployment's public origin + * (`OS_AUTH_URL`, then `OS_BASE_URL`); with neither, a connect is refused + * `503` naming them. Register this exact URI with each vendor's OAuth client. + */ + connectorOAuthRedirectUri?: string; /** * Root directory of the stack/package whose metadata this kernel serves — * the base that relative file refs in declarative connector entries resolve @@ -405,20 +436,68 @@ function basicUsernameOf(auth: ConnectorInstanceAuth | undefined): string | unde /** The grant-bearing instance auth kinds (ADR-0143 D2.1, D2.2): resolved per dispatch, never once at materialization. */ type GrantBearingInstanceAuth = Extract; +/** The `oauth2` instance auth kind (ADR-0143 D2.1). */ +type OAuth2InstanceAuth = Extract; + /** - * ADR-0143 D4 — why a grant-bearing instance is not served by this server. - * The per-dispatch resolution these kinds need is not here yet, so the - * reconcile holds such an instance degraded with this reason rather than - * materializing it with no credential: it is never called upstream - * unauthenticated. + * ADR-0143 D2.2 — why an `app-credential` instance is not served yet. Its + * vendor exchanges (Feishu, DingTalk, WeCom) belong to the providers slice's + * shared transport, so the reconcile holds it degraded with this reason + * rather than materializing it with no credential: it is never called + * upstream unauthenticated. */ -function grantBearingNotServedReason(name: string, auth: GrantBearingInstanceAuth): string { - return `connector instance '${name}' declares auth type '${auth.type}', a grant-bearing kind whose token is obtained ` - + 'and refreshed per dispatch (ADR-0143 D4). This server does not run that resolution yet, so the instance is ' - + 'not served and is never called without its credential. Declare a static kind (bearer, api-key or basic) ' - + 'to dispatch it now.'; +function appCredentialNotServedReason(name: string, auth: Extract): string { + return `connector instance '${name}' declares auth type 'app-credential' (vendor '${auth.vendor}'), whose token exchange ` + + 'this server does not run yet (ADR-0143 D2.2), so the instance is not served and is never called without its ' + + 'credential. Declare a static kind (bearer, api-key or basic), or oauth2, to dispatch it now.'; } +/** + * ADR-0143 D4 — why an `oauth2` instance cannot be served by this server + * through `provider`, or `undefined` when it can. Two things are needed: a + * provider registered as asking the per-dispatch credential handle on every + * request (`registerConnectorProvider(…, { credentialHandle: true })`), and + * the endpoints and client the handle sends a token request with — on + * `provider: 'mcp'` over http they may be omitted for the MCP authorization + * flow to discover and register, which is not run here yet. 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. + */ +function oauth2NotServableReason(name: string, provider: string, auth: OAuth2InstanceAuth, providerApplies: boolean): string | undefined { + if (!providerApplies) { + return `connector instance '${name}' declares auth type 'oauth2', but provider '${provider}' does not yet ask the ` + + 'per-dispatch credential handle a grant-bearing instance is served through (ADR-0143 D4), so its calls would go ' + + 'out without the token. The instance is not served and is never called without its credential.'; + } + const missing = [ + auth.tokenUrl === undefined ? 'tokenUrl' : undefined, + auth.clientId === undefined ? 'clientId' : undefined, + auth.grant === 'authorization_code' && auth.authorizationUrl === undefined ? 'authorizationUrl' : undefined, + ].filter((k): k is string => k !== undefined); + if (missing.length > 0) { + return `connector instance '${name}' declares auth type 'oauth2' without ${missing.map((k) => `\`${k}\``).join(', ')}: ` + + 'discovering the endpoints and registering a client (the MCP authorization flow) is not run by this server yet ' + + '(ADR-0143 D2.1), so the instance is not served. Declare them to dispatch it now.'; + } + const insecure = insecureOAuth2EndpointFields(auth as Partial>); + if (insecure.length === 0) return undefined; + return `connector instance '${name}' declares auth type 'oauth2' with ${insecure.map((k) => `\`${k}\``).join(', ')} ` + + 'not over https: the client secret, the refresh token and the authorization redirect would cross the network in ' + + 'the clear (ADR-0143 D4), so the instance is not served. Declare an https:// endpoint; plain http:// is accepted ' + + 'only to a loopback host.'; +} + +/** + * ADR-0143 D4 — the connections of every `authorization_code` instance, read + * once per reconcile: the organizations each is `connected` in, and whether + * any connection is `reconnect_required`. `unreadable` when the store could + * not be read. + */ +type ConnectionStateOfInstance = + | { readonly kind: 'read'; readonly connected: readonly string[]; readonly reconnectRequired: readonly string[] } + | { readonly kind: 'unreadable' }; + /** The per-request signing instance auth kind (ADR-0143 D2.3): bound to a signer, never handed over as a value. */ type SignedInstanceAuth = Extract; @@ -439,7 +518,8 @@ function requestSignerNotAppliedReason(name: string, provider: string, auth: Sig * resolved to (ADR-0097 §3). `secret` is present whenever `auth` names a * reference — an unresolved one never reaches here: the reconcile degrades * the instance first (#27237). A grant-bearing kind never reaches here either: - * the reconcile holds it degraded ({@link grantBearingNotServedReason}); nor + * `oauth2` is served through its credential handle, `app-credential` is held + * degraded ({@link appCredentialNotServedReason}); nor * does `signed`, whose key is bound to a request signer instead * (`./request-signing/index.ts`). */ @@ -849,6 +929,20 @@ export class AutomationServicePlugin implements Plugin { * credential door writes. See `./connector-credentials.ts`. */ private connectorCredentialStore?: ConnectorCredentialStore; + /** + * ADR-0143 D3 — the connector connection store (`sys_connector_connection`): + * the refresh token and status of every `authorization_code` instance an + * organization connected. See `./connector-connections.ts`. + */ + private connectorConnectionStore?: ConnectorConnectionStore; + /** + * ADR-0143 D4 — the per-dispatch credential handle of every materialized + * `oauth2` instance, by name: what its provider was handed, and what a + * completed connect seeds and a revoke drops a token from. + */ + private grantHandles = new Map(); + /** ADR-0143 D5 — where begun connects wait without a cluster service (a single node). */ + private memoryPendingAuthorizations?: PendingAuthorizationStore; /** * [#27428] The cluster transport the credential door's writes fan out on, * this node's id (stamps `originNode`), and the subscription's disposer — @@ -950,8 +1044,9 @@ export class AutomationServicePlugin implements Plugin { scope: 'system', defaultDatasource: 'cloud', namespace: 'sys', - // [#27237] The connector credential store rides it too. - objects: [SysAutomationRun, SysFlowDispatch, SysFlowCredential, SysConnectorCredential], + // [#27237] The connector credential store rides it too, and + // (ADR-0143 D3) the connector connection store. + objects: [SysAutomationRun, SysFlowDispatch, SysFlowCredential, SysConnectorCredential, SysConnectorConnection], }); return true; } catch (err) { @@ -1019,7 +1114,7 @@ export class AutomationServicePlugin implements Plugin { scope: 'system', defaultDatasource: 'cloud', namespace: 'sys', - objects: [SysFlowCredential, SysConnectorCredential], + objects: [SysFlowCredential, SysConnectorCredential, SysConnectorConnection], }); return true; } catch (err) { @@ -1149,6 +1244,15 @@ export class AutomationServicePlugin implements Plugin { // declared instances it lists and the reconcile a write runs are this // plugin's, while the registered service is the engine. this.connectorCredentialStore = new ConnectorCredentialStore(() => this.resolveDataEngine(ctx)); + // ADR-0143 D3 / D5 — the connector connection store, and the door the + // `automation` service's connection members answer through. + this.connectorConnectionStore = new ConnectorConnectionStore(() => this.resolveDataEngine(ctx)); + this.engine.setConnectorConnectionDoor({ + get: (request) => this.getConnectorConnection(ctx, request), + begin: (request) => this.beginConnectorConnection(ctx, request), + complete: (request) => this.completeConnectorConnection(ctx, request), + revoke: (request) => this.revokeConnectorConnection(ctx, request), + }); this.engine.setConnectorCredentialDoor({ list: () => this.listConnectorCredentialRefs(ctx), set: (request) => this.setConnectorCredential(ctx, request), @@ -2233,6 +2337,40 @@ export class AutomationServicePlugin implements Plugin { credentialSources.set(ref, fingerprint); d.signature += `|credential:${fingerprint}`; } + // ADR-0143 D4 — whether an `authorization_code` instance has a working + // connection joins its signature, so the first connect materializes it + // and the last revoke or refused grant holds it again. A connect, + // revoke or refusal in one organization while another stays connected + // re-materializes nothing (the handle reads the row per dispatch, and + // the other organizations keep their cached tokens), and neither does + // a refresh that rotates the token. + const connectionStates = new Map(); + for (const [name, d] of desired) { + const auth = d.entry.auth; + if (auth?.type !== 'oauth2' || auth.grant !== 'authorization_code') continue; + let state: ConnectionStateOfInstance; + try { + const rows = await this.connectorConnectionStoreOrNone().listFor(name); + state = { + kind: 'read', + connected: rows.filter((r) => r.status === 'connected').map((r) => r.organizationId).sort(), + reconnectRequired: rows.filter((r) => r.status === 'reconnect_required').map((r) => r.organizationId).sort(), + }; + } catch (err) { + state = { kind: 'unreadable' }; + ctx.logger.warn( + `[Automation] the connections of connector instance '${name}' could not be read; it retries.`, + describeThrownForLog(err), + ); + } + connectionStates.set(name, state); + d.signature += state.kind === 'unreadable' + ? '|connections:unreadable' + : state.connected.length > 0 + ? '|connections:connected' + // Held: which reason it is held with moves with the refused organizations. + : `|connections:none:${state.reconnectRequired.join(',')}`; + } // A refusal of a value the door does not hold stands until an upstream // accepts the reference — or the reference stops resolving from the // source that was refused (`env` / `vault` are fingerprints as well). @@ -2306,17 +2444,26 @@ export class AutomationServicePlugin implements Plugin { continue; } - // ADR-0143 D4 — a grant-bearing kind (`oauth2`, `app-credential`) - // is held degraded with a named reason until per-dispatch - // resolution lands, never materialized without its credential. - if (entry.auth?.type === 'oauth2' || entry.auth?.type === 'app-credential') { + // ADR-0143 D2.2 — `app-credential` is held degraded with a named + // reason until its vendor exchanges land, never materialized + // without its credential. ADR-0143 D4 — an `oauth2` instance is + // served only through a provider that asks its credential handle, + // and only with the endpoints the handle sends a token request to. + const grantRefusal = entry.auth?.type === 'app-credential' + ? appCredentialNotServedReason(name, entry.auth) + : entry.auth?.type === 'oauth2' + ? oauth2NotServableReason(name, provider, entry.auth, engine.providerAppliesCredentialHandle(provider)) + : undefined; + if (grantRefusal !== undefined) { + if (existing) await this.dematerializeConnector(engine, name, existing, ctx, 'no longer servable'); + this.grantHandles.delete(name); this.failDegradedConnectorInstance(engine, ctx, { name, entry, provider, signature, - hasLive: existing !== undefined, - reason: grantBearingNotServedReason(name, entry.auth), + hasLive: false, + reason: grantRefusal, cause: undefined, }); continue; @@ -2362,6 +2509,7 @@ export class AutomationServicePlugin implements Plugin { // unparseable retry policy below. let auth: ResolvedConnectorAuth | undefined; let requestSigner: ConnectorRequestSigner | undefined; + let credentialHandle: OAuth2GrantHandle | undefined; if (entry.auth?.type === 'signed') { try { requestSigner = createConnectorRequestSigner(name, entry.auth, credential?.value ?? ''); @@ -2373,7 +2521,41 @@ export class AutomationServicePlugin implements Plugin { ); continue; } - } else { + } else if (entry.auth?.type === 'oauth2') { + // ADR-0143 D4 — an `authorization_code` instance with no + // working connection in any organization is not an authoring + // fault and never fails a boot: it is held degraded, "not + // connected" or "reconnect required", until a connect. + if (entry.auth.grant === 'authorization_code') { + const state = connectionStates.get(name); + if (state?.kind === 'unreadable') { + this.degradeConnectorInstance(engine, ctx, { + name, + entry, + provider, + signature, + hasLive: existing !== undefined, + reason: `the connections of connector instance '${name}' could not be read; it retries on its own`, + cause: undefined, + }); + continue; + } + if (state === undefined || state.connected.length === 0) { + await this.holdConnectorUntilConnected(engine, ctx, { + name, + entry, + provider, + signature, + existing, + reason: state && state.reconnectRequired.length > 0 + ? reconnectRequiredReason(name, state.reconnectRequired[0]) + : notConnectedReason(name), + }); + continue; + } + } + credentialHandle = this.createGrantHandle(ctx, name, entry, entry.auth, credential?.value); + } else if (entry.auth?.type !== 'app-credential') { auth = resolveInstanceAuth(entry.auth, credential?.value); } @@ -2408,6 +2590,9 @@ export class AutomationServicePlugin implements Plugin { providerConfig: entry.providerConfig ?? {}, auth, ...(requestSigner === undefined ? {} : { requestSigner }), + // ADR-0143 D4 — the per-dispatch credential of an `oauth2` + // instance; `auth` is then `undefined`. + ...(credentialHandle === undefined ? {} : { credentialHandle }), // ADR-0049 · #18975 — the declared resilience policy reaches the // factory. The built-in HTTP providers hand it to // `connectorFetchOptions()` → `resilientFetch()`; a custom @@ -2528,13 +2713,19 @@ export class AutomationServicePlugin implements Plugin { // and — unless the provider proved the credential while // materializing — for the first one it accepts. const proven = materialization.credentialVerified === true; - const handlers = credential && ref !== undefined + // ADR-0143 D4 — an `oauth2` instance's upstream `401` refuses the + // access token the handle answered, never the client secret its + // `credentialRef` names, so its calls are not watched for it: the + // provider refreshes once and retries (`callWithBorrowedCredential`). + const handlers = credential && ref !== undefined && credentialHandle === undefined ? this.watchForCredentialRejection(engine, ctx, { name, entry, provider, signature, ref, credential, tracked, proven, }, materialization.handlers) : materialization.handlers; engine.registerConnector(def, handlers, 'declarative'); this.materializedConnectors.set(name, tracked); + if (credentialHandle) this.grantHandles.set(name, credentialHandle); + else this.grantHandles.delete(name); // Success clears any pending degraded retry — including replacing a // registered husk (registerConnector above overwrote it). const recovered = this.degradedInstances.delete(name); @@ -2548,7 +2739,7 @@ export class AutomationServicePlugin implements Plugin { // upstream accepted it. rest and openapi first send it on a call, // where a `2xx` only stamps a `set` row's time; neither that nor a // materialization alone turns a `rejected` row back to `set`. - if (credential && ref !== undefined && proven) { + if (credential && ref !== undefined && proven && credentialHandle === undefined) { await this.recordCredentialProven(ctx, name, ref, credential); } } @@ -2735,6 +2926,366 @@ export class AutomationServicePlugin implements Plugin { return this.connectorCredentialStore ?? new ConnectorCredentialStore(() => undefined); } + /** ADR-0143 D3 — the connection store, or one over no engine (every read empty). */ + private connectorConnectionStoreOrNone(): ConnectorConnectionStore { + return this.connectorConnectionStore ?? new ConnectorConnectionStore(() => undefined); + } + + /** The cluster service, when the deployment composes one; resolved at use. */ + private clusterService(ctx: PluginContext): IClusterService | undefined { + try { + return ctx.getService('cluster'); + } catch { + return undefined; + } + } + + /** + * ADR-0143 D4 — the credential handle a materializing `oauth2` instance's + * provider is handed. Its refreshes run under the cluster lock when there + * is one; a refused grant re-judges the instance on the reconcile queue. + */ + private createGrantHandle( + ctx: PluginContext, + name: string, + entry: DeclaredConnectorItem, + auth: OAuth2InstanceAuth, + clientSecret: string | undefined, + ): OAuth2GrantHandle { + // `oauth2NotServableReason` passed: both are declared. + const tokenUrl = auth.tokenUrl as string; + const clientId = auth.clientId as string; + return createOAuth2GrantHandle({ + connector: name, + config: { + grant: auth.grant, + tokenUrl, + clientId, + ...(clientSecret === undefined ? {} : { clientSecret }), + ...(auth.scopes === undefined ? {} : { scopes: auth.scopes }), + ...(auth.resource === undefined ? {} : { resource: auth.resource }), + }, + connections: this.connectorConnectionStoreOrNone(), + lock: () => this.clusterService(ctx)?.lock, + onGrantRefused: (organizationId) => { + ctx.logger.warn( + `[Automation] connector instance '${name}': the authorization server refused the grant of organization ` + + `'${organizationId}'; it needs to be reconnected there (ADR-0143 D4).`, + ); + void this.materializeDeclaredConnectors(ctx, { fatal: false }).catch(() => { /* logged by the reconcile */ }); + }, + ...(typeof entry.requestTimeoutMs === 'number' ? { timeoutMs: entry.requestTimeoutMs } : {}), + }); + } + + /** + * ADR-0143 D4 — hold an `authorization_code` instance with no working + * connection degraded: "not connected" (never authorized) or "reconnect + * required" (its grant was refused). Not an authoring fault, so it never + * fails a boot and is never retried on the backoff; a connect changes its + * signature, which re-attempts it. A live connector is torn down first, so + * the registry shows the instance degraded rather than ready. + */ + private async holdConnectorUntilConnected( + engine: AutomationEngine, + ctx: PluginContext, + info: { + name: string; + entry: DeclaredConnectorItem; + provider: string; + signature: string; + existing: { close?: () => void | Promise } | undefined; + reason: string; + }, + ): Promise { + if (info.existing) await this.dematerializeConnector(engine, info.name, info.existing, ctx, 'no working connection'); + this.degradedInstances.set(info.name, { attempts: 1, signature: info.signature, reason: info.reason, held: true }); + try { + engine.registerDegradedConnector( + this.buildDegradedHuskDef(info.name, info.entry), + info.reason, + 'declarative', + undefined, + { kind: 'connection' }, + ); + } catch (err) { + ctx.logger.warn( + `[Automation] could not register degraded husk for '${info.name}' — the instance stays absent from the ` + + 'connector registry until it is connected.', + describeThrownForLog(err), + ); + } + ctx.logger.warn(`[Automation] connector instance '${info.name}' (provider '${info.provider}') is degraded: ${info.reason}.`); + } + + /** ADR-0143 D5 — where begun connects wait: the cluster KV, or this process on a single node. */ + private pendingAuthorizations(ctx: PluginContext): PendingAuthorizationStore { + const kv = this.clusterService(ctx)?.kv; + if (kv) return kvPendingAuthorizationStore(kv); + this.memoryPendingAuthorizations ??= memoryPendingAuthorizationStore(); + return this.memoryPendingAuthorizations; + } + + /** ADR-0143 D5 — the deployment's fixed redirect URI, or the `503` naming how to configure one. */ + private connectorOAuthRedirectUri(): string { + if (this.options.connectorOAuthRedirectUri) return this.options.connectorOAuthRedirectUri; + const env = typeof process !== 'undefined' ? process.env : undefined; + const origin = (env?.OS_AUTH_URL || env?.OS_BASE_URL || '').trim().replace(/\/+$/, ''); + if (/^https?:\/\//i.test(origin)) return `${origin}/api/v1/automation/connectors/oauth/callback`; + throw credentialDoorRefusal( + 'This deployment has no fixed public origin for the OAuth redirect, so a connector instance cannot be connected: ' + + 'set OS_BASE_URL (or OS_AUTH_URL) to the URL it is served at, or pass connectorOAuthRedirectUri to the ' + + 'automation plugin. A redirect URI is never taken from the request (ADR-0143 D5).', + 'SERVICE_UNAVAILABLE', + 503, + ); + } + + /** + * ADR-0143 D5 — the declared `authorization_code` instance a connection + * door call names, servable, and the caller's organization and user. + */ + private async connectionDoorSubject( + ctx: PluginContext, + request: ConnectorConnectionRequest | undefined, + ): Promise<{ + name: string; + entry: DeclaredConnectorItem; + auth: Extract; + organizationId: string; + userId?: string; + }> { + const name = request?.connector; + if (typeof name !== 'string' || name.length === 0) { + throw credentialDoorRefusal('A connector instance name is required.', 'VALIDATION_ERROR', 400); + } + const declared = await this.readDeclaredConnectorItems(ctx, { post: true }); + const entry = (declared ?? []).find((raw) => (raw as DeclaredConnectorItem | null)?.name === name) as DeclaredConnectorItem | undefined; + if (!entry || typeof entry.provider !== 'string' || entry.provider.length === 0) { + throw credentialDoorRefusal( + `No installed package declares a provider-bound connector instance '${name}'.`, + 'RESOURCE_NOT_FOUND', + 404, + ); + } + const auth = entry.auth; + if (auth?.type !== 'oauth2' || auth.grant !== 'authorization_code') { + throw credentialDoorRefusal( + `Connector instance '${name}' does not use the oauth2 authorization_code grant, so it has no connection to ` + + "make: only an instance an administrator authorizes keeps one (ADR-0143 D3). A 'client_credentials' " + + 'instance needs only the client secret its credentialRef names.', + 'VALIDATION_ERROR', + 400, + ); + } + const notServable = oauth2NotServableReason(name, entry.provider, auth, true); + if (notServable !== undefined) throw credentialDoorRefusal(`${notServable}.`, 'VALIDATION_ERROR', 400); + const organizationId = request?.context?.tenantId; + if (typeof organizationId !== 'string' || organizationId.length === 0) { + throw credentialDoorRefusal( + `Connecting connector instance '${name}' needs an active organization: a connection belongs to the ` + + 'organization that makes it (ADR-0143 Q1). Switch to the organization first.', + 'VALIDATION_ERROR', + 400, + ); + } + const userId = typeof request?.context?.userId === 'string' && request.context.userId.length > 0 ? request.context.userId : undefined; + return { name, entry, auth, organizationId, userId }; + } + + /** ADR-0143 D3 — a connection row (or its absence) as the door answers it. Never a token. */ + private describeConnection(name: string, organizationId: string, record: ConnectorConnectionRecord | undefined): ConnectorConnectionState { + if (!record) return { connector: name, organizationId, status: 'not_connected' }; + return { + connector: name, + organizationId, + status: record.status, + ...(record.statusReason ? { statusReason: record.statusReason } : {}), + ...(record.scopes ? { scopes: record.scopes } : {}), + ...(record.expiresAt ? { expiresAt: record.expiresAt } : {}), + ...(record.connectedAt ? { connectedAt: record.connectedAt } : {}), + ...(record.connectedBy ? { connectedBy: record.connectedBy } : {}), + }; + } + + /** ADR-0143 D3 — `IAutomationService.getConnectorConnection`. */ + private async getConnectorConnection(ctx: PluginContext, request: ConnectorConnectionRequest): Promise { + const { name, organizationId } = await this.connectionDoorSubject(ctx, request); + return this.describeConnection(name, organizationId, await this.connectorConnectionStoreOrNone().readOrRefuse(organizationId, name)); + } + + /** + * ADR-0143 D5 — `IAutomationService.beginConnectorConnection`: a fresh + * `state` and PKCE pair bound to the caller, kept until the callback, and + * the authorization URL. Nothing is written to the connection record. + */ + private async beginConnectorConnection(ctx: PluginContext, request: ConnectorConnectionRequest): Promise { + const { name, auth, organizationId, userId } = await this.connectionDoorSubject(ctx, request); + if (userId === undefined) { + throw credentialDoorRefusal( + `Connecting connector instance '${name}' needs a signed-in user: the callback is accepted only from the user ` + + 'who began the connect (ADR-0143 D5).', + 'VALIDATION_ERROR', + 400, + ); + } + const redirectUri = this.connectorOAuthRedirectUri(); + 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 }); + ctx.logger.info(`[Automation] connect of connector instance '${name}' begun for organization '${organizationId}' by user '${userId}'.`); + return { + connector: name, + organizationId, + authorizationUrl: buildAuthorizationUrl({ + authorizationUrl: auth.authorizationUrl as string, + clientId: auth.clientId as string, + redirectUri, + state, + challenge, + ...(auth.scopes === undefined ? {} : { scopes: auth.scopes }), + ...(auth.resource === undefined ? {} : { resource: auth.resource }), + }), + expiresAt: new Date(expiresAt).toISOString(), + }; + } + + /** + * ADR-0143 D5 — `IAutomationService.completeConnectorConnection`: take the + * `state` once, check it against the caller, exchange the code with the + * verifier, store the connection and re-judge the instance. + */ + private async completeConnectorConnection(ctx: PluginContext, request: ConnectorConnectionCallbackRequest): Promise { + const query = request?.query ?? {}; + const refuse = (message: string): Error => credentialDoorRefusal(message, 'VALIDATION_ERROR', 400); + if (typeof query.state !== 'string' || query.state.length === 0) { + throw refuse('The OAuth callback carries no state, so it answers no connect this server began. Nothing was connected.'); + } + const pending = await this.pendingAuthorizations(ctx).take(query.state); + if (!pending || pending.expiresAt <= Date.now()) { + throw refuse( + 'The OAuth callback\'s state names no connect waiting here: it expired, was already used, or was never begun. ' + + 'Nothing was connected; begin the connect again.', + ); + } + const callerOrganization = request.context?.tenantId; + const callerUser = request.context?.userId; + if (callerUser !== pending.userId || callerOrganization !== pending.organizationId) { + ctx.logger.warn( + `[Automation] an OAuth callback for connector instance '${pending.connector}' was refused: it did not come from ` + + 'the user and organization that began the connect.', + ); + throw refuse( + 'The OAuth callback was not completed by the user and organization that began this connect, so its code was ' + + 'not used. Nothing was connected; begin the connect again from your own session.', + ); + } + if (typeof query.error === 'string' && query.error.length > 0) { + const registered = new Set([ + 'invalid_request', 'unauthorized_client', 'access_denied', 'unsupported_response_type', 'invalid_scope', + 'server_error', 'temporarily_unavailable', + ]); + throw refuse( + `The authorization server did not authorize connector instance '${pending.connector}'` + + `${registered.has(query.error) ? ` (${query.error})` : ''}. Nothing was connected.`, + ); + } + if (typeof query.code !== 'string' || query.code.length === 0) { + throw refuse('The OAuth callback carries no code. Nothing was connected; begin the connect again.'); + } + // The declaration is read again: the instance may have changed while + // the browser was away. + const { name, auth, organizationId } = await this.connectionDoorSubject(ctx, { + connector: pending.connector, + context: request.context, + }); + let clientSecret: string | undefined; + if (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); + if (resolution.kind !== 'resolved') { + throw credentialDoorRefusal( + `Connector instance '${name}' cannot exchange the code: ${missingCredentialReason(auth.credentialRef, this.options.credentialResolver !== undefined)}. ` + + 'Nothing was connected.', + 'SERVICE_UNAVAILABLE', + 503, + ); + } + clientSecret = resolution.value; + } + const params: Record = { + grant_type: 'authorization_code', + code: query.code, + redirect_uri: pending.redirectUri, + code_verifier: pending.verifier, + }; + if (auth.resource) params.resource = auth.resource; + let token; + try { + token = await requestOAuth2Token({ + connector: name, + tokenUrl: auth.tokenUrl as string, + client: { clientId: auth.clientId as string, ...(clientSecret === undefined ? {} : { clientSecret }) }, + params, + }); + } catch (err) { + if (isConnectorUpstreamUnavailable(err)) { + throw credentialDoorRefusal(`${(err as Error).message}. Nothing was connected; begin the connect again.`, 'SERVICE_UNAVAILABLE', 503); + } + if (err instanceof OAuth2GrantRefusedError) { + throw refuse(`The authorization server of connector instance '${name}' refused the code (invalid_grant). Nothing was connected; begin the connect again.`); + } + throw refuse(`${(err as Error).message}. Nothing was connected.`); + } + const userId = request.context?.userId; + const record = await this.onReconcileQueue(async () => { + const stored = await this.connectorConnectionStoreOrNone().connect({ + organizationId, + connectorName: name, + ...(userId ? { userId } : {}), + ...(token.refreshToken === undefined ? {} : { refreshToken: token.refreshToken }), + ...(token.scope === undefined ? {} : { scopes: token.scope }), + ...(token.expiresAt === undefined ? {} : { expiresAt: new Date(token.expiresAt) }), + }); + ctx.logger.info( + `[Automation] connector instance '${name}' connected for organization '${organizationId}'` + + `${userId ? ` by user '${userId}'` : ''}; reconciling it.`, + ); + await this.reconcileDeclaredConnectors(ctx, { fatal: false }); + return stored; + }); + // The access token the exchange answered serves the next dispatch; it is + // never stored (D3). + this.grantHandles.get(name)?.seed(organizationId, record.revision, token); + return this.describeConnection(name, organizationId, record); + } + + /** ADR-0143 D5 — `IAutomationService.revokeConnectorConnection`. */ + private async revokeConnectorConnection(ctx: PluginContext, request: ConnectorConnectionRequest): Promise { + const { name, organizationId, userId } = await this.connectionDoorSubject(ctx, request); + const removed = await this.onReconcileQueue(async () => { + const gone = await this.connectorConnectionStoreOrNone().revoke(organizationId, name, userId); + if (!gone) return false; + this.grantHandles.get(name)?.forget(organizationId); + ctx.logger.info( + `[Automation] connection of connector instance '${name}' revoked for organization '${organizationId}'` + + `${userId ? ` by user '${userId}'` : ''}; reconciling it.`, + ); + await this.reconcileDeclaredConnectors(ctx, { fatal: false }); + return true; + }); + if (!removed) { + throw credentialDoorRefusal( + `Connector instance '${name}' is not connected for organization '${organizationId}', so there is nothing to revoke.`, + 'RESOURCE_NOT_FOUND', + 404, + ); + } + return this.describeConnection(name, organizationId, undefined); + } + /** * [#27237] The deployment's effective tenancy posture, from the `tenancy` * service; `undefined` when none is registered (a single-organization @@ -3468,6 +4019,7 @@ export class AutomationServicePlugin implements Plugin { try { await tracked.close(); } catch { /* best-effort */ } } this.materializedConnectors.delete(name); + this.grantHandles.delete(name); ctx.logger.info(`[Automation] unregistered declarative connector instance '${name}' (${why})`); } diff --git a/packages/services/service-automation/src/sys-connector-connection.object.ts b/packages/services/service-automation/src/sys-connector-connection.object.ts new file mode 100644 index 00000000000..d790426dcd9 --- /dev/null +++ b/packages/services/service-automation/src/sys-connector-connection.object.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; + +/** + * sys_connector_connection — the connection record of a grant-bearing + * declarative connector instance (ADR-0143 D3, Q2 = B). + * + * An `oauth2` instance with `grant: 'authorization_code'` is authorized once + * by an administrator (D5). What that grant produces and cannot be obtained + * again unattended is kept here, and nothing else: + * + * - 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"). + * + * ⛔ Not here: the access token. It lives in the automation plugin's + * in-process cache only (D3, last bullet), as does every `client_credentials` + * token, which the instance's client secret re-derives at any time — so a + * `client_credentials` instance has no row. The client secret itself stays a + * `credentialRef`, resolved through the #27237 chain (D3, first bullet). + * + * One row per `(organization, connector instance)` (Q1 = A: a connection is + * shared by its organization, and every run of that organization dispatches + * with it). The organization column is explicit so the row is bound to the + * organization that connected it, not to whatever context reads it. + * + * ⛔ Not a sign-in account (D6). Nothing here is a better-auth `account` row, + * and no sign-in path reads it: an integration grant never lets anyone sign in, + * and no sign-in revokes or replaces it. + * + * Writers and readers: the automation plugin alone (`connector-connections.ts`), + * under a system context — the connect / callback / revoke door and the + * per-dispatch refresh. The generic data door is closed, as on + * `sys_connector_credential`. + * + * @namespace sys + */ +export const SysConnectorConnection = ObjectSchema.create({ + name: 'sys_connector_connection', + label: 'Connector Connection', + pluralLabel: 'Connector Connections', + icon: 'plug', + isSystem: true, + managedBy: 'engine-owned', + // ADR-0066 secure-by-default: a token store is not covered by the wildcard + // grant. Everything reads and writes it through the automation plugin under + // a system context. + access: { default: 'private' }, + description: + "Connection record of a grant-bearing connector instance (ADR-0143 D3): one row per organization and instance, holding the encrypted refresh token and the grant's status, scopes and expiry. Never an access token, never a sign-in account.", + displayNameField: 'connector_name', + nameField: 'connector_name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) + highlightFields: ['connector_name', 'status', 'expires_at', 'connected_at'], + + fields: { + organization_id: Field.text({ + label: 'Organization', + required: true, + readonly: true, + maxLength: 255, + description: 'The organization that connected this instance; every run of that organization dispatches with this connection (ADR-0143 Q1).', + }), + + connector_name: Field.text({ + label: 'Connector Instance', + required: true, + readonly: true, + maxLength: 255, + description: 'The declarative connector instance this connection authorizes, exactly as declared.', + }), + + grant: Field.select(['authorization_code'], { + label: 'Grant', + required: true, + readonly: true, + description: 'The OAuth2 grant that produced this connection. Only `authorization_code` keeps a row: a `client_credentials` token is re-derived from the client secret.', + }), + + refresh_token: Field.secret({ + label: 'Refresh Token', + description: + 'The refresh token the authorization server issued, encrypted at rest and masked on every read; replaced atomically when the server rotates it. Absent when the server issues none.', + }), + + status: Field.select(['connected', 'reconnect_required'], { + label: 'Status', + required: true, + defaultValue: 'connected', + description: + "`connected` while the grant works; `reconnect_required` once the authorization server refused it (revoked, or expired with no usable refresh). Only a new connect returns it to `connected`.", + }), + + status_reason: Field.textarea({ + label: 'Status Reason', + readonly: true, + description: 'Why the grant stopped working, in the platform\'s words. Never a token, and never the authorization server\'s own text.', + }), + + scopes: Field.textarea({ + label: 'Scopes', + readonly: true, + description: 'The scopes the authorization server granted, space-separated, as it answered them.', + }), + + expires_at: Field.datetime({ + label: 'Access Expires At', + readonly: true, + description: 'When the access token last obtained for this connection expires. The token itself is never stored.', + }), + + revision: Field.number({ + label: 'Revision', + required: true, + readonly: true, + defaultValue: 1, + description: 'Raised on every write of the refresh token: a refresh writes the rotated token only over the revision it read, so a rotated token is never overwritten by a stale one.', + }), + + connected_at: Field.datetime({ + label: 'Connected At', + readonly: true, + description: 'When an administrator last connected or reconnected this instance for the organization.', + }), + + connected_by: Field.text({ + label: 'Connected By', + readonly: true, + maxLength: 255, + description: 'The user id that last connected or reconnected it.', + }), + }, + + indexes: [ + // One connection per instance in an organization — the connect door's + // upsert key (ADR-0143 Q1 = A). + { fields: ['connector_name'], unique: 'organization' }, + ], + + enable: { + // Engine-owned and write-only: no generic data door at all. The refresh + // token is masked on every engine read regardless. + trackHistory: false, + searchable: false, + apiEnabled: false, + apiMethods: [], + }, +}); diff --git a/packages/spec/api-surface/contracts.json b/packages/spec/api-surface/contracts.json index a96a7ac4549..96c6517619f 100644 --- a/packages/spec/api-surface/contracts.json +++ b/packages/spec/api-surface/contracts.json @@ -64,6 +64,11 @@ "CheckNamespaceInput (interface)", "CheckNamespaceResult (interface)", "ClusterCallContext (interface)", + "ConnectorConnectionBegin (interface)", + "ConnectorConnectionCallbackQuery (interface)", + "ConnectorConnectionCallbackRequest (interface)", + "ConnectorConnectionRequest (interface)", + "ConnectorConnectionState (interface)", "ConnectorCredentialClearRequest (interface)", "ConnectorCredentialRef (interface)", "ConnectorCredentialSetRequest (interface)", diff --git a/packages/spec/export-origins/contracts.json b/packages/spec/export-origins/contracts.json index 38981c5802f..faf38b54fdd 100644 --- a/packages/spec/export-origins/contracts.json +++ b/packages/spec/export-origins/contracts.json @@ -64,6 +64,11 @@ "CheckNamespaceInput": "src/contracts/package-service.ts#CheckNamespaceInput (interface)", "CheckNamespaceResult": "src/contracts/package-service.ts#CheckNamespaceResult (interface)", "ClusterCallContext": "src/contracts/cluster-service.ts#ClusterCallContext (interface)", + "ConnectorConnectionBegin": "src/contracts/automation-service.ts#ConnectorConnectionBegin (interface)", + "ConnectorConnectionCallbackQuery": "src/contracts/automation-service.ts#ConnectorConnectionCallbackQuery (interface)", + "ConnectorConnectionCallbackRequest": "src/contracts/automation-service.ts#ConnectorConnectionCallbackRequest (interface)", + "ConnectorConnectionRequest": "src/contracts/automation-service.ts#ConnectorConnectionRequest (interface)", + "ConnectorConnectionState": "src/contracts/automation-service.ts#ConnectorConnectionState (interface)", "ConnectorCredentialClearRequest": "src/contracts/automation-service.ts#ConnectorCredentialClearRequest (interface)", "ConnectorCredentialRef": "src/contracts/automation-service.ts#ConnectorCredentialRef (interface)", "ConnectorCredentialSetRequest": "src/contracts/automation-service.ts#ConnectorCredentialSetRequest (interface)", diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index db44cd8deef..a3712a275b6 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -1082,6 +1082,69 @@ export interface ConnectorCredentialClearRequest { context?: ExecutionContext; } +/** + * ADR-0143 D3 / D5 — the connection of an `authorization_code` connector + * instance in the caller's organization, as the connection door answers it. + * ⛔ Never a token: the refresh token is a `secret` column the door never + * reads, and the access token is never stored at all. + */ +export interface ConnectorConnectionState { + /** The connector instance. */ + connector: string; + /** The organization the connection belongs to (ADR-0143 Q1 = A). */ + organizationId: string; + /** + * `not_connected` — no administrator connected it here yet (the instance + * dispatches nothing for this organization); `connected`; or + * `reconnect_required` — the authorization server refused the grant. + */ + status: 'not_connected' | 'connected' | 'reconnect_required'; + /** Why the grant stopped working, when `status` is `reconnect_required`. */ + statusReason?: string; + /** The scopes the authorization server granted, space-separated. */ + scopes?: string; + /** When the last access token obtained for this connection expires. */ + expiresAt?: string; + /** When it was last connected, and by whom. */ + connectedAt?: string; + connectedBy?: string; +} + +/** What {@link IAutomationService.getConnectorConnection} and its revoke / begin siblings are asked about. */ +export interface ConnectorConnectionRequest { + /** The declarative connector instance, by name. */ + connector: string; + /** The caller — its organization selects the connection, its user stamps the row. */ + context?: ExecutionContext; +} + +/** What {@link IAutomationService.beginConnectorConnection} answers: where to send the administrator's browser. */ +export interface ConnectorConnectionBegin { + connector: string; + organizationId: string; + /** The vendor's authorization URL, carrying `state` and the `S256` PKCE challenge. */ + authorizationUrl: string; + /** When the begun connect stops being completable. */ + expiresAt: string; +} + +/** The redirect's closed query set (RFC 6749 §4.1.2, RFC 9207), as the callback door hands it over. */ +export interface ConnectorConnectionCallbackQuery { + code?: string; + state?: string; + error?: string; + error_description?: string; + error_uri?: string; + iss?: string; +} + +/** What {@link IAutomationService.completeConnectorConnection} is asked to finish. */ +export interface ConnectorConnectionCallbackRequest { + query: ConnectorConnectionCallbackQuery; + /** The caller — it must be the user and organization that began the connect. */ + context?: ExecutionContext; +} + export interface IAutomationService { /** * Execute a named flow or script @@ -1378,6 +1441,48 @@ export interface IAutomationService { */ clearConnectorCredential?(request: ConnectorCredentialClearRequest): Promise; + /** + * ADR-0143 D3 — the caller's organization's connection of one + * `authorization_code` instance. Rejects with an ADR-0112 `code` + + * `status`: an instance no installed package declares (`404 + * RESOURCE_NOT_FOUND`); one whose auth is not `oauth2` with `grant: + * 'authorization_code'` (`400 VALIDATION_ERROR`); a caller with no + * organization (`400`); a deployment that cannot read the store (`503`). + * + * Optional for the reason {@link getConnectorDescriptors} is. + */ + getConnectorConnection?(request: ConnectorConnectionRequest): Promise; + + /** + * ADR-0143 D5 — begin connecting (or reconnecting) one + * `authorization_code` instance for the caller's organization: a fresh + * `state` and `S256` PKCE pair bound to the caller, and the authorization + * 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. + */ + beginConnectorConnection?(request: ConnectorConnectionRequest): Promise; + + /** + * ADR-0143 D5 — complete a begun connect from the authorization server's + * redirect: the `state` is taken once and must have been begun by the same + * user in the same organization, then the code is exchanged with the PKCE + * verifier and the connection stored. Rejects `400 VALIDATION_ERROR` for an + * unknown, expired, replayed or foreign `state` and for an authorization + * server's `error` answer (naming its registered code only), and `503` when + * the token endpoint cannot be reached. + */ + completeConnectorConnection?(request: ConnectorConnectionCallbackRequest): Promise; + + /** + * ADR-0143 D5 — remove the caller's organization's connection of one + * instance; its next dispatch there is refused "not connected". Rejects + * `404 RESOURCE_NOT_FOUND` when it was not connected, otherwise as + * {@link getConnectorConnection} does. + */ + revokeConnectorConnection?(request: ConnectorConnectionRequest): Promise; + /** * Per-flow deployment + binding state, for operator surfaces. * diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index 916e34b35cd..65545643f93 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -123,8 +123,8 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly