Skip to content

feat(coinbase): enforce per-agent credentials and intent ownership - #203

Open
georgyia wants to merge 4 commits into
feat/coinbase-foundationfrom
feat/coinbase-agent-auth
Open

georgyia wants to merge 4 commits into
feat/coinbase-foundationfrom
feat/coinbase-agent-auth

Conversation

@georgyia

Copy link
Copy Markdown
Collaborator

Summary

Closes #190.

This PR establishes the authenticated agent boundary required before Coinbase wallet or transaction routes can be enabled:

  • issues a unique, expiring agk_... credential per registered agent and stores only a bcrypt verifier plus lookup prefix
  • authenticates agent -> linked user -> intent through one shared ownership guard on quote, result, decision, and card routes
  • adds atomic credential rotation, stable 401/403 error codes, verified-agent audit attribution, and agent-keyed rate limits
  • revokes credentials before best-effort intent, card, and ledger cleanup during unlink
  • migrates existing intent ownership, updates the stub worker, and documents secure OpenClaw onboarding and legacy-client migration

Stack

This PR is stacked on #201 and intentionally targets feat/coinbase-foundation so the review contains only issue #190. After #201 merges, this PR should be retargeted to main.

Security properties

  • WORKER_API_KEY is accepted only as the server-side initial-registration bootstrap; operational routes require X-Agent-Key
  • raw agent credentials are returned once, never persisted, and redacted from structured logs
  • X-Agent-Id never establishes identity and a mismatched value is rejected
  • intent access requires both the authenticated agent ID and its linked user ID to match the persisted owner
  • rotation uses the credential version as an optimistic concurrency guard and invalidates the old key immediately
  • unlink atomically detaches the user and revokes the credential before external cleanup begins
  • future Coinbase routes can reuse claimedAgentAuthMiddleware and requireOwnedIntent; this PR does not expose transaction execution

API and migration

  • first registration: POST /v1/agent/register with X-Worker-Key and {}
  • pairing-code renewal: the same endpoint with the current X-Agent-Key
  • credential rotation: POST /v1/agent/credential/rotate with the current X-Agent-Key
  • legacy agents do not receive an unsafe automatic credential exchange; operators must unlink and re-pair them as documented
  • existing intents are backfilled from the user's current linked agent; unlinked legacy intents remain inaccessible to agent routes

Verification

  • npm run build
  • npm run lint (zero errors)
  • npm run format:check
  • npx prisma validate and npx prisma generate
  • migration deploy against local PostgreSQL; ownership consistency query returned zero missing owners
  • unit tests: 39 suites, 421 tests passed
  • focused authorization tests: 6 suites, 88 tests passed
  • integration tests: 4 suites, 40 tests passed; 96 external-service tests intentionally skipped
  • real PostgreSQL/Redis onboarding flow: 3 tests passed for issuance, hashing, pairing, renewal, and intent ownership
  • repository secret scan found no committed CDP, private-key, or raw generated agent credentials

Reviewer focus

  1. Credential issuance, prefix lookup, expiry, and rotation semantics
  2. Shared agent/user/intent authorization guard and stable error contracts
  3. Revocation-before-cleanup behavior during unlink
  4. Legacy OpenClaw migration and bootstrap-key separation

@coderabbitai

coderabbitai Bot commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7e64e1da-2a8b-430e-935f-0419d17cc10f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/coinbase-agent-auth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@georgyia georgyia added the coinbase Coinbase CDP, AgentKit, wallets, Spend Permissions, and x402 integration label Aug 19, 2026
@georgyia
georgyia requested a review from JonasBaeumer August 19, 2026 05:55
@georgyia georgyia added this to the v.0.1 (first launch) milestone Aug 19, 2026
@georgyia

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Head commit changed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@georgyia

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@georgyia

Copy link
Copy Markdown
Collaborator Author

P1 — moving agentContextHook to onRequest kills the body-derived intentId, and the unit test hides it.

The hook moved from a plugin-scope preHandler to the per-route onRequest array. onRequest fires before body parsing, so request.body is undefined there. Verified on Fastify 5.12.0 with the same route shape this PR uses:

status                        = 200
keyGenerator saw agent id     = agent-abc
request.body during onRequest = undefined

