Skip to content

refactor(security): Stack setup-token security fixes#289

Merged
giuseppecrj merged 6 commits into
mainfrom
codex/setup-token-security-stack
May 5, 2026
Merged

refactor(security): Stack setup-token security fixes#289
giuseppecrj merged 6 commits into
mainfrom
codex/setup-token-security-stack

Conversation

@giuseppecrj

@giuseppecrj giuseppecrj commented May 4, 2026

Copy link
Copy Markdown
Contributor

Summary

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.


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.

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

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

@coderabbitai

coderabbitai Bot commented May 4, 2026

Copy link
Copy Markdown

Walkthrough

This change introduces two security and consistency improvements to the API key and setup token management flow. First, a unique index is added to the api_keys table on the device_id column, enforcing one API key per device. Second, setup tokens are now hashed before persisting to the database, and the setup token redemption endpoint is refactored from a select-then-update pattern to an atomic update-with-where operation that validates token hash, unredeemed state, and expiration simultaneously. Database schema, migrations, and tests are updated to reflect these changes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title 'refactor(security): Stack setup-token security fixes' directly and clearly summarizes the PR's main objective of implementing multiple security fixes for setup token handling.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 codex/setup-token-security-stack

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.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3d428d5. Configure here.

Comment thread apps/server/src/auth/github.ts
@texuf

texuf commented May 4, 2026

Copy link
Copy Markdown
Contributor

fixed a similar thing here: #254, we can go with yours though, heres a summary:
I compared the actual diffs.

Short Version
PR #254 is a focused patch against main. It hardens setup tokens by hashing them at rest and making one setup token redeem only once, using a transaction.

PR #289 is a broader stacked PR against g/consolidate-server-boundaries. It includes the same core setup-token fixes, but adds a database-level guarantee that each device can only have one API key.

PR #254
#254 fix: harden setup token exchange

Solves the problem by:

Generating setup tokens as opaque random base64url bearer tokens instead of UUIDs.
Storing only hashToken(rawSetupToken) in setup_tokens.token.
Hashing the submitted token during /v1/auth/exchange before lookup.
Wrapping redemption, device upsert, old API-key deletion, and new API-key creation in one DB transaction.
Preventing replay with a conditional update:
WHERE setup_token.id = ... AND redeemed = false AND expires_at > now
Switching API keys from UUIDs to opaque random bearer tokens too.
Adding tests for hashed setup-token storage and concurrent redemption.
Main tradeoff: it relies on application logic for “one active API key per device.” There is no schema-level unique constraint on api_keys.device_id, so a future regression or concurrent exchange using different setup tokens could still create duplicate device keys.

PR #289
#289 [codex] Stack setup-token security fixes

Solves the problem by:

Storing only hashed setup tokens at rest.
Hashing the submitted token during /v1/auth/exchange.
Claiming the setup token atomically in a single statement:
UPDATE setup_tokens SET redeemed = true WHERE token = hash AND redeemed = false AND expires_at > now RETURNING *
Adding a unique index on api_keys(device_id).
Updating Drizzle schema, migration metadata, and generated DB docs for that invariant.
Updating tests around hashed setup-token storage and the new one-key-per-device invariant.
Main tradeoff: the setup-token claim is simpler and strong, but the later device/API-key work is not all wrapped in one transaction like #254. The new unique index preserves the invariant, but if two different valid setup tokens race for the same device, one path may fail at insert time instead of being cleanly serialized.

Key Difference
If the only target is “a setup token must not be stored raw and must not be redeemable twice,” both PRs solve it.

If the target also includes “a device must never end up with multiple active API keys,” #289 is stronger because it enforces that in Postgres with api_keys_device_unique.

The best combined shape is probably #289’s schema invariant plus #254’s transactional exchange structure, with a clean conflict-handling path for the unique-index case.

Base automatically changed from g/consolidate-server-boundaries to main May 5, 2026 00:17
erikolsson and others added 5 commits May 4, 2026 21:27
Setup tokens were stored plaintext and looked up by raw equality, so a
read-only DB leak inside the 10-minute expiry window would expose every
pending pairing token. Switch to the same SHA-256-at-rest pattern that
apiKeys.keyHash, refresh sessions, and MCP OAuth tokens already use:
hash on issuance, hash incoming token on /v1/auth/exchange, compare hashes.

Raw token is returned to the caller exactly once and never re-read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
Add unique index on api_keys.device_id. Without this, a regression in the
/v1/auth/exchange redemption flow (which does DELETE + INSERT on api_keys
keyed by device_id) could leave two rows for the same device — the exact
corrupted state a recent audit identified as reachable via concurrent
setup-token redemptions. With the index in place, the second INSERT
violates the constraint and fails loudly instead of silently producing
two long-lived API keys.

