Repository navigation
fix(service-automation): harden the OAuth2 connector connection door (ADR-0143 S1 should-fixes) - #28397
Conversation
…S1 should-fixes) - Seal the pending authorization (PKCE verifier, user, organization, redirect URI) with AES-256-GCM under a key derived from the raw state, which the server keeps only as a digest; an edited or moved KV value opens as nothing. Documents the cluster KV trust boundary. - Write a refresh, the "reconnect required" mark and a reconnect only over the revision they read, as one conditional write (where id + revision). - Route connect()/revoke() reads through readOrRefuse (503, never 500). - Never follow a token endpoint redirect; refuse it. - Drop the *.localhost arm of the plain-http loopback allowance. - State the missing RFC 7009 vendor revocation in the route ledger and docs. Claude-Session: https://claude.ai/code/session_01QUs3BvFbD3DRY4t9ByMH8B Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QUs3BvFbD3DRY4t9ByMH8B Co-authored-by: Claude <noreply@anthropic.com>
…e the tenant-audit census The two new engine doubles open with the update/delete dispatch asserts and join the pinned ledger. The store's write sites shrank by two (one conditional write replaces two read-then-update pairs), so the census and its prose counts are re-measured. Claude-Session: https://claude.ai/code/session_01QUs3BvFbD3DRY4t9ByMH8B Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 34 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 632f9e02b7701298c83021dfd28037e347a5d397 && git checkout 632f9e02b7701298c83021dfd28037e347a5d397
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8c8c5b5255109a7d039225e48e8f6b718247a9af f9577543daaf751d07545d586d3555a8a1387a12 && git checkout -B drift-repro 8c8c5b5255109a7d039225e48e8f6b718247a9af && git merge --no-ff f9577543daaf751d07545d586d3555a8a1387a12
node scripts/docs-audit/affected-docs.mjs --json 8c8c5b5255109a7d039225e48e8f6b718247a9af
|
|
domain:server#4 · os-zhuang · session_01QUs3BtyH9zgxKzYrJiH18J ACCEPT · PR #28397 · Head-sha 维护者速读
席位意见:七条都修在点上,封存方案不需要新的 spec 加密作用域,条件写走真实 ObjectQL 引擎测过;可合。 席位核对
|
Requested by Sam · project thread
Fixes #28326
Clause-②: no
Metadata-protocol: none
维护者速读
*.localhost、断开连接不向厂商撤销令牌这一缺口写进路由台账和文档。oauth2-grant/authorization.ts(封存方案与 KV 信任边界)、connector-connections.ts(updateAtRevision条件写)、oauth2-grant/token-endpoint.ts(redirect: 'manual')。pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/connector-connections.test.ts src/oauth2-grant,预期 5 个文件全绿。What changes
All seven items on the card, each measured on
mainat3ba52069first; all seven still stood.state, which the server keeps only as a digest; the envelope's AAD names its KV slotrecordRefresh/markReconnectRequiredread the row, compared the revision, then updated by idupdate(fields, { where: { id, revision }, multi: true }), judged on the affected-row count;connect()'s reconnect uses the same write and refuses503when it loses the raceDELETE …/connectiondeletes locally; the docs already said so, the route ledger row did notreadOrRefuseconnect()andrevoke()did a bareread(), so a throwing store answered an undeclared 500readOrRefuse(503ConnectorCredentialUnavailableError, the engine's message not carried)fetchredirect mode follows a 307/308 with the form body (client secret, refresh token)redirect: 'manual', and any 3xx is refused asOAuth2TokenResponseError(503, configuration)*.localhosthttp:localhost,127.0.0.0/8,[::1]only28326-oauth2-connection-door-hardening.md; the merged26702note is untouchedRoute changes from the dispatch, said plainly
ICryptoProvider. The provider'sCryptoContext.scopeis the closedCRYPTO_CONTEXT_SCOPESset (ADR-0128 D1), which has no member for this producer; adding one is apackages/specchange, outside this claim, and borrowing an existing member is what ADR-0128 forbids. Its handle is also persisted as asys_secretrow, a durable record for a ten-minute value. The state-derived key needs no shared secret, so it holds on a cluster whose replicas share only the KV. The trust boundary this leaves is written in the module header and the docs: a KV reader learns nothing; a KV writer cannot alter or move an entry it did not seal, but can still delete one or plant one under a state it chose itself. Cluster KV write access stays inside the platform's trust boundary, as it is for the cluster lock.redirect: 'manual'plus an explicit refusal, notredirect: 'error'. With'error'a redirect surfaces as a thrownTypeError, which this module reads as "no answer", that isCONNECTOR_UPSTREAM_UNAVAILABLE, retried on the backoff forever. A redirect is a wrongtokenUrl, so it is refused once as a configuration fault. Either way nothing is sent past the first hop.revocation_endpointtoday, so a revoke call would be code no path reaches.Surface beyond the claim's file list
The claim names
connector-connections.ts,oauth2-grant/**,route-ledger.ts,connectors.mdxand the changeset. Also touched, each forced by the change:service-automation/src/connector-connections.test.ts(new): the store's tests, over a real ObjectQL engine on SQLite.scripts/engine-double-contract.pinned.json: the two new engine doubles open withassertEngineUpdateDispatch/assertEngineDeleteDispatch, andcheck:engine-double-contract --writerecorded them.content/docs/permissions/tenant-audit-census.mdxanddocs/audits/2026-08-tenant-audit-write-call-sites.counts.md: the store has two fewer write call sites, sotenant-audit-census.mjs --writere-measured the generated block, and the hand-written prose counts were moved to match (257 to 255, 63 to 62 and 61, 179 to 177, 147 to 145).Why
ADR-0143 S1 put the OAuth2 connection door on
main(PR #28317). Its contract review (record 6539266538) and delta review left these as should-fixes, and the card carries all of them.Tests
src/oauth2-grant/authorization.test.ts: the KV holds no verifier, user, organization, instance, redirect URI or state, only{ v, iv, ct, tag }; an edited envelope, an envelope moved to another state's slot, and a plain value planted in the KV all open as nothing; the memory store round-trips the same envelope.src/connector-connections.test.ts(new, real engine): a refresh, a non-rotating refresh, the reconnect-required mark and a reconnect each write when the row has not moved; a reconnect landing between the refresh (or the refusal, or another reconnect) and its write is never overwritten; a revoked row is not written;connectandrevokeover a store whose read throws answer 503 without the engine's message.src/oauth2-grant/token-endpoint.test.ts: a 307, 308 and 302 from a real local server are refused, and theLocationserver receives nothing.src/oauth2-grant/endpoint-transport.test.ts:http://idp.localhost,http://localhost.localhost,http://localhost@evil.comandhttp://[::ffff:127.0.0.1]are refused. The last two were already refused onmain(the WHATWG parser's host); they are pinned so the rule cannot regress.Acceptance notes
Verification
Ablations (each through scripts/ablation-replace.mjs, restore proven blob == HEAD)
The subjects are imported by relative path from
src/, so nodist/build sits between mutation and test.putstores the plain pending objectopenPendingAuthorizationaccepts a non-envelope as the valueupdateAtRevisionreverts to main's read, compare, update-by-idrevoke()bareread()connect()bareread()redirect: 'manual'removed*.localhostarm restoredidp.localhost,localhost.localhostA first redirect ablation passed an empty replacement; the tool refused it on its own replacement-count check and restored the file before the tests ran, so nothing was read from it. It was re-run with a marker replacement, as above. A first SF3 ablation that dropped
revisionfrom thewherealso turned the by-id route back on and reddened every write test, so it isolated nothing; the read-compare-update form above is the one that reproduces main.Local runs (head f957754)
pnpm --filter @objectstack/service-automation typecheckandtest: 243 files, 3084 tests passed (lock VERDICT command-exit 0).connector-connections,oauth2-grant/*,connector-connection-door,connector-materialization: 7 files, 157 tests passed.packages/runtime:domains/automation-connector-connection-door.test.ts12 passed,route-ledger.conformance.test.ts8 passed,pnpm typecheckexit 0.pnpm lintexit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 101 derived families, all run, every one exit 0;--ranreconciliation: 101 run, 0 NOT-MEASURED, 0 UNRUN (pnpm lintrecorded beyond the derivation). The first pass ond06a0250caughtcheck:engine-double-contractandcheck-tenant-audit-census(and its self-test) red; both fixed inf9577543. Three families needed a full build (check:dual-build-cjs-loads, speccheck:skill-examples, teardown-shape--self-testafter unshallowing) and passed after it.Generated by Claude Code