agentContextHook resolves intentId as params?.intentId ?? body?.intentId. The body branch exists precisely for the two routes that take intentId in the body rather than the path — POST /v1/agent/quote and POST /v1/agent/result — and both now log intentId: null. Those are the two routes that move money and advance the state machine, so they are the ones you most want correlated in a log search.

tests/unit/api/agentContext.test.ts:57 ("falls back to intentId in request body when not in params") passes because makeRequest hands the hook a literal body: {...}. The hook is tested in isolation against a request object that cannot occur at the point it is now registered, so the test stays green while the behaviour is dead. This is the same class of regression as #90, which #148 had to restore.

Splitting the two hooks keeps the auth-first short-circuit and gets the body back:

onRequest: [claimedAgentAuthMiddleware],
preHandler: [agentContextHook],

Worth a test that asserts through app.inject rather than a hand-built request, otherwise the next move of this hook repeats it.

P1 — confirm intended: the card-reveal rate limit is now per-agent, not per-intent.

-  return `card-reveal:${intentId}`;
+  return `card-reveal:${req.authenticatedAgent?.id ?? 'unauthenticated'}`;

Old rule: any caller gets 2 reveals per minute for a given intent. New rule: an agent gets 2 reveals per minute across every intent it owns. Keying on intentId was trivially sidestepped by rotating the intent, so tightening it is clearly right — but the new cap now bounds an agent's total checkout throughput at 2 per minute, which is a different thing from throttling retries on one card. Is 2/minute the intended ceiling on concurrent purchases per agent, or should this be per-agent-per-intent (${agent.id}:${intentId}) so an agent working several intents is not queued behind itself?

Whichever you pick, max: 2 deserves a named constant and a one-line comment stating the rule, since the value now encodes a business limit rather than a retry guard.

Note — the rate limiter runs after the credential lookup.

hook: 'preHandler' is the right call for per-agent keying (it is what makes req.authenticatedAgent available to the global keyGenerator, which I confirmed works). The consequence is that every request now costs an indexed PairingCode lookup, and a bcrypt compare at cost 12 whenever the prefix hits, before the limiter can shed load. Unauthenticated floods still bail at isAgentCredentialFormat and the global limiter still falls back to req.ip, so this is bounded — just worth knowing that the cheap rejection path moved.

Nothing else. Auth is applied consistently across all seven agent routes with no gaps, the agentId backfill in the migration is correct and the comment states the fail-closed rule for unlinked legacy intents, and the bidirectional claimedUser.agentId === record.agentId check means a stale claimedByUserId cannot resurrect a link. I checked two things that turned out fine: reply.sent still behaves correctly under the Fastify 5 upgrade, and dropping include: { virtualCard: true } from the decision route is safe because that relation was already unused there.

@JonasBaeumer

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 78d14f6e74

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/api/middleware/agentAuth.ts
Comment thread src/app.ts Outdated
Comment thread src/api/routes/intents.ts
@georgyia
georgyia force-pushed the feat/coinbase-agent-auth branch from 78d14f6 to a8511d5 Compare August 29, 2026 22:15
@georgyia

Copy link
Copy Markdown
Collaborator Author

Pushed a8511d5 (rebased onto the updated feat/coinbase-foundation).

P0 — recheck revocation before mutating an intent (@chatgpt-codex-connector). Confirmed and fixed. requireOwnedIntent now re-reads the live PairingCode row and the user link before it returns an intent, checking revocation, credential version, expiry, and both directions of the link. It is a re-read by agent id, not a second bcrypt verification — the credential was already proven, and every field that can change underneath it is observable without it.

Five tests cover the window, and they are shaped so authentication sees the live credential and the change lands only in time for the recheck, which is the race an actually-slow request opens: revoked, unlinked, rotated, and expired all return 401 with the mutation never reached, plus one asserting a live credential still gets through.

P1 — rate-limit before authentication (@chatgpt-codex-connector). Right, and worse than "the limiter runs late": on agent routes it never ran at all for rejected requests. The global limiter is back at onRequest, ahead of every route's auth hook, keyed on IP alone. Agent keying moved to the routes that need it (hook: 'preHandler' per route), where identity is established. New test fires 70 invalid-key requests from one IP and asserts 10 are shed and that the credential store saw at most 60 — it fails with Expected: 10, Received: 0 against the old wiring.