No defensive cleanup in the migration: if rows violating the constraint
exist, that is itself a signal worth investigating, not silently fixing.
Auto-deleting "older" rows could just as easily revoke the legitimate
user's key as the attacker's, depending on which redemption arrived first.

api_keys is small (~one row per active device), so a non-CONCURRENT
CREATE UNIQUE INDEX completes in milliseconds.

Refresh docs/generated/db-schema.md to reflect the new index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@giuseppecrj giuseppecrj force-pushed the codex/setup-token-security-stack branch from 9a522b9 to 12088b7 Compare May 5, 2026 01:29
@giuseppecrj giuseppecrj marked this pull request as ready for review May 5, 2026 02:16
@giuseppecrj giuseppecrj changed the title [codex] Stack setup-token security fixes refactor(security): Stack setup-token security fixes May 5, 2026

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/server/src/auth/github.ts (1)

266-315: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make the exchange fully transactional around device/key replacement.

The setup-token claim is atomic now, but the later device upsert plus api_keys delete/insert still spans multiple statements. With the new api_keys_device_unique index, two different valid setup tokens exchanged concurrently for the same {userId, deviceName} can either 500 on insert or revoke the first request's freshly issued key after it was already returned. This needs one DB transaction so key replacement is deterministic and the token claim rolls back if issuance fails.

