Skip to content

server: atomic claim on setup-token redemption#281

Closed
erikolsson wants to merge 1 commit into
security/hash-setup-tokensfrom
security/atomic-token-redemption
Closed

server: atomic claim on setup-token redemption#281
erikolsson wants to merge 1 commit into
security/hash-setup-tokensfrom
security/atomic-token-redemption

Conversation

@erikolsson

@erikolsson erikolsson commented May 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • The previous redemption flow in /v1/auth/exchange read a setupTokens row with redeemed=false, then UPDATEd to redeemed=true keyed only by id. There was no re-assertion of the predicate, no transaction, and no row lock — two concurrent calls with the same token both passed the check, both proceeded, and the downstream delete apiKeys WHERE deviceId=? + insert apiKeys path could leave two valid keys for the same device. Each caller walked away with a working long-lived API key.
  • Replace with a single atomic UPDATE … SET redeemed=true WHERE token=? AND redeemed=false AND expires_at > now() RETURNING *. Postgres serializes the row lock; one caller's UPDATE matches and returns the row, the loser matches zero rows and gets a 400. Expiry moves into the WHERE so an expired token also fails atomically (the prior code had a tiny window where it could redeem-then-fail-on-expiry).

Why this shape

The codebase already uses the same atomic-claim pattern in:

  • apps/server/src/oauth/mcp.ts — auth-code consumption + refresh-token rotation
  • apps/server/src/auth/sessions.ts — refresh-token rotation via DELETE … RETURNING

This brings setup-token redemption into line with that prior art. No shared helper extracted because each callsite uses different tables/predicates and abstracting prematurely would obscure the per-table invariants.

Stacked on #280

Branched on top of security/hash-setup-tokens (#280) so reviewers can read the two changes independently. GitHub will auto-retarget to main when #280 merges.

Audit context

PR 2 of 3 from the desktop pairing security review. PR 3 will add a unique index on api_keys.device_id so the corrupted state above is unrepresentable at the schema level even if a future regression reintroduces the race.

Test plan

  • bun run typecheck (apps/server) — clean
  • bun run test (apps/server) — 290 pass / 0 fail
  • Manual: redeem a setup token end-to-end; confirm replaying the same token returns 400; confirm an expired token returns 400

🤖 Generated with Claude Code


Note

Medium Risk
Touches authentication/token-issuance flow and changes database write semantics; mistakes could block legitimate setup-token redemption or allow unintended replays if predicates are wrong.

Overview
Fixes a race in POST /v1/auth/exchange by replacing the SELECT-then-UPDATE setup-token redemption with a single atomic UPDATE … WHERE token AND redeemed=false AND expiresAt>now() RETURNING.

This makes setup-token redemption one-time under concurrency and folds expiry validation into the claim step, returning 400 when no row is claimed.

Reviewed by Cursor Bugbot for commit d7bd262. Bugbot is set up for automated code reviews on this repo. Configure here.

The previous flow read setupTokens with redeemed=false, then issued an
UPDATE without re-asserting the predicate. Two concurrent /v1/auth/exchange
calls with the same token both passed the check, both proceeded into the
device upsert + apiKey delete/insert path, and could leave two valid
api_keys rows for the same device — each caller walking away with a
working long-lived API key.

Replace with a single UPDATE … SET redeemed=true WHERE token=? AND
redeemed=false AND expires_at>now() RETURNING *. Postgres serializes the
row lock; one caller's UPDATE matches and returns the row, the loser's
matches zero rows and gets a 400. Expiry check moves into the WHERE so an
expired token also returns zero rows atomically.

Same pattern already used in oauth/mcp.ts (auth-code consumption, refresh
rotation) and auth/sessions.ts (refresh rotation via DELETE…RETURNING).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown

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: CHILL

Plan: Pro

Run ID: d3405b39-719c-4307-90f1-a157ec1befe1

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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/atomic-token-redemption

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 and usage tips.

giuseppecrj added a commit that referenced this pull request May 5, 2026
## Summary

- Combines the security fixes from #280, #281, and #282 on top of
`g/consolidate-server-boundaries` so those separate PRs can be closed
after review.
- Hashes setup tokens at rest while still returning the raw token once
for pairing.
- Claims setup tokens atomically during `/v1/auth/exchange` so
concurrent redemption cannot mint multiple API keys.
- Adds a unique index on `api_keys(device_id)` as the schema-level
one-active-key-per-device guarantee.
- Updates tests for the new invariant and adds an assertion that raw
setup tokens are not stored in the database.

## Validation

- `bun run gen:db-schema:check`
- `bun run typecheck`
- `bun test test/auth-resolvers.test.ts`
- `bun run test` — 296 pass, 0 fail

## Notes

This PR targets `g/consolidate-server-boundaries` intentionally. Once it
lands into the consolidation branch, the older security PRs #280, #281,
and #282 should be redundant.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes security-sensitive auth flows and adds a data-cleanup
migration with a new uniqueness constraint on `api_keys`, which could
affect existing tokens/devices if assumptions are wrong.
> 
> **Overview**
> **Hardens the setup-token pairing flow** by storing only a SHA-256
hash of setup tokens and updating `/v1/auth/exchange` to *atomically*
claim (redeem) tokens while also checking expiry.
> 
> **Enforces one active API key per device** by adding a unique index on
`api_keys.device_id` (with a migration that deletes older duplicate keys
per device) and updating tests/docs to match the new hashing and
uniqueness invariants.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6cede0e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed race condition in setup token redemption that could create
duplicate API keys
  * Enforced one-API-key-per-device constraint

* **Security**
  * Setup tokens are now securely hashed before storage

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Erik Olsson <valross2@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@giuseppecrj

Copy link
Copy Markdown
Contributor

closing due to #289

@giuseppecrj giuseppecrj closed this May 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants