diff --git a/.changeset/26702-connector-providers-oauth.md b/.changeset/26702-connector-providers-oauth.md new file mode 100644 index 00000000000..2dc140fbd1a --- /dev/null +++ b/.changeset/26702-connector-providers-oauth.md @@ -0,0 +1,18 @@ +--- +'@objectstack/connector-rest': minor +'@objectstack/connector-openapi': minor +'@objectstack/service-automation': minor +--- + +feat(connectors): the built-in `rest` and `openapi` providers serve `oauth2` and `app-credential` instances through the per-dispatch credential handle, and the Feishu, DingTalk and WeCom app-credential exchanges run (ADR-0143 S2, part of #26702) + +Clause-②: yes (widening) +Metadata-protocol: semantic + +ADR-0143 S1 (#28317) served an `oauth2` instance only on a provider that asks the credential handle, and none of the built-in providers did; every `app-credential` instance was held degraded. Now: + +- **`rest` and `openapi` ask the handle.** Both plugins register their provider with `{ credentialHandle: true }`. A grant-bearing instance on either one is served: every request asks the handle for the run's organization, sends the token where the handle places it (an `Authorization: Bearer` header, or a vendor's header or query parameter), refreshes once and retries once on an upstream `401`, and a second `401` is the dispatch's `503` naming the instance. A thrown transport error has the token removed from its message first. A handle beside a static credential or a request signer is refused when the instance is built: a request is authenticated one way. +- **`openapi` reads the document's `security`.** An operation whose effective `security` is `[]` is anonymous: the handle is not asked and no token is sent. On a grant-bearing instance, a parameter that an `apiKey` scheme the operation requires names is filled by the platform and is no longer an input of the action. An `oauth2` scheme's flows stay documentation: the instance's own `auth` names the token endpoint. +- **`app-credential` exchanges run** (`@objectstack/service-automation`), each as its vendor documents it: Feishu's `client_credentials` token at `accounts.feishu.cn` (sent as a bearer token), DingTalk's v1.0 `accessToken` API (sent in `x-acs-dingtalk-access-token`; the legacy `oapi` APIs that take `access_token` in the query are not served), and WeCom's `gettoken` (sent as the `access_token` query parameter). The token is held in memory only, refreshed inside the 30 second skew margin, and concurrent asks share one exchange. The token request never follows a redirect. No answer, `429` or `5xx` is `CONNECTOR_UPSTREAM_UNAVAILABLE`; a refusal is a `503` naming the vendor's error code only, never the secret. +- **WeCom early invalidation.** WeCom answers an invalidated token inside an ordinary answer (`errcode` `40014` or `42001`). A WeCom instance's actions treat that as the `401` rule does: the token is obtained afresh once and the call is repeated once; a second refusal is the `503`. +- **The guard.** An `app-credential` instance is served on a provider that asks the credential handle and held degraded, naming the provider, on one that does not. `mcp` does not ask the handle yet, so an `oauth2` or `app-credential` instance on `mcp` stays degraded. diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index ea4f30b460b..7bb4c1dccb9 100644 --- a/packages/connectors/connector-openapi/package.json +++ b/packages/connectors/connector-openapi/package.json @@ -29,7 +29,8 @@ }, "dependencies": { "@objectstack/core": "workspace:*", - "@objectstack/spec": "workspace:*" + "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*" }, "devDependencies": { "@objectstack/service-automation": "workspace:*", diff --git a/packages/connectors/connector-openapi/src/connector-openapi-plugin.ts b/packages/connectors/connector-openapi/src/connector-openapi-plugin.ts index 65fa80cdad7..509fae5484c 100644 --- a/packages/connectors/connector-openapi/src/connector-openapi-plugin.ts +++ b/packages/connectors/connector-openapi/src/connector-openapi-plugin.ts @@ -20,7 +20,18 @@ export interface ConnectorRegistrySurface { >, ): void; unregisterConnector(name: string): void; - registerConnectorProvider(providerKey: string, factory: ConnectorProviderFactory): void; + /** + * `options.credentialHandle: true` says the factory asks + * `ConnectorProviderContext.credentialHandle` on every request, so the host + * serves grant-bearing instances (`oauth2`, `app-credential`) through it + * (ADR-0143 D4). An older host ignores the third argument and keeps + * holding those instances degraded. + */ + registerConnectorProvider( + providerKey: string, + factory: ConnectorProviderFactory, + options?: { readonly credentialHandle?: boolean }, + ): void; } /** @@ -81,6 +92,8 @@ export class ConnectorOpenApiPlugin implements Plugin { automation.registerConnectorProvider( OPENAPI_PROVIDER_KEY, createOpenApiProviderFactory({ fetchImpl: this.options.fetchImpl }), + // ADR-0143 D4 — the transport asks the credential handle per request. + { credentialHandle: true }, ); ctx.logger.info("ConnectorOpenApiPlugin: registered 'openapi' connector provider"); } diff --git a/packages/connectors/connector-openapi/src/index.ts b/packages/connectors/connector-openapi/src/index.ts index c8219ad82fc..be89631476a 100644 --- a/packages/connectors/connector-openapi/src/index.ts +++ b/packages/connectors/connector-openapi/src/index.ts @@ -5,14 +5,14 @@ * * Generate an ObjectStack {@link Connector} from a declarative OpenAPI 3.x * document (ADR-0023). One operation becomes one connector action; a single - * generic handler drives a self-contained static-auth HTTP transport (mirroring + * generic handler drives a self-contained HTTP transport (mirroring * `@objectstack/connector-rest`). The generated connector is an ordinary * `type: 'api'` connector — registered via `engine.registerConnector` with no * new engine surface. * - * Open-source scope: static auth only (`none` / `api-key` / `basic` / `bearer`), - * credentials supplied by the caller. Managed OAuth2, credential vaulting, and - * per-tenant lifecycle are the enterprise tier (ADR-0015 / 0022). + * Static auth (`none` / `api-key` / `basic` / `bearer`), a request signer + * (ADR-0143 D2.3), or a per-dispatch credential handle for the grant-bearing + * kinds `oauth2` and `app-credential` (ADR-0143 D4, open tier per D1). */ export { diff --git a/packages/connectors/connector-openapi/src/openapi-connector.ts b/packages/connectors/connector-openapi/src/openapi-connector.ts index 85eb1027ad2..4b4664d104d 100644 --- a/packages/connectors/connector-openapi/src/openapi-connector.ts +++ b/packages/connectors/connector-openapi/src/openapi-connector.ts @@ -1,8 +1,15 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Connector, ConnectorRequestSigner, RetryConfig } from '@objectstack/spec/integration'; +import type { + BorrowedConnectorAuth, + Connector, + ConnectorCredentialHandle, + ConnectorRequestSigner, + RetryConfig, +} from '@objectstack/spec/integration'; import { connectorFetchOptions, readConnectorResponse } from '@objectstack/spec/integration'; import { resilientFetch } from '@objectstack/spec/shared'; +import { callWithBorrowedCredential, withoutBorrowedCredential } from '@objectstack/types'; /** * OpenAPI connector generator — turns a declarative OpenAPI 3.x document into a @@ -19,15 +26,35 @@ import { resilientFetch } from '@objectstack/spec/shared'; * hand-written one — the registry, the `connector_action` node, the discovery * route, and the Studio palette never know it came from OpenAPI. * - * Open-source scope: **static** auth only (`none` / `api-key` / `basic` / - * `bearer`), with credentials supplied by the caller. Managed OAuth2, credential - * vaulting, and per-tenant lifecycle are the enterprise tier (ADR-0015 / 0022). + * Three ways to authenticate, one per connector (mirrors connector-rest): * - * A request-signing instance (ADR-0143 D2.3) passes a `requestSigner` instead - * of a credential: every attempt is signed by it — retries included — and its - * response verification, when the scheme has one, runs before a body is - * returned (`connectorFetchOptions` / `readConnectorResponse`, shared with - * `@objectstack/connector-rest`). + * - **static** auth (`none` / `api-key` / `basic` / `bearer`), the credential + * supplied by the caller; + * - a **credential handle** (ADR-0143 D4) for a grant-bearing instance — + * `oauth2` or `app-credential`, open tier since ADR-0143 D1 — asked on + * every request through `callWithBorrowedCredential` (`@objectstack/types`): + * an expired token is refreshed before it is sent, and an upstream `401` + * refreshes it once and retries once; + * - a **request signer** (ADR-0143 D2.3) instead of a credential: every + * attempt is signed by it — retries included — and its response + * verification, when the scheme has one, runs before a body is returned + * (`connectorFetchOptions` / `readConnectorResponse`, shared with + * `@objectstack/connector-rest`). + * + * **What the document's security says** (OpenAPI 3.x §4.8.27, §4.8.30), read + * for a connector with a credential handle — the token comes from the + * platform, so the document decides only where it is NOT sent and which + * inputs it fills: + * + * - an operation whose effective `security` is the empty list (`security: + * []` on the operation, or on the document with no operation override) is + * anonymous: its request carries no credential and never asks the handle, + * so a grant's token is not handed to an endpoint that never asked for one; + * - a header or query parameter an `apiKey` security scheme the operation + * requires names (`in` + `name` — a vendor token that rides a query + * parameter, WeCom's `access_token`) is the platform's to fill, so it is + * left out of the action's input schema, as `Authorization` is for an + * `oauth2`, `openIdConnect` or `http` scheme. */ /** Static auth understood by the generated connector (the open-source subset). */ @@ -56,8 +83,16 @@ export interface OpenApiDocument { servers?: { url: string }[]; paths?: Record; components?: { securitySchemes?: Record }; + /** The document-wide security requirement (§4.8.1); an operation's own `security` replaces it. */ + security?: OpenApiSecurityRequirement[]; } +/** + * One Security Requirement Object (§4.8.30): scheme name → required scopes. + * The operation's list is alternatives; `{}` in it makes the security optional. + */ +export type OpenApiSecurityRequirement = Record; + export interface OpenApiPathItem { /** Parameters shared by every operation under this path (OpenAPI 3.x Path Item Object). */ parameters?: OpenApiParameter[]; @@ -72,6 +107,8 @@ export interface OpenApiOperation { parameters?: OpenApiParameter[]; requestBody?: OpenApiRequestBody; responses?: Record; + /** This operation's security requirement, replacing the document's; `[]` = anonymous. */ + security?: OpenApiSecurityRequirement[]; } export interface OpenApiParameter { @@ -94,10 +131,22 @@ export interface OpenApiResponse { } export interface OpenApiSecurityScheme { - type: 'apiKey' | 'http' | 'oauth2' | 'openIdConnect'; + type: 'apiKey' | 'http' | 'oauth2' | 'openIdConnect' | 'mutualTLS'; name?: string; in?: 'header' | 'query' | 'cookie'; scheme?: string; + bearerFormat?: string; + /** `oauth2` only (§4.8.28): the flows the scheme supports. */ + flows?: Record; + openIdConnectUrl?: string; +} + +/** One OAuth Flow Object (§4.8.29). */ +export interface OpenApiOAuthFlow { + authorizationUrl?: string; + tokenUrl?: string; + refreshUrl?: string; + scopes?: Record; } /** Flattened view of a single operation, passed to the `include` predicate. */ @@ -132,6 +181,12 @@ export interface OpenApiConnectorConfig { * static `auth` other than `none` — a request is authenticated one way. */ requestSigner?: ConnectorRequestSigner; + /** + * Per-dispatch credential of a grant-bearing instance (ADR-0143 D4): asked + * on every request an operation that is not anonymous sends. Exclusive with + * a static `auth` other than `none` and with `requestSigner`. + */ + credentialHandle?: ConnectorCredentialHandle; /** Headers merged into every request (request-level headers win). */ defaultHeaders?: Record; /** Only include operations for which this predicate returns true (allowlist). */ @@ -190,31 +245,55 @@ export function createOpenApiConnector(config: OpenApiConnectorConfig): OpenApiC if (!baseUrl) { throw new Error('createOpenApiConnector: no base URL — provide config.baseUrl or document.servers[0].url'); } - if (config.requestSigner && auth.type !== 'none') { + const ways = [ + auth.type !== 'none' ? `a static '${auth.type}' credential` : undefined, + config.requestSigner ? 'a request signer' : undefined, + config.credentialHandle ? 'a credential handle' : undefined, + ].filter((w): w is string => w !== undefined); + if (ways.length > 1) { throw new Error( - `createOpenApiConnector: connector '${name}' is given both a static '${auth.type}' credential and a request ` + - 'signer; a request is authenticated one way — pass one of them.', + `createOpenApiConnector: connector '${name}' is given ${ways.join(' and ')}; ` + + 'a request is authenticated one way — pass one of them.', ); } + const handle = config.credentialHandle; // One shared transport (mirrors connector-rest) reused by every action handler. - async function request(input: RequestInput): Promise> { + async function request(input: RequestInput, ctx: unknown, anonymous: boolean): Promise> { const method = input.method.toUpperCase(); - const headers: Record = { ...config.defaultHeaders, ...input.headers }; - const query: Record = { ...input.query }; - applyAuth(auth, headers, query); - - const url = buildUrl(baseUrl as string, input.path, query); - const hasBody = input.body !== undefined && method !== 'GET' && method !== 'HEAD'; - if (hasBody && headers['Content-Type'] === undefined && headers['content-type'] === undefined) { - headers['Content-Type'] = 'application/json'; - } - const response = await resilientFetch(url, { - method, - headers, - body: hasBody ? JSON.stringify(input.body) : undefined, - }, fetchOptions); + /** One attempt, with the static auth or the credential the handle answered. */ + const send = (credential?: BorrowedConnectorAuth): Promise => { + const headers: Record = { ...config.defaultHeaders, ...input.headers }; + const query: Record = { ...input.query }; + applyAuth(credential ?? auth, headers, query); + + const url = buildUrl(baseUrl as string, input.path, query); + const hasBody = input.body !== undefined && method !== 'GET' && method !== 'HEAD'; + if (hasBody && headers['Content-Type'] === undefined && headers['content-type'] === undefined) { + headers['Content-Type'] = 'application/json'; + } + + return resilientFetch(url, { + method, + headers, + body: hasBody ? JSON.stringify(input.body) : undefined, + }, fetchOptions); + }; + + // ADR-0143 D4 — ask the handle on every request an operation that + // requires security sends; a `401` refreshes once and retries once. A + // thrown transport error is scrubbed of the credential first. + const response = handle && !anonymous + ? await callWithBorrowedCredential(handle, async (credential) => { + try { + return await send(credential); + } catch (err) { + if (err instanceof Error) err.message = withoutBorrowedCredential(err.message, credential); + throw err; + } + }, { organizationId: organizationOfDispatch(ctx) }) + : await send(); // A signed instance's response is verified first, when its scheme // signs responses. @@ -228,16 +307,18 @@ export function createOpenApiConnector(config: OpenApiConnectorConfig): OpenApiC for (const op of collectOperations(document)) { if (include && !include(toInfo(op))) continue; const key = uniqueKey(operationActionKey(op), seenKeys); + const security = effectiveSecurity(op, document); + const anonymous = security !== undefined && security.length === 0; actions.push({ key, label: op.summary ?? titleize(key), description: op.description, - inputSchema: buildInputSchema(op, auth), + inputSchema: buildInputSchema(op, auth, handle && !anonymous ? schemeFilledParameters(security, document) : new Set()), outputSchema: buildOutputSchema(op), }); - handlers[key] = async (input: Record) => { + handlers[key] = async (input: Record, ctx: unknown) => { const req = input as { path?: unknown; query?: unknown; header?: unknown; body?: unknown }; return request({ method: op.method, @@ -245,7 +326,7 @@ export function createOpenApiConnector(config: OpenApiConnectorConfig): OpenApiC query: stringifyValues(asRecord(req.query)), headers: stringifyValues(asRecord(req.header)), body: req.body, - }); + }, ctx, anonymous); }; } @@ -274,6 +355,51 @@ interface Op extends OpenApiOperation { path: string; } +/** + * The organization a dispatch runs in, read from the host's handler context + * (`ConnectorActionContext.automation.tenantId` in + * `@objectstack/service-automation`). A credential handle selects the + * organization's connection by it (ADR-0143 Q1 = A); absent, the handle + * answers as D4 rules for a run with none. + */ +function organizationOfDispatch(ctx: unknown): string | undefined { + const tenantId = (ctx as { automation?: { tenantId?: unknown } } | null | undefined)?.automation?.tenantId; + return typeof tenantId === 'string' && tenantId.length > 0 ? tenantId : undefined; +} + +/** + * The operation's effective security requirement (§4.8.10 `security`): its + * own list, else the document's, else `undefined` — the document says nothing. + * A list that is not an array reads as saying nothing, never as anonymous. + */ +function effectiveSecurity(op: OpenApiOperation, doc: OpenApiDocument): OpenApiSecurityRequirement[] | undefined { + const own = (op as { security?: unknown }).security; + if (Array.isArray(own)) return own as OpenApiSecurityRequirement[]; + const shared = (doc as { security?: unknown }).security; + return Array.isArray(shared) ? (shared as OpenApiSecurityRequirement[]) : undefined; +} + +/** + * The parameters (`header:` / `query:`) that the + * `apiKey` schemes `security` names place the credential in — filled by the + * platform, never by the flow's input. A scheme the document does not declare + * fills nothing. + */ +function schemeFilledParameters(security: OpenApiSecurityRequirement[] | undefined, doc: OpenApiDocument): Set { + const filled = new Set(); + const schemes = doc.components?.securitySchemes ?? {}; + for (const requirement of security ?? []) { + if (!requirement || typeof requirement !== 'object') continue; + for (const schemeName of Object.keys(requirement)) { + const scheme = Object.prototype.hasOwnProperty.call(schemes, schemeName) ? schemes[schemeName] : undefined; + if (!scheme || scheme.type !== 'apiKey' || typeof scheme.name !== 'string') continue; + if (scheme.in === 'header') filled.add(`header:${scheme.name.toLowerCase()}`); + else if (scheme.in === 'query') filled.add(`query:${scheme.name}`); + } + } + return filled; +} + /** * Flatten paths × methods into a deterministic list of operations, each * carrying its path item's shared `parameters` (see {@link mergeParameters}). @@ -353,9 +479,11 @@ const IGNORED_HEADER_PARAMETERS = new Set(['accept', 'content-type', 'authorizat * breaks this schema, so it must not demand what the caller cannot supply: * the ignored header parameters above, and the header or query parameter * the connector's own `auth` fills (the credential never belongs in a - * flow's `connectorConfig.input`), are left out of it. + * flow's `connectorConfig.input`), are left out of it — and so, for a + * connector with a credential handle, is every parameter an `apiKey` security + * scheme the operation requires places the token in (`schemeFilled`). */ -function buildInputSchema(op: OpenApiOperation, auth: RestAuth): JsonSchema | undefined { +function buildInputSchema(op: OpenApiOperation, auth: RestAuth, schemeFilled: ReadonlySet): JsonSchema | undefined { const sections: Record<'path' | 'query' | 'header', { props: Record; required: string[] }> = { path: { props: {}, required: [] }, query: { props: {}, required: [] }, @@ -369,6 +497,7 @@ function buildInputSchema(op: OpenApiOperation, auth: RestAuth): JsonSchema | un if (p.in !== 'path' && p.in !== 'query' && p.in !== 'header') continue; if (p.in === 'header' && (IGNORED_HEADER_PARAMETERS.has(p.name.toLowerCase()) || p.name.toLowerCase() === authHeader)) continue; if (p.in === 'query' && authQuery !== undefined && p.name === authQuery) continue; + if (schemeFilled.has(p.in === 'header' ? `header:${p.name.toLowerCase()}` : `${p.in}:${p.name}`)) continue; const sec = sections[p.in]; sec.props[p.name] = p.schema ?? (p.description ? { type: 'string', description: p.description } : { type: 'string' }); if (p.required) sec.required.push(p.name); @@ -437,7 +566,7 @@ function buildUrl(baseUrl: string, path: string, query: Record): } /** Apply static auth to the outgoing headers / query (mirrors connector-rest). */ -function applyAuth(auth: RestAuth, headers: Record, query: Record): void { +function applyAuth(auth: RestAuth | BorrowedConnectorAuth, headers: Record, query: Record): void { switch (auth.type) { case 'none': return; diff --git a/packages/connectors/connector-openapi/src/openapi-provider-credential-handle.test.ts b/packages/connectors/connector-openapi/src/openapi-provider-credential-handle.test.ts new file mode 100644 index 00000000000..5ab6a321e96 --- /dev/null +++ b/packages/connectors/connector-openapi/src/openapi-provider-credential-handle.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D4 — the `openapi` provider serves a grant-bearing instance +// (`oauth2`, `app-credential`) through the per-dispatch credential handle, and +// reads the document's `security` for where the token is not sent and which +// inputs it fills. + +import { describe, it, expect } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import type { + BorrowedConnectorAuth, + ConnectorCredentialHandle, + ConnectorCredentialRequest, + ConnectorProviderContext, +} from '@objectstack/spec/integration'; +import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; +import { createOpenApiProviderFactory } from './openapi-provider.js'; +import { ConnectorOpenApiPlugin } from './connector-openapi-plugin.js'; +import type { OpenApiDocument } from './openapi-connector.js'; + +interface Sent { url: string; headers: Record } + +function scriptedFetch(statuses: number[]) { + const sent: Sent[] = []; + const impl = (async (url: string, init: RequestInit) => { + sent.push({ url, headers: { ...(init.headers as Record) } }); + const status = statuses[Math.min(sent.length - 1, statuses.length - 1)]; + return new Response(JSON.stringify({ n: sent.length }), { status, headers: { 'content-type': 'application/json' } }); + }) as unknown as typeof fetch; + return { impl, sent }; +} + +function fakeHandle(present: (token: string) => BorrowedConnectorAuth = (token) => ({ type: 'bearer', token })) { + const asked: Array<{ verb: 'get' | 'refresh'; request?: ConnectorCredentialRequest }> = []; + let n = 1; + const handle: ConnectorCredentialHandle = { + connector: 'vendor', + async get(request) { asked.push({ verb: 'get', request }); return { auth: present(`tok-${n}`) }; }, + async refresh(request) { asked.push({ verb: 'refresh', request }); n += 1; return { auth: present(`tok-${n}`) }; }, + }; + return { handle, asked }; +} + +/** A document whose `listItems` requires the oauth2 scheme, `health` is anonymous, and `wecomUser` takes the token in a query apiKey. */ +const DOC: OpenApiDocument = { + openapi: '3.0.3', + info: { title: 'Vendor' }, + servers: [{ url: 'https://api.example.com' }], + components: { + securitySchemes: { + oauth: { + type: 'oauth2', + flows: { clientCredentials: { tokenUrl: 'https://idp.example.com/token', scopes: { 'items:read': 'read' } } }, + }, + accessToken: { type: 'apiKey', in: 'query', name: 'access_token' }, + }, + }, + security: [{ oauth: ['items:read'] }], + paths: { + '/items': { get: { operationId: 'listItems', responses: { '200': { description: 'ok' } } } }, + '/health': { get: { operationId: 'health', security: [], responses: { '200': { description: 'ok' } } } }, + '/cgi-bin/user/get': { + get: { + operationId: 'wecomUser', + security: [{ accessToken: [] }], + parameters: [ + { name: 'access_token', in: 'query', required: true, schema: { type: 'string' } }, + { name: 'userid', in: 'query', required: true, schema: { type: 'string' } }, + ], + responses: { '200': { description: 'ok' } }, + }, + }, + }, +}; + +function ctx(partial: Partial): ConnectorProviderContext { + return { name: 'vendor', label: 'Vendor', type: 'api', providerConfig: { spec: DOC }, ...partial }; +} + +const dispatch = (organizationId?: string) => ({ automation: organizationId === undefined ? {} : { tenantId: organizationId } }); + +describe('openapi provider — a grant-bearing instance through its credential handle (ADR-0143 D4)', () => { + it('asks the handle on every request in the name of the run\'s organization, and sends the bearer token', async () => { + const { impl, sent } = scriptedFetch([200]); + const { handle, asked } = fakeHandle(); + const { handlers } = await createOpenApiProviderFactory({ fetchImpl: impl })(ctx({ credentialHandle: handle })); + + await handlers.listItems({}, dispatch('org_a')); + + expect(asked).toEqual([{ verb: 'get', request: { organizationId: 'org_a' } }]); + expect(sent[0].headers.Authorization).toBe('Bearer tok-1'); + }); + + it('⛔ an upstream 401 refreshes once and retries once; a second 401 ends in the seam\'s 503', async () => { + const once = scriptedFetch([401, 200]); + const first = fakeHandle(); + const a = await createOpenApiProviderFactory({ fetchImpl: once.impl })(ctx({ credentialHandle: first.handle })); + await expect(a.handlers.listItems({}, dispatch('org_a'))).resolves.toMatchObject({ status: 200 }); + expect(once.sent.map((s) => s.headers.Authorization)).toEqual(['Bearer tok-1', 'Bearer tok-2']); + + const twice = scriptedFetch([401]); + const b = await createOpenApiProviderFactory({ fetchImpl: twice.impl })(ctx({ credentialHandle: fakeHandle().handle })); + await expect(b.handlers.listItems({}, dispatch('org_a'))).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect(twice.sent).toHaveLength(2); + }); + + it('⛔ an operation whose `security` is empty is anonymous: no token is sent and the handle is not asked', async () => { + const { impl, sent } = scriptedFetch([200]); + const { handle, asked } = fakeHandle(); + const { handlers } = await createOpenApiProviderFactory({ fetchImpl: impl })(ctx({ credentialHandle: handle })); + + await handlers.health({}, dispatch('org_a')); + + expect(asked).toEqual([]); + expect(sent[0].headers.Authorization).toBeUndefined(); + }); + + it('⛔ a parameter an `apiKey` scheme the operation requires names is the platform\'s to fill, not the flow\'s input', async () => { + const { impl, sent } = scriptedFetch([200]); + const { handle } = fakeHandle((token) => ({ type: 'api-key', key: token, headerName: 'access_token', paramName: 'access_token' })); + const { def, handlers } = await createOpenApiProviderFactory({ fetchImpl: impl })(ctx({ credentialHandle: handle })); + + const schema = def.actions?.find((a) => a.key === 'wecomUser')?.inputSchema as { properties: { query: { properties: Record; required: string[] } } }; + expect(Object.keys(schema.properties.query.properties)).toEqual(['userid']); + expect(schema.properties.query.required).toEqual(['userid']); + + await handlers.wecomUser({ query: { userid: 'zhangsan' } }, dispatch()); + const url = new URL(sent[0].url); + expect(url.searchParams.get('access_token')).toBe('tok-1'); + expect(url.searchParams.get('userid')).toBe('zhangsan'); + }); + + it('CONTROL: without a credential handle the document\'s security changes nothing — the parameter stays an input', async () => { + const { def } = await createOpenApiProviderFactory({ fetchImpl: scriptedFetch([200]).impl })(ctx({})); + const schema = def.actions?.find((a) => a.key === 'wecomUser')?.inputSchema as { properties: { query: { properties: Record } } }; + expect(Object.keys(schema.properties.query.properties)).toEqual(['access_token', 'userid']); + }); + + it('⛔ refuses a credential handle beside a request signer: a request is authenticated one way', async () => { + const signer = { connector: 'vendor', scheme: 'aliyun-acs3-hmac-sha256' as const, sign: async () => ({ headers: {} }) }; + await expect(createOpenApiProviderFactory()(ctx({ credentialHandle: fakeHandle().handle, requestSigner: signer }))) + .rejects.toThrow(/a request signer and a credential handle/); + }); + + it('the plugin registers the provider as a credential-handle consumer', async () => { + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + kernel.use(new AutomationServicePlugin()); + kernel.use(new ConnectorOpenApiPlugin()); + await kernel.bootstrap(); + expect(kernel.getService('automation').providerAppliesCredentialHandle('openapi')).toBe(true); + await kernel.shutdown(); + }); +}); diff --git a/packages/connectors/connector-openapi/src/openapi-provider.ts b/packages/connectors/connector-openapi/src/openapi-provider.ts index ded7449ce37..d6ea910d55a 100644 --- a/packages/connectors/connector-openapi/src/openapi-provider.ts +++ b/packages/connectors/connector-openapi/src/openapi-provider.ts @@ -234,8 +234,11 @@ async function loadOpenApiDocument( * boot the automation service invokes it for each `provider: 'openapi'` * declarative instance: it loads the OpenAPI document from `providerConfig.spec`, * then produces the same `{ def, handlers }` bundle {@link createOpenApiConnector} - * generates for a hand-wired OpenAPI connector — one action per operation over a - * static-auth HTTP transport, with the resolved `auth` applied. + * generates for a hand-wired OpenAPI connector — one action per operation over + * an HTTP transport, with the resolved `auth` applied — or, for a grant-bearing + * instance (`oauth2`, `app-credential`), the `credentialHandle` asked on every + * request (ADR-0143 D4); the document's `security` decides which operations + * send it (see `createOpenApiConnector`). * * `providerConfig.include` (optional) narrows the actions to an allowlist of * operation keys, through the generator's own `include` predicate. @@ -278,6 +281,8 @@ export function createOpenApiProviderFactory(deps: OpenApiProviderDeps = {}): Co auth, // ADR-0143 D2.3 — a `signed` instance's signer, applied to every attempt. requestSigner: ctx.requestSigner, + // ADR-0143 D4 — a grant-bearing instance's per-dispatch credential. + credentialHandle: ctx.credentialHandle, include, // ADR-0049 · #18975 — the authored resilience policy, already resolved by // the materializer, reaches the transport this bundle closes over. diff --git a/packages/connectors/connector-openapi/vitest.config.ts b/packages/connectors/connector-openapi/vitest.config.ts index 4d2ddd1ded7..4673e46da5c 100644 --- a/packages/connectors/connector-openapi/vitest.config.ts +++ b/packages/connectors/connector-openapi/vitest.config.ts @@ -63,6 +63,11 @@ export default defineConfig({ find: /^@objectstack\/service-automation$/, replacement: path.resolve(__dirname, '../../services/service-automation/src/index.ts'), }, + { + // `src/openapi-connector.ts` reads `callWithBorrowedCredential` from it (ADR-0143 D4, #26702). + find: /^@objectstack\/types$/, + replacement: path.resolve(__dirname, '../../types/src/index.ts'), + }, ], }, }); diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index 303be2031ec..60544fa9700 100644 --- a/packages/connectors/connector-rest/package.json +++ b/packages/connectors/connector-rest/package.json @@ -28,7 +28,8 @@ }, "dependencies": { "@objectstack/core": "workspace:*", - "@objectstack/spec": "workspace:*" + "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*" }, "devDependencies": { "@objectstack/service-automation": "workspace:*", diff --git a/packages/connectors/connector-rest/src/connector-rest-plugin.ts b/packages/connectors/connector-rest/src/connector-rest-plugin.ts index 1e94486338a..5f3cef936fc 100644 --- a/packages/connectors/connector-rest/src/connector-rest-plugin.ts +++ b/packages/connectors/connector-rest/src/connector-rest-plugin.ts @@ -20,7 +20,18 @@ export interface ConnectorRegistrySurface { >, ): void; unregisterConnector(name: string): void; - registerConnectorProvider(providerKey: string, factory: ConnectorProviderFactory): void; + /** + * `options.credentialHandle: true` says the factory asks + * `ConnectorProviderContext.credentialHandle` on every request, so the host + * serves grant-bearing instances (`oauth2`, `app-credential`) through it + * (ADR-0143 D4). An older host ignores the third argument and keeps + * holding those instances degraded. + */ + registerConnectorProvider( + providerKey: string, + factory: ConnectorProviderFactory, + options?: { readonly credentialHandle?: boolean }, + ): void; } /** @@ -69,6 +80,8 @@ export class ConnectorRestPlugin implements Plugin { automation.registerConnectorProvider( REST_PROVIDER_KEY, createRestProviderFactory({ fetchImpl: this.options.fetchImpl }), + // ADR-0143 D4 — the transport asks the credential handle per request. + { credentialHandle: true }, ); ctx.logger.info("ConnectorRestPlugin: registered 'rest' connector provider"); } diff --git a/packages/connectors/connector-rest/src/index.ts b/packages/connectors/connector-rest/src/index.ts index 87397f785e6..9c30564c02e 100644 --- a/packages/connectors/connector-rest/src/index.ts +++ b/packages/connectors/connector-rest/src/index.ts @@ -8,8 +8,9 @@ * dispatch node + an empty connector registry; this plugin populates the * registry with a `rest` connector exposing a `request` action. * - * Static auth only (`none` / `api-key` / `basic` / `bearer`); OAuth2 refresh, - * credential vaulting, and multi-tenant lifecycle are the enterprise tier. + * Static auth (`none` / `api-key` / `basic` / `bearer`), a request signer + * (ADR-0143 D2.3), or a per-dispatch credential handle for the grant-bearing + * kinds `oauth2` and `app-credential` (ADR-0143 D4, open tier per D1). */ export { diff --git a/packages/connectors/connector-rest/src/rest-connector.ts b/packages/connectors/connector-rest/src/rest-connector.ts index 737a193c804..a0721192b9a 100644 --- a/packages/connectors/connector-rest/src/rest-connector.ts +++ b/packages/connectors/connector-rest/src/rest-connector.ts @@ -1,8 +1,15 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { Connector, ConnectorRequestSigner, RetryConfig } from '@objectstack/spec/integration'; +import type { + BorrowedConnectorAuth, + Connector, + ConnectorCredentialHandle, + ConnectorRequestSigner, + RetryConfig, +} from '@objectstack/spec/integration'; import { connectorFetchOptions, readConnectorResponse } from '@objectstack/spec/integration'; import { resilientFetch } from '@objectstack/spec/shared'; +import { callWithBorrowedCredential, withoutBorrowedCredential } from '@objectstack/types'; /** * Generic REST connector — the reference *concrete* connector (ADR-0018 @@ -10,17 +17,22 @@ import { resilientFetch } from '@objectstack/spec/shared'; * its one action, `request`, which the baseline `connector_action` node * dispatches to. * - * Open-source scope: **static** auth only (`none` / `api-key` / `basic` / - * `bearer`), with credentials supplied by the caller. OAuth2 token acquisition - * and refresh, credential vaulting, and multi-tenant connection lifecycle are - * the enterprise tier (see `../cloud/docs/design/connector-tiering.md`) and are - * deliberately out of scope here. + * Three ways to authenticate, one per request: * - * A request-signing instance (ADR-0143 D2.3) passes a `requestSigner` instead - * of a credential: every attempt is signed by it — retries included — and its - * response verification, when the scheme has one, runs before a body is - * returned (`connectorFetchOptions` / `readConnectorResponse`, the one site - * for both built-in HTTP connectors). + * - **static** auth (`none` / `api-key` / `basic` / `bearer`), the credential + * supplied by the caller; + * - a **credential handle** (ADR-0143 D4) for a grant-bearing instance — + * `oauth2` or `app-credential`, open tier since ADR-0143 D1: the handle is + * asked on every request through `callWithBorrowedCredential` + * (`@objectstack/types`, the one copy of the call rule), so an expired + * token is refreshed before it is sent and an upstream `401` refreshes it + * once and retries once. The token itself is never held here; + * + * - a **request signer** (ADR-0143 D2.3) instead of a credential: every + * attempt is signed by it — retries included — and its response + * verification, when the scheme has one, runs before a body is returned + * (`connectorFetchOptions` / `readConnectorResponse`, the one site for + * both built-in HTTP connectors). */ /** Auth config understood by the REST connector (the static subset). */ @@ -44,6 +56,13 @@ export interface RestConnectorOptions { * static `auth` other than `none` — a request is authenticated one way. */ requestSigner?: ConnectorRequestSigner; + /** + * Per-dispatch credential of a grant-bearing instance (ADR-0143 D4): asked + * on every request, refreshed before an expired credential is sent and once + * on an upstream `401`. Exclusive with a static `auth` other than `none` + * and with `requestSigner` — a request is authenticated one way. + */ + credentialHandle?: ConnectorCredentialHandle; /** Headers merged into every request (request-level headers win). */ defaultHeaders?: Record; /** @@ -99,7 +118,7 @@ function buildUrl(baseUrl: string, path: string, query?: RestRequestInput['query * query so an `api-key` configured with `paramName` can ride the query string. */ function applyAuth( - auth: RestAuth, + auth: RestAuth | BorrowedConnectorAuth, headers: Record, query: Record, ): void { @@ -121,15 +140,33 @@ function applyAuth( } } +/** + * The organization a dispatch runs in, read from the host's handler context + * (`ConnectorActionContext.automation.tenantId` in + * `@objectstack/service-automation`). A credential handle selects the + * organization's connection by it (ADR-0143 Q1 = A); absent — a schedule, a + * caller with no context — the handle answers as D4 rules for a run with none. + */ +function organizationOfDispatch(ctx: unknown): string | undefined { + const tenantId = (ctx as { automation?: { tenantId?: unknown } } | null | undefined)?.automation?.tenantId; + return typeof tenantId === 'string' && tenantId.length > 0 ? tenantId : undefined; +} + export function createRestConnector(opts: RestConnectorOptions): RestConnectorBundle { const name = opts.name ?? 'rest'; const auth: RestAuth = opts.auth ?? { type: 'none' }; - if (opts.requestSigner && auth.type !== 'none') { + const ways = [ + auth.type !== 'none' ? `a static '${auth.type}' credential` : undefined, + opts.requestSigner ? 'a request signer' : undefined, + opts.credentialHandle ? 'a credential handle' : undefined, + ].filter((w): w is string => w !== undefined); + if (ways.length > 1) { throw new Error( - `createRestConnector: connector '${name}' is given both a static '${auth.type}' credential and a request signer; ` + + `createRestConnector: connector '${name}' is given ${ways.join(' and ')}; ` + 'a request is authenticated one way — pass one of them.', ); } + const handle = opts.credentialHandle; // Resolved once per connector, not per request: the policy is fixed for the // bundle's lifetime, and a declarative instance re-materializes (with a new // bundle) whenever the author edits it. @@ -143,7 +180,7 @@ export function createRestConnector(opts: RestConnectorOptions): RestConnectorBu name, label: opts.label ?? 'REST Connector', type: 'api', - description: 'Generic REST/HTTP connector with static authentication.', + description: 'Generic REST/HTTP connector.', icon: 'globe', authentication: auth, // Defaulted by ConnectorSchema; set explicitly so the literal satisfies @@ -157,7 +194,7 @@ export function createRestConnector(opts: RestConnectorOptions): RestConnectorBu { key: 'request', label: 'HTTP Request', - description: 'Send an HTTP request to the connector\'s base URL with static auth applied.', + description: 'Send an HTTP request to the connector\'s base URL with the connector\'s authentication applied.', inputSchema: { type: 'object', properties: { @@ -180,26 +217,44 @@ export function createRestConnector(opts: RestConnectorOptions): RestConnectorBu ], }; - async function request(input: Record): Promise> { + async function request(input: Record, ctx: unknown): Promise> { const req = input as RestRequestInput; const method = (req.method ?? 'GET').toUpperCase(); - const headers: Record = { ...opts.defaultHeaders, ...req.headers }; - const query: Record = { ...req.query }; - applyAuth(auth, headers, query); + /** One attempt, with the static auth or the credential the handle answered. */ + const send = (credential?: BorrowedConnectorAuth): Promise => { + const headers: Record = { ...opts.defaultHeaders, ...req.headers }; + const query: Record = { ...req.query }; + applyAuth(credential ?? auth, headers, query); - const url = buildUrl(opts.baseUrl, req.path ?? '', query); + const url = buildUrl(opts.baseUrl, req.path ?? '', query); - const hasBody = req.body !== undefined && method !== 'GET' && method !== 'HEAD'; - if (hasBody && headers['Content-Type'] === undefined && headers['content-type'] === undefined) { - headers['Content-Type'] = 'application/json'; - } + const hasBody = req.body !== undefined && method !== 'GET' && method !== 'HEAD'; + if (hasBody && headers['Content-Type'] === undefined && headers['content-type'] === undefined) { + headers['Content-Type'] = 'application/json'; + } + + return resilientFetch(url, { + method, + headers, + body: hasBody ? JSON.stringify(req.body) : undefined, + }, fetchOptions); + }; - const response = await resilientFetch(url, { - method, - headers, - body: hasBody ? JSON.stringify(req.body) : undefined, - }, fetchOptions); + // ADR-0143 D4 — ask the handle on every request; a `401` refreshes + // once and retries once, a second `401` ends in the seam's `503` + // refusal naming the instance. A thrown transport error is scrubbed + // of the credential first: a query-parameter token rides the URL. + const response = handle + ? await callWithBorrowedCredential(handle, async (credential) => { + try { + return await send(credential); + } catch (err) { + if (err instanceof Error) err.message = withoutBorrowedCredential(err.message, credential); + throw err; + } + }, { organizationId: organizationOfDispatch(ctx) }) + : await send(); // Parse JSON when advertised; fall back to text so non-JSON endpoints // don't throw. A signed instance's response is verified first, when diff --git a/packages/connectors/connector-rest/src/rest-provider-credential-handle.test.ts b/packages/connectors/connector-rest/src/rest-provider-credential-handle.test.ts new file mode 100644 index 00000000000..875c305be8d --- /dev/null +++ b/packages/connectors/connector-rest/src/rest-provider-credential-handle.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D4 — the `rest` provider serves a grant-bearing instance (`oauth2`, +// `app-credential`) through the per-dispatch credential handle: asked on every +// request, refreshed once on an upstream `401` and retried once. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import type { + BorrowedConnectorAuth, + ConnectorCredentialHandle, + ConnectorCredentialRequest, + ConnectorProviderContext, +} from '@objectstack/spec/integration'; +import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; +import { createRestProviderFactory } from './rest-provider.js'; +import { ConnectorRestPlugin } from './connector-rest-plugin.js'; + +interface Sent { url: string; headers: Record } + +/** A fetch answering each call with the next status of `statuses` (the last one repeats). */ +function scriptedFetch(statuses: number[]) { + const sent: Sent[] = []; + const impl = (async (url: string, init: RequestInit) => { + sent.push({ url, headers: { ...(init.headers as Record) } }); + const status = statuses[Math.min(sent.length - 1, statuses.length - 1)]; + return new Response(JSON.stringify({ n: sent.length }), { status, headers: { 'content-type': 'application/json' } }); + }) as unknown as typeof fetch; + return { impl, sent }; +} + +/** A handle answering `tok-1`, then `tok-2` after each refresh, recording what it was asked in the name of. */ +function fakeHandle(present: (token: string) => BorrowedConnectorAuth = (token) => ({ type: 'bearer', token })) { + const asked: Array<{ verb: 'get' | 'refresh'; request?: ConnectorCredentialRequest }> = []; + let n = 1; + const handle: ConnectorCredentialHandle = { + connector: 'vendor', + async get(request) { asked.push({ verb: 'get', request }); return { auth: present(`tok-${n}`) }; }, + async refresh(request) { asked.push({ verb: 'refresh', request }); n += 1; return { auth: present(`tok-${n}`) }; }, + }; + return { handle, asked }; +} + +function ctx(partial: Partial): ConnectorProviderContext { + return { name: 'vendor', label: 'Vendor', type: 'api', providerConfig: { baseUrl: 'https://api.example.com' }, ...partial }; +} + +const dispatch = (organizationId?: string) => ({ automation: organizationId === undefined ? {} : { tenantId: organizationId } }); + +describe('rest provider — a grant-bearing instance through its credential handle (ADR-0143 D4)', () => { + it('asks the handle on every request in the name of the run\'s organization, and sends the bearer token', async () => { + const { impl, sent } = scriptedFetch([200]); + const { handle, asked } = fakeHandle(); + const { handlers } = await createRestProviderFactory({ fetchImpl: impl })(ctx({ credentialHandle: handle })); + + await handlers.request({ path: '/a' }, dispatch('org_a')); + await handlers.request({ path: '/b' }, dispatch('org_b')); + + expect(asked).toEqual([ + { verb: 'get', request: { organizationId: 'org_a' } }, + { verb: 'get', request: { organizationId: 'org_b' } }, + ]); + expect(sent.map((s) => s.headers.Authorization)).toEqual(['Bearer tok-1', 'Bearer tok-1']); + }); + + it('a run with no organization asks the handle with none (D4 answers it from the only connection)', async () => { + const { impl } = scriptedFetch([200]); + const { handle, asked } = fakeHandle(); + const { handlers } = await createRestProviderFactory({ fetchImpl: impl })(ctx({ credentialHandle: handle })); + await handlers.request({ path: '/a' }, dispatch()); + expect(asked).toEqual([{ verb: 'get', request: {} }]); + }); + + it('⛔ an upstream 401 refreshes once and retries once with the new token', async () => { + const { impl, sent } = scriptedFetch([401, 200]); + const { handle, asked } = fakeHandle(); + const { handlers } = await createRestProviderFactory({ fetchImpl: impl })(ctx({ credentialHandle: handle })); + + const out = await handlers.request({ path: '/a' }, dispatch('org_a')); + + expect(out).toMatchObject({ status: 200, ok: true }); + expect(asked.map((a) => a.verb)).toEqual(['get', 'refresh']); + expect(sent.map((s) => s.headers.Authorization)).toEqual(['Bearer tok-1', 'Bearer tok-2']); + }); + + it('⛔ a second 401 ends in the seam\'s 503 refusal naming the instance, never the upstream\'s answer', async () => { + const { impl, sent } = scriptedFetch([401]); + const { handle } = fakeHandle(); + const { handlers } = await createRestProviderFactory({ fetchImpl: impl })(ctx({ credentialHandle: handle })); + + const err = await handlers.request({ path: '/a' }, dispatch('org_a')).catch((e: unknown) => e as Error & { code?: string; status?: number }); + expect(err).toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect((err as Error).message).toContain("connector instance 'vendor'"); + expect(sent).toHaveLength(2); + }); + + it('a handle\'s refusal is the dispatch\'s failure, and nothing is sent', async () => { + const { impl, sent } = scriptedFetch([200]); + const refusal = Object.assign(new Error("connector instance 'vendor' is not connected"), { code: 'SERVICE_UNAVAILABLE', status: 503 }); + const handle: ConnectorCredentialHandle = { connector: 'vendor', get: async () => { throw refusal; }, refresh: async () => { throw refusal; } }; + const { handlers } = await createRestProviderFactory({ fetchImpl: impl })(ctx({ credentialHandle: handle })); + await expect(handlers.request({ path: '/a' }, dispatch('org_a'))).rejects.toBe(refusal); + expect(sent).toHaveLength(0); + }); + + it('places a vendor token where the handle says: a header (DingTalk), or a query parameter (WeCom)', async () => { + const { impl, sent } = scriptedFetch([200]); + const header = fakeHandle((token) => ({ type: 'api-key', key: token, headerName: 'x-acs-dingtalk-access-token' })); + const query = fakeHandle((token) => ({ type: 'api-key', key: token, headerName: 'access_token', paramName: 'access_token' })); + const factory = createRestProviderFactory({ fetchImpl: impl }); + await (await factory(ctx({ credentialHandle: header.handle }))).handlers.request({ path: '/v1.0/x' }, dispatch()); + await (await factory(ctx({ credentialHandle: query.handle }))).handlers.request({ path: '/cgi-bin/x' }, dispatch()); + + expect(sent[0].headers['x-acs-dingtalk-access-token']).toBe('tok-1'); + expect(new URL(sent[1].url).searchParams.get('access_token')).toBe('tok-1'); + expect(sent[1].headers.access_token).toBeUndefined(); + }); + + it('⛔ a thrown transport error is scrubbed of the token a query parameter carried', async () => { + const impl = (async (url: string) => { throw new Error(`connect failed for ${url}`); }) as unknown as typeof fetch; + const { handle } = fakeHandle((token) => ({ type: 'api-key', key: `${token}-secretvalue`, headerName: 'access_token', paramName: 'access_token' })); + const { handlers } = await createRestProviderFactory({ fetchImpl: impl })( + ctx({ credentialHandle: handle, retryConfig: { strategy: 'no_retry' } as ConnectorProviderContext['retryConfig'] }), + ); + const err = await handlers.request({ path: '/a' }, dispatch()).catch((e: unknown) => e as Error); + expect(err.message).toContain('connect failed'); + expect(err.message).not.toContain('tok-1-secretvalue'); + }); + + it('⛔ refuses a credential handle beside a static credential: a request is authenticated one way', () => { + const { handle } = fakeHandle(); + expect(() => createRestProviderFactory()(ctx({ credentialHandle: handle, auth: { type: 'bearer', token: 't' } }))) + .toThrow(/a static 'bearer' credential and a credential handle/); + }); +}); + +describe('ConnectorRestPlugin — registers the provider as a credential-handle consumer (ADR-0143 D4)', () => { + afterEach(() => { vi.unstubAllGlobals(); }); + + /** Boot the automation service, the REST plugin and a registry declaring `declared`. */ + async function boot(declared: unknown[], credentialResolver: (ref: string) => string | undefined) { + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + kernel.use(new AutomationServicePlugin({ credentialResolver })); + kernel.use({ + name: 'test.registry', + type: 'standard', + version: '1.0.0', + dependencies: ['com.objectstack.service-automation'], + async init(c: { registerService(name: string, svc: unknown): void }) { + c.registerService('objectql', { registry: { listItems: (t: string) => (t === 'connector' ? declared : []) } }); + }, + async start() {}, + } as never); + kernel.use(new ConnectorRestPlugin()); + await kernel.bootstrap(); + return { kernel, engine: kernel.getService('automation') }; + } + + it('says so on registration, so the host serves grant-bearing instances through it', async () => { + const { kernel, engine } = await boot([], () => undefined); + expect(engine.providerAppliesCredentialHandle('rest')).toBe(true); + await kernel.shutdown(); + }); + + it('⛔ end to end: a WeCom `app-credential` instance re-exchanges an early-invalidated token once and retries the call', async () => { + const calls: string[] = []; + let token = 0; + let apiCalls = 0; + vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => { + const url = new URL(String(input)); + calls.push(`${url.host}${url.pathname}`); + const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } }); + if (url.host === 'qyapi.weixin.qq.com' && url.pathname === '/cgi-bin/gettoken') { + token += 1; + return json({ errcode: 0, errmsg: 'ok', access_token: `wecom-token-${token}`, expires_in: 7200 }); + } + apiCalls += 1; + // WeCom answers an early-invalidated token inside an ordinary answer. + return url.searchParams.get('access_token') === 'wecom-token-1' + ? json({ errcode: 42001, errmsg: 'access_token expired' }) + : json({ errcode: 0, errmsg: 'ok', userid: 'zhangsan' }); + })); + const { kernel, engine } = await boot( + [{ + name: 'wecom_api', + label: 'WeCom', + type: 'api', + provider: 'rest', + providerConfig: { baseUrl: 'https://qyapi.weixin.qq.com' }, + auth: { type: 'app-credential', vendor: 'wecom', appId: 'ww_corp', credentialRef: 'WECOM_SECRET' }, + }], + (ref) => (ref === 'WECOM_SECRET' ? 'corp-secret-value' : undefined), + ); + + expect(engine.getConnectorDegradedReason('wecom_api')).toBeUndefined(); + const out = await engine.resolveConnectorAction('wecom_api', 'request')!( + { path: '/cgi-bin/user/get', query: { userid: 'zhangsan' } }, + { automation: { tenantId: 'org_a' } } as never, + ); + + expect(out).toMatchObject({ status: 200, body: { errcode: 0, userid: 'zhangsan' } }); + expect(token).toBe(2); + expect(apiCalls).toBe(2); + expect(calls).toEqual([ + 'qyapi.weixin.qq.com/cgi-bin/gettoken', + 'qyapi.weixin.qq.com/cgi-bin/user/get', + 'qyapi.weixin.qq.com/cgi-bin/gettoken', + 'qyapi.weixin.qq.com/cgi-bin/user/get', + ]); + await kernel.shutdown(); + }); +}); diff --git a/packages/connectors/connector-rest/src/rest-provider.ts b/packages/connectors/connector-rest/src/rest-provider.ts index 8ad39275b6c..3f8215bce76 100644 --- a/packages/connectors/connector-rest/src/rest-provider.ts +++ b/packages/connectors/connector-rest/src/rest-provider.ts @@ -31,7 +31,12 @@ function isStringRecord(v: unknown): v is Record { * automation service invokes it for each `provider: 'rest'` declarative instance, * turning `providerConfig.baseUrl` (+ the resolved `auth`) into the same * `{ def, handlers }` bundle {@link createRestConnector} produces for a - * hand-wired REST connector — one `request` action over static-auth HTTP. + * hand-wired REST connector — one `request` action over HTTP. + * + * A grant-bearing instance (`oauth2`, `app-credential`) arrives with + * `ctx.credentialHandle` instead of `ctx.auth` (ADR-0143 D4), and the + * transport asks it on every request; this package registers the factory + * with `{ credentialHandle: true }` to say so (`./connector-rest-plugin.ts`). * * Hard-fails on invalid config (missing `baseUrl`), so a misconfigured instance * fails boot loudly rather than materializing a dead connector. @@ -59,6 +64,8 @@ export function createRestProviderFactory(deps: RestProviderDeps = {}): Connecto auth, // ADR-0143 D2.3 — a `signed` instance's signer, applied to every attempt. requestSigner: ctx.requestSigner, + // ADR-0143 D4 — a grant-bearing instance's per-dispatch credential. + credentialHandle: ctx.credentialHandle, defaultHeaders: isStringRecord(cfg.defaultHeaders) ? cfg.defaultHeaders : undefined, // ADR-0049 · #18975 — the authored resilience policy, already resolved by // the materializer, reaches the transport this bundle closes over. diff --git a/packages/connectors/connector-rest/vitest.config.ts b/packages/connectors/connector-rest/vitest.config.ts index 092f3349553..297fb7fd761 100644 --- a/packages/connectors/connector-rest/vitest.config.ts +++ b/packages/connectors/connector-rest/vitest.config.ts @@ -1,4 +1,38 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// This package needs nothing beyond the shared preset; see its header. -export { default } from '../../../vitest.preset.mts'; +/** + * The shared preset's one setting plus one anchored `resolve.alias` entry, and + * nothing else. + * + * `src/rest-connector.ts` reads `callWithBorrowedCredential` from + * `@objectstack/types` (ADR-0143 D4, #26702). Without the entry that import + * resolves through the package's `exports` to its built `dist/`, so this + * package's tests would judge build state rather than the source in the + * checkout. `scripts/check-test-source-alias.mjs` named it; its registry is + * shrink-only, so the entry belongs here, not there. The package's other + * unaliased imports stay that registry's to retire. + * + * Array form with an ANCHORED pattern: the object form matches by prefix, so a + * bare key whose replacement is a file also swallows every subpath. + */ + +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + // A late console.* must not redden a green suite (#10374): the shared + // preset's one setting, spelled here because this config replaces it. + // Mechanism + measured costs: examples/app-showcase/vitest.config.ts. + // Enforced repo-wide by scripts/check-console-intercept-disarm.mjs. + disableConsoleIntercept: true, + }, + resolve: { + alias: [ + { + find: /^@objectstack\/types$/, + replacement: path.resolve(__dirname, '../../types/src/index.ts'), + }, + ], + }, +}); diff --git a/packages/services/service-automation/src/connector-materialization.test.ts b/packages/services/service-automation/src/connector-materialization.test.ts index 9126935e867..4ccbae44aa5 100644 --- a/packages/services/service-automation/src/connector-materialization.test.ts +++ b/packages/services/service-automation/src/connector-materialization.test.ts @@ -1076,13 +1076,40 @@ describe('ADR-0143 D4 — a grant-bearing instance is served through its credent 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 () => { + it('serves an `app-credential` instance through its credential handle on an opted-in provider, with no static auth (ADR-0143 D2.2)', 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'); + const engine = automationOf(kernel); + expect(calls).toHaveLength(1); + expect(calls[0].auth).toBeUndefined(); + expect(calls[0].credentialHandle).toMatchObject({ connector: 'vendor' }); + expect(engine.getConnectorDegradedReason('vendor')).toBeUndefined(); + // ⛔ The app secret never reaches the provider: only the handle does. + expect(JSON.stringify(calls[0])).not.toContain('resolved-secret'); + await kernel.shutdown(); + }); + + it('⛔ holds an `app-credential` instance degraded on a provider that does not ask the credential handle', async () => { + const { factory, calls } = makeFakeProvider(); + const kernel = await boot( + [providerConnector('vendor', { auth: { type: 'app-credential', vendor: 'wecom', appId: 'ww_x', credentialRef: 'SECRET' } })], + { providerFactory: factory, credentialResolver: () => 'resolved-secret' }, + ); + expectHeld(automationOf(kernel), calls, "auth type 'app-credential', but provider 'fake' does not yet ask the per-dispatch credential handle", 'never called without its credential'); + await kernel.shutdown(); + }); + + it('holds an `app-credential` instance whose app secret does not resolve, naming the reference', async () => { + const { factory, calls } = makeFakeProvider(); + const kernel = await boot( + [providerConnector('vendor', { auth: { type: 'app-credential', vendor: 'dingtalk', appId: 'ding_x', credentialRef: 'DING_SECRET' } })], + { providerFactory: factory, providerOptions: { credentialHandle: true }, credentialResolver: () => undefined }, + ); + expect(calls).toHaveLength(0); + expect(automationOf(kernel).getConnectorDegradedReason('vendor')).toContain('DING_SECRET'); await kernel.shutdown(); }); diff --git a/packages/services/service-automation/src/oauth2-grant/app-credential.test.ts b/packages/services/service-automation/src/oauth2-grant/app-credential.test.ts new file mode 100644 index 00000000000..3d56c8c8b01 --- /dev/null +++ b/packages/services/service-automation/src/oauth2-grant/app-credential.test.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0143 D2.2 / D4 — the vendor app-credential exchanges and their handle. + +import { describe, it, expect } from 'vitest'; +import { isConnectorUpstreamUnavailable } from '@objectstack/spec/integration'; +import { + APP_CREDENTIAL_EXCHANGES, + createAppCredentialHandle, + requestAppCredentialToken, + retryOnVendorTokenRefusal, +} from './app-credential.js'; + +const SECRET = 'app-secret-value-0123'; + +interface Seen { url: string; method?: string; headers: Record; body?: string } + +/** A fetch answering with `answer(n)` for the n-th call (1-based). */ +function fetchAnswering(answer: (n: number, seen: Seen) => Response) { + const seen: Seen[] = []; + const impl = (async (url: string, init: RequestInit = {}) => { + const s: Seen = { + url, + method: init.method, + headers: { ...(init.headers as Record) }, + body: typeof init.body === 'string' ? init.body : undefined, + }; + seen.push(s); + return answer(seen.length, s); + }) as unknown as typeof fetch; + return { impl, seen }; +} + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }); + +describe('the vendor exchanges, each as its vendor documents it (ADR-0143 D2.2)', () => { + it('feishu — client credentials at accounts.feishu.cn, form-encoded; the token is a bearer token', async () => { + const { impl, seen } = fetchAnswering(() => json({ code: 0, access_token: 't-feishu', expires_in: 7200, token_type: 'Bearer' })); + const token = await requestAppCredentialToken({ + connector: 'lark', exchange: APP_CREDENTIAL_EXCHANGES.feishu, appId: 'cli_a1', appSecret: SECRET, fetch: impl, now: () => 1_000, + }); + expect(seen[0]).toMatchObject({ url: 'https://accounts.feishu.cn/oauth/v3/token', method: 'POST' }); + expect(seen[0].headers['content-type']).toBe('application/x-www-form-urlencoded'); + expect(Object.fromEntries(new URLSearchParams(seen[0].body))).toEqual({ + grant_type: 'client_credentials', client_id: 'cli_a1', client_secret: SECRET, + }); + expect(token).toEqual({ accessToken: 't-feishu', expiresAt: 1_000 + 7_200_000 }); + expect(APP_CREDENTIAL_EXCHANGES.feishu.present('t-feishu')).toEqual({ type: 'bearer', token: 't-feishu' }); + }); + + it('dingtalk — the v1.0 accessToken API with a JSON body; the token rides x-acs-dingtalk-access-token', async () => { + const { impl, seen } = fetchAnswering(() => json({ accessToken: 't-ding', expireIn: 7200 })); + const token = await requestAppCredentialToken({ + connector: 'ding', exchange: APP_CREDENTIAL_EXCHANGES.dingtalk, appId: 'ding_key', appSecret: SECRET, fetch: impl, now: () => 0, + }); + expect(seen[0]).toMatchObject({ url: 'https://api.dingtalk.com/v1.0/oauth2/accessToken', method: 'POST' }); + expect(JSON.parse(seen[0].body ?? '')).toEqual({ appKey: 'ding_key', appSecret: SECRET }); + expect(token).toEqual({ accessToken: 't-ding', expiresAt: 7_200_000 }); + expect(APP_CREDENTIAL_EXCHANGES.dingtalk.present('t-ding')).toEqual({ + type: 'api-key', key: 't-ding', headerName: 'x-acs-dingtalk-access-token', + }); + }); + + it('wecom — gettoken with corpid and corpsecret; the token rides the access_token query parameter', async () => { + const { impl, seen } = fetchAnswering(() => json({ errcode: 0, errmsg: 'ok', access_token: 't-wecom', expires_in: 7200 })); + const token = await requestAppCredentialToken({ + connector: 'wecom', exchange: APP_CREDENTIAL_EXCHANGES.wecom, appId: 'ww_corp', appSecret: SECRET, fetch: impl, now: () => 0, + }); + const url = new URL(seen[0].url); + expect(`${url.origin}${url.pathname}`).toBe('https://qyapi.weixin.qq.com/cgi-bin/gettoken'); + expect(seen[0].method).toBe('GET'); + expect(Object.fromEntries(url.searchParams)).toEqual({ corpid: 'ww_corp', corpsecret: SECRET }); + expect(token).toEqual({ accessToken: 't-wecom', expiresAt: 7_200_000 }); + expect(APP_CREDENTIAL_EXCHANGES.wecom.present('t-wecom')).toMatchObject({ type: 'api-key', key: 't-wecom', paramName: 'access_token' }); + }); + + it.each([ + ['feishu', json({ code: 20002, error: 'invalid_client', error_description: `bad ${SECRET}` }, 400), '20002'], + ['dingtalk', json({ code: 'invalidClientIdOrSecret', message: `bad ${SECRET}` }, 400), 'invalidClientIdOrSecret'], + ['wecom', json({ errcode: 40001, errmsg: `invalid secret ${SECRET}` }), '40001'], + ] as const)('⛔ %s — a refused exchange is the seam\'s 503 naming the vendor code, never the vendor\'s text', async (vendor, answer, code) => { + const { impl } = fetchAnswering(() => answer); + const err = (await requestAppCredentialToken({ + connector: 'inst', exchange: APP_CREDENTIAL_EXCHANGES[vendor], appId: 'id', appSecret: SECRET, fetch: impl, + }).catch((e: unknown) => e)) as Error & { code?: string; status?: number }; + expect(err).toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect(err.message).toContain(`(${code})`); + expect(err.message).toContain("connector instance 'inst'"); + expect(err.message).not.toContain(SECRET); + }); + + it('a 2xx answer without a token is refused too', async () => { + const { impl } = fetchAnswering(() => json({ errcode: 0, errmsg: 'ok' })); + await expect(requestAppCredentialToken({ + connector: 'inst', exchange: APP_CREDENTIAL_EXCHANGES.wecom, appId: 'id', appSecret: SECRET, fetch: impl, + })).rejects.toMatchObject({ status: 503 }); + }); + + it.each([429, 500, 503])('an HTTP %i from the token endpoint is operational: CONNECTOR_UPSTREAM_UNAVAILABLE', async (status) => { + const { impl } = fetchAnswering(() => json({}, status)); + const err = await requestAppCredentialToken({ + connector: 'inst', exchange: APP_CREDENTIAL_EXCHANGES.feishu, appId: 'id', appSecret: SECRET, fetch: impl, + }).catch((e: unknown) => e); + expect(isConnectorUpstreamUnavailable(err)).toBe(true); + }); + + it('⛔ no answer is operational, and the refusal carries neither the URL nor the transport\'s error (WeCom\'s URL holds the secret)', async () => { + const impl = (async (url: string) => { throw new Error(`getaddrinfo failed for ${url}`); }) as unknown as typeof fetch; + const err = (await requestAppCredentialToken({ + connector: 'inst', exchange: APP_CREDENTIAL_EXCHANGES.wecom, appId: 'id', appSecret: SECRET, fetch: impl, + }).catch((e: unknown) => e)) as Error; + expect(isConnectorUpstreamUnavailable(err)).toBe(true); + expect(err.message).not.toContain(SECRET); + expect((err as { cause?: unknown }).cause).toBeUndefined(); + }); + + it('the token request is never sent after a redirect', async () => { + const { impl } = fetchAnswering(() => json({ code: 0, access_token: 't', expires_in: 60 })); + let init: RequestInit | undefined; + const spy = (async (url: string, i: RequestInit) => { init = i; return impl(url, i); }) as unknown as typeof fetch; + await requestAppCredentialToken({ connector: 'inst', exchange: APP_CREDENTIAL_EXCHANGES.feishu, appId: 'id', appSecret: SECRET, fetch: spy }); + expect(init?.redirect).toBe('error'); + }); +}); + +describe('the app-credential handle (ADR-0143 D4)', () => { + it('exchanges once, then answers the cached token until the skew margin; concurrent asks share one exchange', async () => { + let clock = 0; + const { impl, seen } = fetchAnswering((n) => json({ code: 0, access_token: `t-${n}`, expires_in: 120 })); + const handle = createAppCredentialHandle({ connector: 'lark', vendor: 'feishu', appId: 'id', appSecret: SECRET, fetch: impl, now: () => clock }); + + const [a, b] = await Promise.all([handle.get(), handle.get({ organizationId: 'org_a' })]); + expect(a.auth).toEqual({ type: 'bearer', token: 't-1' }); + expect(b.auth).toEqual({ type: 'bearer', token: 't-1' }); + expect(a.expiresAt).toBe(120_000); + expect(seen).toHaveLength(1); + + clock = 89_000; // 31 s left: still outside the 30 s margin + expect((await handle.get()).auth).toEqual({ type: 'bearer', token: 't-1' }); + clock = 91_000; // inside the margin + expect((await handle.get()).auth).toEqual({ type: 'bearer', token: 't-2' }); + expect(seen).toHaveLength(2); + }); + + it('⛔ refresh drops the held token and exchanges again; concurrent refreshes share one exchange', async () => { + const { impl, seen } = fetchAnswering((n) => json({ errcode: 0, access_token: `t-${n}`, expires_in: 7200 })); + const handle = createAppCredentialHandle({ connector: 'wecom', vendor: 'wecom', appId: 'ww', appSecret: SECRET, fetch: impl, now: () => 0 }); + await handle.get(); + const [x, y] = await Promise.all([handle.refresh(), handle.refresh()]); + expect(x.auth).toMatchObject({ key: 't-2' }); + expect(y.auth).toMatchObject({ key: 't-2' }); + expect(seen).toHaveLength(2); + }); +}); + +describe('the refresh-once rule on a vendor\'s in-body token refusal — WeCom early invalidation (ADR-0143 D4, Q3)', () => { + const refused = { status: 200, ok: true, body: { errcode: 42001, errmsg: 'access_token expired' } }; + const accepted = { status: 200, ok: true, body: { errcode: 0, errmsg: 'ok' } }; + + function handleSpy() { + const refreshed: unknown[] = []; + return { refreshed, handle: { refresh: async (r?: unknown) => { refreshed.push(r); return { auth: { type: 'bearer' as const, token: 'x' } }; } } }; + } + + it.each([40014, 42001])('⛔ errcode %i refreshes the handle once and repeats the call once', async (errcode) => { + const { refreshed, handle } = handleSpy(); + let n = 0; + const handlers = retryOnVendorTokenRefusal('wecom', 'wecom', handle, { + request: async () => (++n === 1 ? { ...refused, body: { errcode } } : accepted), + }); + await expect(handlers.request({}, { automation: { tenantId: 'org_a' } })).resolves.toEqual(accepted); + expect(n).toBe(2); + expect(refreshed).toEqual([{ organizationId: 'org_a' }]); + }); + + it('⛔ a second refusal ends in the seam\'s 503 naming the instance, and is not retried again', async () => { + const { refreshed, handle } = handleSpy(); + let n = 0; + const handlers = retryOnVendorTokenRefusal('wecom', 'wecom', handle, { request: async () => { n += 1; return refused; } }); + await expect(handlers.request({}, {})).rejects.toMatchObject({ code: 'SERVICE_UNAVAILABLE', status: 503 }); + expect(n).toBe(2); + expect(refreshed).toHaveLength(1); + }); + + it('CONTROL: any other answer is returned as is, with no refresh', async () => { + const { refreshed, handle } = handleSpy(); + const other = { status: 200, ok: true, body: { errcode: 60011, errmsg: 'no privilege' } }; + const handlers = retryOnVendorTokenRefusal('wecom', 'wecom', handle, { request: async () => other }); + await expect(handlers.request({}, {})).resolves.toBe(other); + expect(refreshed).toHaveLength(0); + }); + + it('a vendor that answers a refused token with a 401 keeps its handlers unwrapped (the provider\'s rule covers it)', () => { + const handlers = { request: async () => accepted }; + expect(retryOnVendorTokenRefusal('lark', 'feishu', handleSpy().handle, handlers)).toBe(handlers); + expect(retryOnVendorTokenRefusal('ding', 'dingtalk', handleSpy().handle, handlers)).toBe(handlers); + }); +}); diff --git a/packages/services/service-automation/src/oauth2-grant/app-credential.ts b/packages/services/service-automation/src/oauth2-grant/app-credential.ts new file mode 100644 index 00000000000..100507fe8ed --- /dev/null +++ b/packages/services/service-automation/src/oauth2-grant/app-credential.ts @@ -0,0 +1,348 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # The vendor app-credential exchanges (ADR-0143 D2.2, D4) + * + * An `app-credential` instance trades an app id and an app secret for a + * short-lived access token, non-interactively. That is not an RFC 6749 grant: + * each vendor spells the request, the response and where the token rides on + * the outgoing call differently. So each value of the closed + * `ConnectorAppCredentialVendorSchema` names ONE exchange, implemented here + * against the vendor's published documentation, and nothing about it is + * authored (D2.2): the author writes `vendor`, `appId` and the `credentialRef` + * that names the secret. + * + * | vendor | token request | token answer | the token on a call | + * |:---|:---|:---|:---| + * | `feishu` | `POST https://accounts.feishu.cn/oauth/v3/token`, form: `grant_type=client_credentials`, `client_id`, `client_secret` | `code` 0, `access_token`, `expires_in` | `Authorization: Bearer` | + * | `dingtalk` | `POST https://api.dingtalk.com/v1.0/oauth2/accessToken`, JSON `appKey`, `appSecret` | `accessToken`, `expireIn` | header `x-acs-dingtalk-access-token` | + * | `wecom` | `GET https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=…&corpsecret=…` | `errcode` 0, `access_token`, `expires_in` | query parameter `access_token` | + * + * Sources: Feishu "自建应用获取 tenant_access_token" + * (open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/authentication-management/access-token/tenant_access_token); + * DingTalk "获取企业内部应用的 accessToken" (open.dingtalk.com, the v1.0 API that + * replaces the legacy `oapi.dingtalk.com/gettoken`); WeCom "获取 access_token" + * (developer.work.weixin.qq.com/document/path/91039) and its global error codes + * (developer.work.weixin.qq.com/document/path/90455). + * + * **Per dispatch** (D4): the token lives in this module's memory only — never + * persisted (D3, last bullet: the configured secret re-derives it at any + * time). It is refreshed before it expires (the seam's skew margin), once on + * an upstream `401` (`callWithBorrowedCredential` in the provider), and — for + * a vendor that refuses an early-invalidated token inside a `2xx` answer, as + * WeCom does with `errcode` 40014 / 42001 — once on that answer + * ({@link retryOnVendorTokenRefusal}). Concurrent requests share one exchange + * (single flight). The token belongs to the app, so a run's organization + * selects nothing. + * + * **Failure** follows D4's split: no answer, a timeout, `429` or a `5xx` is + * `CONNECTOR_UPSTREAM_UNAVAILABLE`, retried on the existing backoff; a refused + * app id or secret, or an answer without a token, is the connector seam's + * `503 SERVICE_UNAVAILABLE`, which the instance's configuration fixes. + * + * ⛔ No vendor text is carried — a token endpoint that echoes the secret back + * would carry it into a refusal or a log line. A refusal names the instance, + * the vendor and, at most, the vendor's error code. WeCom sends the secret in + * the request URL, so no error here carries the URL or the transport's own + * error as a cause. + */ + +import { + BORROWED_CREDENTIAL_SKEW_MS, + connectorCredentialRefusal, + type BorrowedConnectorAuth, + type BorrowedConnectorCredential, + type ConnectorCredentialRequest, +} from '@objectstack/types'; +import { + ConnectorUpstreamUnavailableError, + type ConnectorAppCredentialVendor, + type ConnectorMaterializationHandler, +} from '@objectstack/spec/integration'; +import type { OAuth2GrantHandle } from './grant-handle.js'; +import { DEFAULT_TOKEN_TIMEOUT_MS } from './token-endpoint.js'; + +/** One exchange's answer, reduced to what the handle keeps. */ +export interface AppCredentialToken { + readonly accessToken: string; + /** Epoch ms; absent when the vendor did not say. */ + readonly expiresAt?: number; +} + +/** What a vendor's exchange is: where the token is asked for, how it is read, and how it rides. */ +export interface AppCredentialExchange { + readonly vendor: ConnectorAppCredentialVendor; + /** The request that exchanges the app id and secret. */ + request(appId: string, appSecret: string): { readonly url: string; readonly init: RequestInit }; + /** + * The token in the answer, or the vendor's error code when the answer + * refuses (`undefined` when it carries none). Called for every status the + * runner does not read as operational. + */ + read(status: number, body: unknown): { readonly token: string; readonly expiresInSeconds?: number } | { readonly refused: string | undefined }; + /** How the token rides on an outgoing call. */ + present(token: string): BorrowedConnectorAuth; + /** + * Whether a call's result is the upstream refusing the token inside an + * answer that is not a `401` — an early-invalidated or expired token. + * Absent for a vendor that answers `401`. + */ + refusesToken?(result: Record): boolean; +} + +const asRecord = (v: unknown): Record => + v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : {}; + +/** A vendor error code fit to name in a refusal: a short word or number, never free text. */ +function codeOf(value: unknown): string | undefined { + const text = typeof value === 'number' && Number.isFinite(value) ? String(value) : value; + return typeof text === 'string' && /^[A-Za-z0-9_.-]{1,64}$/.test(text) ? text : undefined; +} + +function seconds(value: unknown): number | undefined { + const n = typeof value === 'string' ? Number(value) : value; + return typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : undefined; +} + +/** Feishu: the self-built app's `tenant_access_token`, RFC 6749 client credentials on `accounts.feishu.cn` (client_secret_post). */ +const FEISHU: AppCredentialExchange = { + vendor: 'feishu', + request(appId, appSecret) { + return { + url: 'https://accounts.feishu.cn/oauth/v3/token', + init: { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, + body: new URLSearchParams({ grant_type: 'client_credentials', client_id: appId, client_secret: appSecret }).toString(), + }, + }; + }, + read(status, body) { + const b = asRecord(body); + const code = b.code; + if (status >= 200 && status < 300 && (code === 0 || code === undefined) && typeof b.access_token === 'string' && b.access_token.length > 0) { + return { token: b.access_token, expiresInSeconds: seconds(b.expires_in) }; + } + return { refused: codeOf(code !== 0 ? code : undefined) ?? codeOf(b.error) }; + }, + present: (token) => ({ type: 'bearer', token }), +}; + +/** DingTalk: the internal app's access token from the v1.0 API, sent in its own header on api.dingtalk.com calls. */ +const DINGTALK: AppCredentialExchange = { + vendor: 'dingtalk', + request(appId, appSecret) { + return { + url: 'https://api.dingtalk.com/v1.0/oauth2/accessToken', + init: { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ appKey: appId, appSecret }), + }, + }; + }, + read(status, body) { + const b = asRecord(body); + if (status >= 200 && status < 300 && typeof b.accessToken === 'string' && b.accessToken.length > 0) { + return { token: b.accessToken, expiresInSeconds: seconds(b.expireIn) }; + } + return { refused: codeOf(b.code) }; + }, + present: (token) => ({ type: 'api-key', key: token, headerName: 'x-acs-dingtalk-access-token' }), +}; + +/** + * WeCom's global error codes for a call whose `access_token` is no longer + * valid: 40014 "不合法的access_token" and 42001 "access_token已过期". WeCom + * may invalidate a token before `expires_in` and answers such a call inside + * an ordinary answer, so these are its `401`. + */ +const WECOM_TOKEN_REFUSED = new Set([40014, 42001]); + +/** WeCom: `gettoken` with the corp id and the application secret; the token rides every call as `access_token`. */ +const WECOM: AppCredentialExchange = { + vendor: 'wecom', + request(appId, appSecret) { + const url = new URL('https://qyapi.weixin.qq.com/cgi-bin/gettoken'); + url.searchParams.set('corpid', appId); + url.searchParams.set('corpsecret', appSecret); + return { url: url.toString(), init: { method: 'GET', headers: { accept: 'application/json' } } }; + }, + read(status, body) { + const b = asRecord(body); + if (status >= 200 && status < 300 && b.errcode === 0 && typeof b.access_token === 'string' && b.access_token.length > 0) { + return { token: b.access_token, expiresInSeconds: seconds(b.expires_in) }; + } + return { refused: codeOf(b.errcode) }; + }, + present: (token) => ({ type: 'api-key', key: token, headerName: 'access_token', paramName: 'access_token' }), + refusesToken(result) { + const errcode = asRecord(result.body).errcode; + return typeof errcode === 'number' && WECOM_TOKEN_REFUSED.has(errcode); + }, +}; + +/** The exchange each closed vendor value names. A vendor enters the spec enum together with its row here. */ +export const APP_CREDENTIAL_EXCHANGES: Readonly> = { + feishu: FEISHU, + dingtalk: DINGTALK, + wecom: WECOM, +}; + +/** One exchange request. */ +export interface AppCredentialTokenRequest { + readonly connector: string; + readonly exchange: AppCredentialExchange; + readonly appId: string; + readonly appSecret: string; + readonly timeoutMs?: number; + readonly fetch?: typeof fetch; + readonly now?: () => number; +} + +/** + * Run one vendor exchange. Throws `CONNECTOR_UPSTREAM_UNAVAILABLE` when the + * vendor did not answer usefully now, the seam's `503 SERVICE_UNAVAILABLE` + * when it refused or answered without a token; never returns a partial answer. + */ +export async function requestAppCredentialToken(request: AppCredentialTokenRequest): Promise { + const { connector, exchange } = request; + const fetchImpl = request.fetch ?? fetch; + const now = request.now ?? Date.now; + const subject = `the ${exchange.vendor} token endpoint of connector instance '${connector}'`; + const { url, init } = exchange.request(request.appId, request.appSecret); + const sentAt = now(); + let response: Response; + try { + response = await fetchImpl(url, { + ...init, + // The answer is read here, never followed: a redirect would carry the secret elsewhere. + redirect: 'error', + signal: AbortSignal.timeout(request.timeoutMs ?? DEFAULT_TOKEN_TIMEOUT_MS), + }); + } catch { + // No cause: the transport's error may carry the request URL, and WeCom's carries the secret. + throw new ConnectorUpstreamUnavailableError(`${subject} did not answer`); + } + if (response.status === 429 || response.status >= 500) { + throw new ConnectorUpstreamUnavailableError(`${subject} answered HTTP ${response.status}`); + } + let body: unknown; + try { + body = await response.json(); + } catch { + body = undefined; + } + const answer = exchange.read(response.status, body); + if ('refused' in answer) { + throw connectorCredentialRefusal( + `${subject} refused the exchange (${answer.refused ?? `HTTP ${response.status}`}): check the instance's appId and ` + + 'the app secret its credentialRef names, and that the app is enabled at the vendor', + 'SERVICE_UNAVAILABLE', + 503, + ); + } + return { + accessToken: answer.token, + // Measured from when the request was SENT, so network time shortens the token's life. + ...(answer.expiresInSeconds === undefined ? {} : { expiresAt: sentAt + answer.expiresInSeconds * 1000 }), + }; +} + +export interface AppCredentialHandleDeps { + readonly connector: string; + readonly vendor: ConnectorAppCredentialVendor; + readonly appId: string; + /** The resolved app secret. */ + readonly appSecret: string; + readonly timeoutMs?: number; + readonly fetch?: typeof fetch; + readonly now?: () => number; +} + +/** + * The per-dispatch credential handle of one `app-credential` instance. It + * carries the connect door's `seed` / `forget` only so the plugin keeps one + * handle type: an app credential is never connected, so `seed` does nothing. + */ +export function createAppCredentialHandle(deps: AppCredentialHandleDeps): OAuth2GrantHandle { + const exchange = APP_CREDENTIAL_EXCHANGES[deps.vendor]; + const now = deps.now ?? Date.now; + let held: AppCredentialToken | undefined; + let inflight: Promise | undefined; + const fresh = (token: AppCredentialToken | undefined): token is AppCredentialToken => + token !== undefined && (token.expiresAt === undefined || token.expiresAt - BORROWED_CREDENTIAL_SKEW_MS > now()); + const fill = (): Promise => { + inflight ??= requestAppCredentialToken({ + connector: deps.connector, + exchange, + appId: deps.appId, + appSecret: deps.appSecret, + ...(deps.timeoutMs === undefined ? {} : { timeoutMs: deps.timeoutMs }), + ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }), + now, + }).then((token) => { held = token; return token; }).finally(() => { inflight = undefined; }); + return inflight; + }; + const toCredential = (token: AppCredentialToken): BorrowedConnectorCredential => ({ + auth: exchange.present(token.accessToken), + ...(token.expiresAt === undefined ? {} : { expiresAt: token.expiresAt }), + }); + return { + connector: deps.connector, + async get() { + return toCredential(fresh(held) ? held : await fill()); + }, + async refresh() { + // A refresh already in flight is the one this caller shares. + if (!inflight) held = undefined; + return toCredential(await fill()); + }, + seed() { /* an app credential is never connected */ }, + forget() { held = undefined; }, + }; +} + +/** The organization a dispatch runs in, as the host's handler context carries it. */ +function organizationOf(ctx: unknown): string | undefined { + const tenantId = (ctx as { automation?: { tenantId?: unknown } } | null | undefined)?.automation?.tenantId; + return typeof tenantId === 'string' && tenantId.length > 0 ? tenantId : undefined; +} + +/** + * D4's refresh-once rule for a vendor that refuses a token inside an ordinary + * answer rather than with a `401` ({@link AppCredentialExchange.refusesToken} + * — WeCom's early invalidation): a result the vendor reads as a refused token + * refreshes the handle once and repeats the call once; a second refusal ends + * in the seam's `503` naming the instance, never the vendor's text. The + * provider's own `401` rule is untouched. A vendor without the predicate gets + * `handlers` back unchanged. + */ +export function retryOnVendorTokenRefusal( + connector: string, + vendor: ConnectorAppCredentialVendor, + handle: Pick, + handlers: Record, +): Record { + const refuses = APP_CREDENTIAL_EXCHANGES[vendor].refusesToken; + if (!refuses) return handlers; + const wrapped: Record = {}; + for (const [key, handler] of Object.entries(handlers)) { + wrapped[key] = async (input, ctx) => { + const first = await handler(input, ctx); + if (!refuses(first)) return first; + const request: ConnectorCredentialRequest = {}; + const organizationId = organizationOf(ctx); + await handle.refresh(organizationId === undefined ? request : { organizationId }); + const second = await handler(input, ctx); + if (!refuses(second)) return second; + throw connectorCredentialRefusal( + `the ${vendor} upstream of connector instance '${connector}' refused its access token, and refused it again ` + + 'after it was obtained afresh. Check that the app the instance\'s appId names may call this API.', + 'SERVICE_UNAVAILABLE', + 503, + ); + }; + } + return wrapped; +} diff --git a/packages/services/service-automation/src/oauth2-grant/index.ts b/packages/services/service-automation/src/oauth2-grant/index.ts index 94946a0ab73..58ec93704e6 100644 --- a/packages/services/service-automation/src/oauth2-grant/index.ts +++ b/packages/services/service-automation/src/oauth2-grant/index.ts @@ -1,9 +1,10 @@ // 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 + * The grant-bearing instance kinds' runtime (ADR-0143 D4, D5): the `oauth2` + * token endpoint call, its per-dispatch credential handle and the connect + * flow's PKCE and pending state; and the `app-credential` vendor exchanges + * with their handle (D2.2). The connection record they read and write is * `../connector-connections.ts`. */ @@ -11,3 +12,4 @@ export * from './token-endpoint.js'; export * from './grant-handle.js'; export * from './authorization.js'; export * from './endpoint-transport.js'; +export * from './app-credential.js'; diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 5eafd9a535b..f51c733f6c4 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -68,6 +68,7 @@ import { createConnectorRequestSigner } from './request-signing/index.js'; import { ConnectorConnectionStore, type ConnectorConnectionRecord } from './connector-connections.js'; import { buildAuthorizationUrl, + createAppCredentialHandle, createAuthorizationState, createOAuth2GrantHandle, createPkcePair, @@ -79,6 +80,7 @@ import { PENDING_AUTHORIZATION_TTL_SECONDS, reconnectRequiredReason, requestOAuth2Token, + retryOnVendorTokenRefusal, type OAuth2GrantHandle, type PendingAuthorizationStore, } from './oauth2-grant/index.js'; @@ -440,16 +442,17 @@ type GrantBearingInstanceAuth = Extract; /** - * 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. + * ADR-0143 D4 — why a grant-bearing instance cannot be served through + * `provider`: the provider was not registered as asking the per-dispatch + * credential handle on every request (`registerConnectorProvider(…, { + * credentialHandle: true })` — the built-in `rest` and `openapi` providers + * are), so its calls would go out without the token. The instance is held + * degraded with this reason, never called unauthenticated. */ -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.'; +function credentialHandleNotAppliedReason(name: string, provider: string, kind: GrantBearingInstanceAuth['type']): string { + return `connector instance '${name}' declares auth type '${kind}', 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.'; } /** @@ -465,11 +468,7 @@ function appCredentialNotServedReason(name: string, auth: Extract