One safe shape
-        const tokenHash = await hashToken(body.token);
-        const [st] = await db
-          .update(setupTokens)
-          .set({ redeemed: true })
-          .where(
-            and(
-              eq(setupTokens.token, tokenHash),
-              eq(setupTokens.redeemed, false),
-              gt(setupTokens.expiresAt, new Date()),
-            ),
-          )
-          .returning();
-
-        if (!st) {
-          set.status = 400;
-          return { error: "Invalid or expired setup token" };
-        }
-
-        const [device] = await db
-          .insert(devices)
-          .values({
-            userId: st.userId,
-            deviceName: body.deviceName,
-            os: body.os ?? null,
-            lastSeenAt: new Date(),
-          })
-          .onConflictDoUpdate({
-            target: [devices.userId, devices.deviceName],
-            set: { os: body.os ?? null, lastSeenAt: new Date() },
-          })
-          .returning();
-
-        await db.delete(apiKeys).where(eq(apiKeys.deviceId, device.id));
-
-        const rawKey = generateApiKey();
-        const keyHash = await hashToken(rawKey);
-        await db.insert(apiKeys).values({
-          userId: st.userId,
-          deviceId: device.id,
-          keyHash,
-        });
-
-        return { apiKey: rawKey, deviceId: device.id };
+        const tokenHash = await hashToken(body.token);
+        const exchanged = await db.transaction(async (tx) => {
+          const [st] = await tx
+            .update(setupTokens)
+            .set({ redeemed: true })
+            .where(
+              and(
+                eq(setupTokens.token, tokenHash),
+                eq(setupTokens.redeemed, false),
+                gt(setupTokens.expiresAt, new Date()),
+              ),
+            )
+            .returning();
+
+          if (!st) return null;
+
+          const [device] = await tx
+            .insert(devices)
+            .values({
+              userId: st.userId,
+              deviceName: body.deviceName,
+              os: body.os ?? null,
+              lastSeenAt: new Date(),
+            })
+            .onConflictDoUpdate({
+              target: [devices.userId, devices.deviceName],
+              set: { os: body.os ?? null, lastSeenAt: new Date() },
+            })
+            .returning();
+
+          await tx.delete(apiKeys).where(eq(apiKeys.deviceId, device.id));
+
+          const apiKey = generateApiKey();
+          await tx.insert(apiKeys).values({
+            userId: st.userId,
+            deviceId: device.id,
+            keyHash: await hashToken(apiKey),
+          });
+
+          return { apiKey, deviceId: device.id };
+        });
+
+        if (!exchanged) {
+          set.status = 400;
+          return { error: "Invalid or expired setup token" };
+        }
+
+        return exchanged;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/auth/github.ts` around lines 266 - 315, Wrap the setup-token
claim, device upsert, api_keys delete and api_keys insert in a single DB
transaction so those steps are atomic and will rollback if any part fails: move
the token update (hashToken +
db.update(setupTokens).set(...).where(...).returning()), the device upsert
(db.insert(devices)...onConflictDoUpdate().returning()), the apiKeys delete
(db.delete(apiKeys).where(...)) and the apiKeys insert into a single
db.transaction(...) callback (using the same transaction client for all calls),
return errors consistently (e.g., return { error: "..."} and set.status) when
the transaction fails, and ensure you use the transaction client (not the outer
db) for hashToken-related DB calls so the token claim is reverted if key
issuance fails; target symbols: hashToken, setupTokens, devices, apiKeys,
generateApiKey, and db.transaction.
🧹 Nitpick comments (1)
apps/server/test/integration.test.ts (1)

149-155: ⚡ Quick win

Assert plaintext absence explicitly.

This proves the hash row exists, but it would still pass if the endpoint accidentally wrote both the hash and the raw token in separate rows. Add a second lookup for the plaintext token and assert that no row is returned.

Suggested test tightening
-    const [storedSetupToken] = await db
+    const hashedSetupToken = await hashToken(setupToken);
+    const [storedSetupToken] = await db
       .select({ token: setupTokens.token })
       .from(setupTokens)
-      .where(eq(setupTokens.token, await hashToken(setupToken)))
+      .where(eq(setupTokens.token, hashedSetupToken))
       .limit(1);
-    expect(storedSetupToken?.token).toBe(await hashToken(setupToken));
+    const [rawSetupToken] = await db
+      .select({ token: setupTokens.token })
+      .from(setupTokens)
+      .where(eq(setupTokens.token, setupToken))
+      .limit(1);
+    expect(storedSetupToken?.token).toBe(hashedSetupToken);
     expect(storedSetupToken?.token).not.toBe(setupToken);
+    expect(rawSetupToken).toBeUndefined();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/test/integration.test.ts` around lines 149 - 155, The test
currently verifies a hashed setup token row exists (via hashToken and
storedSetupToken) but doesn't assert the plaintext token isn't stored; add a
second query against the same table (setupTokens) using eq(setupTokens.token,
setupToken) to look up the raw token value and assert that the result is
null/undefined (no row returned). Keep the original hashed-token assertions
(hashToken, storedSetupToken) and add this explicit negative lookup to ensure
the plaintext token was not written.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/server/drizzle/0016_dazzling_victor_mancha.sql`:
- Line 1: The migration will fail on DBs with duplicate api_keys.device_id
values; add a new corrective migration that first deduplicates api_keys by
keeping a single deterministic row per device (e.g., keep the row with the
smallest id or the newest created_at) and deleting the other rows, then create
the UNIQUE index "api_keys_device_unique" on api_keys(device_id); reference the
api_keys table, device_id column, and the "api_keys_device_unique" index name in
the SQL so the script explicitly removes duplicates (use a DELETE ... WHERE id
NOT IN (SELECT MIN(id) FROM api_keys GROUP BY device_id) or equivalent
deterministic criterion) and then creates the unique index in the same
migration.

---

Outside diff comments:
In `@apps/server/src/auth/github.ts`:
- Around line 266-315: Wrap the setup-token claim, device upsert, api_keys
delete and api_keys insert in a single DB transaction so those steps are atomic
and will rollback if any part fails: move the token update (hashToken +
db.update(setupTokens).set(...).where(...).returning()), the device upsert
(db.insert(devices)...onConflictDoUpdate().returning()), the apiKeys delete
(db.delete(apiKeys).where(...)) and the apiKeys insert into a single
db.transaction(...) callback (using the same transaction client for all calls),
return errors consistently (e.g., return { error: "..."} and set.status) when
the transaction fails, and ensure you use the transaction client (not the outer
db) for hashToken-related DB calls so the token claim is reverted if key
issuance fails; target symbols: hashToken, setupTokens, devices, apiKeys,
generateApiKey, and db.transaction.

---

Nitpick comments:
In `@apps/server/test/integration.test.ts`:
- Around line 149-155: The test currently verifies a hashed setup token row
exists (via hashToken and storedSetupToken) but doesn't assert the plaintext
token isn't stored; add a second query against the same table (setupTokens)
using eq(setupTokens.token, setupToken) to look up the raw token value and
assert that the result is null/undefined (no row returned). Keep the original
hashed-token assertions (hashToken, storedSetupToken) and add this explicit
negative lookup to ensure the plaintext token was not written.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 032b866f-625e-44e1-9be7-d202b86645b0

📥 Commits

Reviewing files that changed from the base of the PR and between 10d4b6c and 12088b7.

⛔ Files ignored due to path filters (1)
  • docs/generated/db-schema.md is excluded by !**/generated/**
📒 Files selected for processing (9)
  • apps/server/drizzle/0016_dazzling_victor_mancha.sql
  • apps/server/drizzle/meta/0016_snapshot.json
  • apps/server/drizzle/meta/_journal.json
  • apps/server/src/auth/github.ts
  • apps/server/src/db/schema.ts
  • apps/server/src/user/routes.ts
  • apps/server/test/auth-resolvers.test.ts
  • apps/server/test/integration.test.ts
  • apps/server/test/upload.test.ts

Comment thread apps/server/drizzle/0016_dazzling_victor_mancha.sql Outdated
@giuseppecrj giuseppecrj requested a review from texuf May 5, 2026 02:38
@giuseppecrj giuseppecrj merged commit 65f0277 into main May 5, 2026
3 checks passed
@giuseppecrj giuseppecrj deleted the codex/setup-token-security-stack branch May 5, 2026 03:10
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.

3 participants