P1 — intent creation without a linked agent (@chatgpt-codex-connector). Not intended. An intent stored with agentId = null was enqueued, advanced to SEARCHING, then failed the ownership check on every agent route forever, with nothing able to attach an agent later. POST /v1/intents now returns 409 agent_not_linked, with a test asserting neither startSearching nor enqueueSearch fires. Documented in docs/api.md.

P1 — onRequest kills the body-derived intentId. Both hooks moved to preHandler, keeping the auth-first ordering. Added three app.inject tests covering body-supplied intentId on quote and result and params-supplied on card; reverting to onRequest fails them with intentId: null, which the hand-built-request unit test could not see.

P1 — card-reveal rate limit key. Left keyed on the agent, since scoping it to agent + intent reopens the bypass that cannot bypass the limit by changing intentId pins. CARD_REVEAL_MAX_PER_MINUTE is now a named constant documenting that this is a business limit rather than a retry guard, what raising or rescoping it costs, and that the single-reveal rule in the card service — not this limit — is what makes a second reveal of the same card impossible. Still your call whether 2/minute is the intended ceiling on an agent's concurrent checkouts; the constant makes it a one-line change either way.

Unit suite is 431 green.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

georgyia added a commit that referenced this pull request Aug 29, 2026
The Coinbase work lands as a stack: #202, #203, and #204 are all based on
feat/coinbase-foundation rather than on main. The pull_request trigger filtered
on branches: [main], so none of those three ran lint, type check, unit tests,
integration tests, or CodeQL -- the only green checks on them were Dependabot and
CodeRabbit, and the gap is invisible because a PR with no matching workflow looks
the same as one with nothing to run.

Dropping the filter costs nothing: a pull_request event still only fires for an
open PR, and same-repo branches keep access to the Stripe secret the integration
job needs.
The authentication hook runs once, early. A caller that has already sent its
headers can hold the connection open and finish after the user unlinks it or the
credential is revoked or rotated; request.authenticatedAgent still holds the
identity captured beforehand, and requireOwnedIntent compared only that cached
value against unchanged intent fields. A compromised agent could therefore post a
quote or a checkout result, or reveal a card, after revocation. Every guarded
mutation now re-reads the live PairingCode row and the user link before acting.
It is a re-read by agent id, not a second bcrypt verification: the credential was
already proven, and revocation, rotation, expiry, and the link are all observable
without it.

The global rate limiter ran at preHandler, behind each agent route's onRequest
authentication hook, so a 401 terminated the request before the limiter counted
it -- an invalid-key flood reached the PairingCode lookup and the bcrypt verifier
unbounded, which is exactly the traffic the advertised 60/min ceiling exists to
shed. It now runs at onRequest keyed on IP alone, ahead of authentication. Agent
keying moves to the routes that need it, where it can run at preHandler with the
identity established.

agentContextHook was registered in the same onRequest array as authentication.
onRequest fires before body parsing, so request.body was undefined and the two
routes carrying intentId in the body -- quote and result, the two that move money
-- logged intentId: null. Both hooks move to preHandler. The existing unit test
passed a hand-built request object that cannot occur at that registration point;
the new tests assert through app.inject and fail against the old wiring.

POST /v1/intents accepted a user with no linked agent and stored agentId = null.
That intent was enqueued, advanced to SEARCHING, and then failed the ownership
check on every agent route for the rest of its life, with nothing able to attach
an agent later. Creation now fails with 409 agent_not_linked.

Also names the two route rate-limit budgets and records why card reveal is keyed
on the agent: the intent-scoped key it replaced was bypassable by rotating the
intent, and the cost is that it bounds an agent's concurrent checkouts.
@georgyia
georgyia force-pushed the feat/coinbase-agent-auth branch from a8511d5 to 7eaec9b Compare August 29, 2026 22:21
requireOwnedIntent now re-reads the live PairingCode row by agentId before a
guarded mutation, but this suite's Prisma double answered only the
credentialPrefix lookup used during authentication. The recheck therefore read
null, treated the credential as revoked, and every ownership, card-reveal, and
state-guard case returned 401 instead of its expected 403, 404, or 409.

Found by CI, which started running on this PR once the pull_request trigger
stopped filtering on branches: [main].
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

coinbase Coinbase CDP, AgentKit, wallets, Spend Permissions, and x402 integration priority: high security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants