Move OAuth state to Neon Postgres (atomic one-time-use) behind STORE_BACKEND flag#184
Merged
JakeSCahill merged 4 commits intoJun 22, 2026
Merged
Conversation
✅ Deploy Preview for redpanda-documentation ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Split the OAuth state store into pluggable backends behind store.mjs: - db/blobs.mjs: the current Netlify Blobs implementation (extracted, no behavior change), default backend. - db/neon.mjs: a Neon Postgres backend whose one-time-use consumes are atomic single statements (UPDATE/DELETE ... RETURNING), closing the read-then-delete race Blobs can't (no compare-and-swap). - store.mjs: thin selector by STORE_BACKEND (default blobs); DCR clients stay on Blobs (plain persistence, no atomicity benefit). Replaces the non-atomic markRefreshUsed with consumeRefresh: on Neon only one of two concurrent refreshes wins the row; the loser is treated as reuse and the family is revoked, restoring theft detection under races. Neon driver is imported lazily so the default path needs no DB or dep. No caller behavior changes on the default backend; all 56 tests pass.
- Migration SQL (db/migrations/0001_oauth_state.sql) for the four one-time-use/transactional tables, with expires_at indexes. - cleanupExpired() + a daily scheduled function (oauth-cleanup.mjs) that deletes expired requests/codes and past-expiry refresh tokens, then sweeps empty families. No-ops unless STORE_BACKEND=neon. Bounds growth. - @neondatabase/serverless dependency (HTTP driver; no Drizzle — the atomic ops are single hand-written statements). - Real-Postgres concurrency tests (tests/mcp-oauth-neon.test.ts), skipped unless TEST_NEON_URL is set: prove two concurrent auth-code consumes / refresh rotations yield exactly one winner, and cleanup removes expired rows. A fake can't prove atomicity, so these require a real DB. 56 tests pass; 3 Neon tests skip without a DB URL.
The database is provisioned and attached to the redpanda-documentation site. Wire the code to Netlify's managed flow: - Use @netlify/database (the package db init installed) instead of the raw @neondatabase/serverless driver: neon.mjs now connects via getDatabase().httpClient (zero-config, reads NETLIFY_DATABASE_URL, fail-closed if absent). - Move the schema into Netlify's auto-applied migrations directory (netlify/database/migrations/), so it's applied on deploy — including to per-preview DB branches. Removes the hand-rolled migrations path. - Update the atomicity test to the new path + @netlify/database client. 56 tests pass; 3 Neon tests skip without TEST_NEON_URL.
e69189b to
3a81089
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Stacked on #181 (
feature/mcp-email-auth) — base will retarget tomainonce #181 merges. Review the compare against feature/mcp-email-auth for the isolated diff.What & why
Moves the OAuth authorization server's one-time-use / transactional state (auth requests, auth codes, refresh tokens, families) from Netlify Blobs to Neon Postgres (Netlify DB), behind a
STORE_BACKENDflag.This closes the one real correctness gap the Blobs code already documents: Blobs has no compare-and-swap, so one-time-use is read-then-delete / read-then-mark. Two concurrent requests with the same auth code (or refresh token) can both observe it unused before either consumes it. The meaningful case is a refresh token: a concurrent legit + stolen rotation both succeed, defeating the family reuse-detection (theft signal). Postgres fixes it with a single atomic statement.
How
lib/oauth/store.mjs): same exported interface; picksdb/blobs.mjs(default) ordb/neon.mjsbySTORE_BACKEND. Callers don't change. DCR clients stay on Blobs (plain persistence — no atomicity benefit, smaller migration surface).takeAuthCode→UPDATE … SET used=true WHERE used=false AND expires_at>now() RETURNING *; refresh rotation →consumeRefreshdoesUPDATE … WHERE used=false RETURNING *. Exactly one concurrent caller wins; the loser is treated as reuse → family revoked, restoring theft detection under races.@netlify/database(getDatabase(), zero-config, readsNETLIFY_DATABASE_URL, fail-closed). Schema lives innetlify/database/migrations/, auto-applied on deploy — including to per-preview DB branches.oauth-cleanup.mjs) deletes expired requests/codes and past-expiry refresh tokens, then sweeps empty families. No-ops unlessSTORE_BACKEND=neon. Bounds growth (Blobs never GC'd these).Rollout (safe, reversible)
STORE_BACKENDunset → stays on Blobs, zero behavior change.STORE_BACKEND=neonis set on the deploy-preview context only → previews exercise Neon (own DB branch, migrations auto-applied).neonon production.STORE_BACKEND=blobs— no code revert.Cutover note: flipping blobs→neon doesn't migrate rows, so live refresh tokens (in Blobs) won't exist in Neon — users re-authenticate once. Auth codes (60s TTL) are unaffected in practice. Flip during low traffic.
Tests
tests/mcp-oauth-neon.test.tsproves the fix against real Postgres (skipped unlessTEST_NEON_URLis set — a fake honoring atomic semantics would prove nothing): two concurrent auth-code consumes / refresh rotations yield exactly one winner; cleanup removes expired rows. Run against a disposable Neon branch (it truncates those tables), never the primary.Out of scope (deferred, tracked separately)
User capture, rate-limit counters, and the dev signing key stay on Blobs. Security review also flagged (non-blocking): DNS-rebinding residual in the CIMD SSRF guard, and showing client name/consent on the login interstitial before phase-2 durable identity.