diff --git a/netlify/database/migrations/20260622110000_oauth_state.sql b/netlify/database/migrations/20260622110000_oauth_state.sql new file mode 100644 index 00000000..d8c71c88 --- /dev/null +++ b/netlify/database/migrations/20260622110000_oauth_state.sql @@ -0,0 +1,52 @@ +-- OAuth transactional state for the docs MCP authorization server. +-- Applied to the Neon (Netlify DB) database used when STORE_BACKEND=neon. +-- Idempotent: safe to run repeatedly. +-- +-- Scope: the one-time-use / transactional tables only. DCR-registered clients +-- stay on Netlify Blobs (plain persistence, not one-time-use). + +-- In-flight authorization requests (consumed by DELETE … RETURNING). +CREATE TABLE IF NOT EXISTS auth_requests ( + id uuid PRIMARY KEY, + client_id text NOT NULL, + client_redirect_uri text NOT NULL, + client_state text, + client_code_challenge text, + upstream_verifier text, + expires_at timestamptz NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_requests_expires ON auth_requests (expires_at); + +-- Authorization codes (one-time; consumed by atomic UPDATE … WHERE used=false). +CREATE TABLE IF NOT EXISTS auth_codes ( + code text PRIMARY KEY, + client_id text NOT NULL, + client_redirect_uri text NOT NULL, + client_code_challenge text, + user_data jsonb NOT NULL, + used boolean NOT NULL DEFAULT false, + expires_at timestamptz NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_codes_expires ON auth_codes (expires_at); + +-- Refresh tokens, stored by hash (one-time; rotated via atomic UPDATE). +CREATE TABLE IF NOT EXISTS refresh_tokens ( + hash text PRIMARY KEY, + family_id uuid NOT NULL, + client_id text NOT NULL, + user_data jsonb NOT NULL, + scope text, + used boolean NOT NULL DEFAULT false, + expires_at timestamptz NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens (expires_at); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_family ON refresh_tokens (family_id); + +-- Refresh-token families (rotation lineage; revoked on reuse detection). +CREATE TABLE IF NOT EXISTS refresh_families ( + id uuid PRIMARY KEY, + client_id text, + revoked boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + revoked_at timestamptz +); diff --git a/netlify/functions/lib/oauth/db/blobs.mjs b/netlify/functions/lib/oauth/db/blobs.mjs new file mode 100644 index 00000000..a7312cbd --- /dev/null +++ b/netlify/functions/lib/oauth/db/blobs.mjs @@ -0,0 +1,108 @@ +// OAuth state storage — Netlify Blobs backend. +// +// Extracted from store.mjs so the backend can be selected at runtime (see +// store.mjs). This is the default backend. Keyed lookups by id/hash fit Blobs. +// +// KNOWN LIMITATION (the reason the Neon backend exists): one-time-use here is +// read-then-delete / read-then-mark, and Blobs has no compare-and-swap, so two +// *simultaneous* requests with the same auth code (or refresh token) can both +// observe it as unused before either consumes it. For refresh tokens a +// concurrent legit+stolen use inside that window would both rotate without +// tripping family revocation. The Neon backend fixes this with a single atomic +// `UPDATE … WHERE used = false RETURNING`. + +import { getStore } from '@netlify/blobs' +import { randomUUID, randomBytes } from 'node:crypto' +import { AUTH_REQUEST_TTL_SEC, AUTH_CODE_TTL_SEC } from '../config.mjs' + +const STORE = 'mcp-oauth' +const AR = (id) => `ar:${id}` // auth request +const AC = (code) => `ac:${code}` // authorization code +const CL = (id) => `client:${id}` // DCR-registered client +const RT = (h) => `rt:${h}` // refresh token (by hash) +const RTF = (id) => `rtf:${id}` // refresh-token family + +function store() { + // STRONG consistency: auth codes and refresh tokens are one-time use, and + // Blobs' default eventual consistency propagates deletes/updates over up to + // 60s — long enough for a consumed code/token to be replayed in that window. + return getStore({ name: STORE, consistency: 'strong' }) +} +const expired = (rec) => !rec || Date.now() > rec.expiresAt + +export async function putAuthRequest(data) { + const id = randomUUID() + await store().setJSON(AR(id), { ...data, expiresAt: Date.now() + AUTH_REQUEST_TTL_SEC * 1000 }) + return id +} + +export async function takeAuthRequest(id) { + if (!id) return null + const key = AR(id) + const rec = await store().get(key, { type: 'json' }).catch(() => null) + await store().delete(key).catch(() => {}) // one-time + return expired(rec) ? null : rec +} + +export async function putAuthCode(data) { + const code = randomBytes(32).toString('base64url') + await store().setJSON(AC(code), { ...data, used: false, expiresAt: Date.now() + AUTH_CODE_TTL_SEC * 1000 }) + return code +} + +export async function takeAuthCode(code) { + if (!code) return null + const key = AC(code) + const rec = await store().get(key, { type: 'json' }).catch(() => null) + if (expired(rec) || rec.used) return null + await store().delete(key).catch(() => {}) // one-time use + return rec +} + +// --- DCR-registered clients (persistent; no TTL) --- +export async function putClient(client) { + await store().setJSON(CL(client.client_id), client) + return client +} + +export async function getStoredClient(clientId) { + if (!clientId) return null + // Resilient: a store error (e.g. Blobs unavailable) resolves to "unknown + // client" rather than crashing the /authorize handler. + try { + return await store().get(CL(clientId), { type: 'json' }) + } catch { + return null + } +} + +// --- refresh tokens (by hash) + families --- +export async function putRefresh(hash, rec) { + await store().setJSON(RT(hash), rec) +} +export async function getRefresh(hash) { + if (!hash) return null + return store().get(RT(hash), { type: 'json' }).catch(() => null) +} + +// Best-effort "consume": mark the token used and return it, or null if it was +// already used / missing. NOT atomic (no CAS on Blobs) — a concurrent caller +// can also observe it unused. The Neon backend makes this atomic. +export async function consumeRefresh(hash) { + const rec = await getRefresh(hash) + if (!rec || rec.used) return null + await store().setJSON(RT(hash), { ...rec, used: true }) + return rec +} + +export async function putFamily(id, rec) { + await store().setJSON(RTF(id), rec) +} +export async function getFamily(id) { + if (!id) return null + return store().get(RTF(id), { type: 'json' }).catch(() => null) +} +export async function revokeFamily(id) { + const fam = (await getFamily(id)) || {} + await store().setJSON(RTF(id), { ...fam, revoked: true, revokedAt: Date.now() }) +} diff --git a/netlify/functions/lib/oauth/db/neon.mjs b/netlify/functions/lib/oauth/db/neon.mjs new file mode 100644 index 00000000..6592c839 --- /dev/null +++ b/netlify/functions/lib/oauth/db/neon.mjs @@ -0,0 +1,183 @@ +// OAuth state storage — Neon Postgres (Netlify DB) backend. +// +// Selected when STORE_BACKEND=neon (see store.mjs). Exists to make one-time-use +// of auth codes and refresh tokens ATOMIC: each consume is a single +// `UPDATE … WHERE used = false RETURNING *` (or `DELETE … RETURNING *`), so two +// concurrent requests with the same code/token cannot both succeed — exactly +// one wins the row, the loser sees zero rows. This closes the replay/reuse race +// that the Blobs backend documents. +// +// Scope: the four one-time-use / transactional tables only. DCR clients stay on +// Blobs (they are plain persistence, not one-time-use — no atomicity benefit). +// +// Uses @netlify/database's zero-config client (getDatabase().httpClient), which +// reads NETLIFY_DATABASE_URL automatically and is the HTTP Neon query function +// (tagged-template SQL). Imported lazily so this module can be loaded without +// the dependency or a database URL present (e.g. STORE_BACKEND=blobs in tests). + +import { randomUUID, randomBytes } from 'node:crypto' +import { AUTH_REQUEST_TTL_SEC, AUTH_CODE_TTL_SEC } from '../config.mjs' + +let _sql = null +async function db() { + if (_sql) return _sql + const { getDatabase } = await import('@netlify/database') + // Throws MissingDatabaseConnectionError if no URL is configured (fail-closed). + _sql = getDatabase().httpClient + return _sql +} + +const toMs = (ts) => (ts ? new Date(ts).getTime() : 0) + +// --- auth requests (one-time; consumed by delete) --- +export async function putAuthRequest(data) { + const sql = await db() + const id = randomUUID() + const expiresMs = Date.now() + AUTH_REQUEST_TTL_SEC * 1000 + await sql` + INSERT INTO auth_requests (id, client_id, client_redirect_uri, client_state, client_code_challenge, upstream_verifier, expires_at) + VALUES (${id}, ${data.clientId}, ${data.clientRedirectUri}, ${data.clientState ?? null}, ${data.clientCodeChallenge ?? null}, ${data.upstreamVerifier ?? null}, to_timestamp(${expiresMs} / 1000.0)) + ` + return id +} + +export async function takeAuthRequest(id) { + if (!id) return null + const sql = await db() + const rows = await sql`DELETE FROM auth_requests WHERE id = ${id} RETURNING *` + const row = rows[0] + if (!row || toMs(row.expires_at) < Date.now()) return null + return { + clientId: row.client_id, + clientRedirectUri: row.client_redirect_uri, + clientState: row.client_state, + clientCodeChallenge: row.client_code_challenge, + upstreamVerifier: row.upstream_verifier, + expiresAt: toMs(row.expires_at), + } +} + +// --- authorization codes (one-time; atomic check+consume) --- +export async function putAuthCode(data) { + const sql = await db() + const code = randomBytes(32).toString('base64url') + const expiresMs = Date.now() + AUTH_CODE_TTL_SEC * 1000 + await sql` + INSERT INTO auth_codes (code, client_id, client_redirect_uri, client_code_challenge, user_data, used, expires_at) + VALUES (${code}, ${data.clientId}, ${data.clientRedirectUri}, ${data.clientCodeChallenge ?? null}, ${JSON.stringify(data.user)}::jsonb, false, to_timestamp(${expiresMs} / 1000.0)) + ` + return code +} + +export async function takeAuthCode(code) { + if (!code) return null + const sql = await db() + // Atomic: only the first caller flips used=false→true and gets the row. + const rows = await sql` + UPDATE auth_codes SET used = true + WHERE code = ${code} AND used = false AND expires_at > now() + RETURNING * + ` + const row = rows[0] + if (!row) return null + return { + clientId: row.client_id, + clientRedirectUri: row.client_redirect_uri, + clientCodeChallenge: row.client_code_challenge, + user: row.user_data, + expiresAt: toMs(row.expires_at), + } +} + +// --- refresh tokens (by hash) --- +export async function putRefresh(hash, rec) { + const sql = await db() + await sql` + INSERT INTO refresh_tokens (hash, family_id, client_id, user_data, scope, used, expires_at) + VALUES (${hash}, ${rec.familyId}, ${rec.clientId}, ${JSON.stringify(rec.user)}::jsonb, ${rec.scope ?? null}, ${rec.used ?? false}, to_timestamp(${rec.expiresAt} / 1000.0)) + ` +} + +export async function getRefresh(hash) { + if (!hash) return null + const sql = await db() + const rows = await sql`SELECT * FROM refresh_tokens WHERE hash = ${hash}` + const row = rows[0] + if (!row) return null + return { + familyId: row.family_id, + clientId: row.client_id, + user: row.user_data, + scope: row.scope, + used: row.used, + expiresAt: toMs(row.expires_at), + } +} + +// Atomic consume: exactly one concurrent caller flips used=false→true and gets +// the row back. A loser (already used / missing) gets null → caller trips reuse. +export async function consumeRefresh(hash) { + if (!hash) return null + const sql = await db() + const rows = await sql` + UPDATE refresh_tokens SET used = true + WHERE hash = ${hash} AND used = false + RETURNING * + ` + const row = rows[0] + if (!row) return null + return { + familyId: row.family_id, + clientId: row.client_id, + user: row.user_data, + scope: row.scope, + used: true, + expiresAt: toMs(row.expires_at), + } +} + +// --- refresh-token families --- +export async function putFamily(id, rec) { + const sql = await db() + await sql` + INSERT INTO refresh_families (id, client_id, revoked, created_at) + VALUES (${id}, ${rec.clientId ?? null}, ${rec.revoked ?? false}, to_timestamp(${rec.createdAt ?? Date.now()} / 1000.0)) + ON CONFLICT (id) DO NOTHING + ` +} + +export async function getFamily(id) { + if (!id) return null + const sql = await db() + const rows = await sql`SELECT * FROM refresh_families WHERE id = ${id}` + const row = rows[0] + if (!row) return null + return { + clientId: row.client_id, + revoked: row.revoked, + createdAt: toMs(row.created_at), + revokedAt: row.revoked_at ? toMs(row.revoked_at) : null, + } +} + +export async function revokeFamily(id) { + const sql = await db() + await sql`UPDATE refresh_families SET revoked = true, revoked_at = now() WHERE id = ${id}` +} + +// TTL cleanup (run on a schedule). Deletes expired one-time-use rows. Only +// past-expiry refresh tokens are removed, so reuse detection still works for +// every token within its lifetime; families with no remaining tokens are then +// swept. Reads already filter on expires_at, so this is purely housekeeping. +export async function cleanupExpired() { + const sql = await db() + const reqs = await sql`DELETE FROM auth_requests WHERE expires_at < now() RETURNING id` + const codes = await sql`DELETE FROM auth_codes WHERE expires_at < now() RETURNING code` + const toks = await sql`DELETE FROM refresh_tokens WHERE expires_at < now() RETURNING hash` + const fams = await sql` + DELETE FROM refresh_families f + WHERE NOT EXISTS (SELECT 1 FROM refresh_tokens t WHERE t.family_id = f.id) + RETURNING id + ` + return { authRequests: reqs.length, authCodes: codes.length, refreshTokens: toks.length, families: fams.length } +} diff --git a/netlify/functions/lib/oauth/store.mjs b/netlify/functions/lib/oauth/store.mjs index cc697628..6cf3d481 100644 --- a/netlify/functions/lib/oauth/store.mjs +++ b/netlify/functions/lib/oauth/store.mjs @@ -1,107 +1,38 @@ -// AS state storage — auth requests (in-flight) + authorization codes. +// OAuth state storage — backend selector. // -// Backed by Netlify Blobs (available today; key-value fits the AS state, which -// is all keyed lookups by id/hash). The interface below is the seam for a -// Netlify DB (Neon Postgres) backend when relational queries/analytics are -// needed — swap the four functions; callers don't change. +// The public interface here is stable; callers (mcp-oauth.mjs, clients.mjs) +// don't know or care which backend is active. The one-time-use / transactional +// state (auth requests, auth codes, refresh tokens, families) is served by +// either the Blobs backend (default) or the Neon Postgres backend, chosen by +// STORE_BACKEND. The Neon backend exists because Blobs has no compare-and-swap, +// so its one-time-use is best-effort; Neon makes consume atomic. // -// NOTE: refresh tokens (Milestone 3) and registered clients/DCR (Milestone 2) -// will add tables/namespaces here. - -import { getStore } from '@netlify/blobs' -import { randomUUID, randomBytes } from 'node:crypto' -import { AUTH_REQUEST_TTL_SEC, AUTH_CODE_TTL_SEC } from './config.mjs' - -const STORE = 'mcp-oauth' -const AR = (id) => `ar:${id}` // auth request -const AC = (code) => `ac:${code}` // authorization code -const CL = (id) => `client:${id}` // DCR-registered client -const RT = (h) => `rt:${h}` // refresh token (by hash) -const RTF = (id) => `rtf:${id}` // refresh-token family - -function store() { - // STRONG consistency is required: auth codes and refresh tokens are one-time - // use, and Blobs' default eventual consistency propagates deletes/updates over - // up to 60s — long enough for a consumed code/token to be replayed in that - // window. Strong reads come from the origin region (fine at our volume). - // - // KNOWN LIMITATION (until the Neon/Postgres backend lands): one-time-use here - // is read-then-delete, and Blobs has no compare-and-swap, so two *simultaneous* - // requests with the same auth code (or refresh token) can both observe it as - // unused before either deletes/marks it — and for refresh tokens a concurrent - // legit+stolen use inside that window would both rotate without tripping family - // revocation. Narrow window (60s code TTL, PKCE-bound). The DB backend fixes it - // with a transactional `UPDATE … WHERE used = false`. - return getStore({ name: STORE, consistency: 'strong' }) -} -const expired = (rec) => !rec || Date.now() > rec.expiresAt - -export async function putAuthRequest(data) { - const id = randomUUID() - await store().setJSON(AR(id), { ...data, expiresAt: Date.now() + AUTH_REQUEST_TTL_SEC * 1000 }) - return id -} - -export async function takeAuthRequest(id) { - if (!id) return null - const key = AR(id) - const rec = await store().get(key, { type: 'json' }).catch(() => null) - await store().delete(key).catch(() => {}) // one-time - return expired(rec) ? null : rec -} - -export async function putAuthCode(data) { - const code = randomBytes(32).toString('base64url') - await store().setJSON(AC(code), { ...data, used: false, expiresAt: Date.now() + AUTH_CODE_TTL_SEC * 1000 }) - return code -} - -export async function takeAuthCode(code) { - if (!code) return null - const key = AC(code) - const rec = await store().get(key, { type: 'json' }).catch(() => null) - if (expired(rec) || rec.used) return null - await store().delete(key).catch(() => {}) // one-time use - return rec -} - -// --- DCR-registered clients (persistent; no TTL) --- -export async function putClient(client) { - await store().setJSON(CL(client.client_id), client) - return client -} - -export async function getStoredClient(clientId) { - if (!clientId) return null - // Resilient: a store error (e.g. Blobs unavailable) resolves to "unknown - // client" rather than crashing the /authorize handler. - try { - return await store().get(CL(clientId), { type: 'json' }) - } catch { - return null - } -} - -// --- refresh tokens (by hash) + families --- -export async function putRefresh(hash, rec) { - await store().setJSON(RT(hash), rec) -} -export async function getRefresh(hash) { - if (!hash) return null - return store().get(RT(hash), { type: 'json' }).catch(() => null) -} -export async function markRefreshUsed(hash) { - const rec = await getRefresh(hash) - if (rec) await store().setJSON(RT(hash), { ...rec, used: true }) -} -export async function putFamily(id, rec) { - await store().setJSON(RTF(id), rec) -} -export async function getFamily(id) { - if (!id) return null - return store().get(RTF(id), { type: 'json' }).catch(() => null) -} -export async function revokeFamily(id) { - const fam = (await getFamily(id)) || {} - await store().setJSON(RTF(id), { ...fam, revoked: true, revokedAt: Date.now() }) -} +// Rollout: deploy with STORE_BACKEND=blobs (default), flip a preview to neon, +// verify, then flip prod. Roll back by resetting the env var — no code revert. +// +// NOTE: flipping blobs→neon does not migrate existing rows, so any live refresh +// tokens (in Blobs) won't exist in Neon — users re-authenticate once at cutover. +// Auth codes (60s TTL) are unaffected in practice. Flip during low traffic. + +import * as blobs from './db/blobs.mjs' +import * as neon from './db/neon.mjs' + +const backend = (process.env.STORE_BACKEND || 'blobs').toLowerCase() === 'neon' ? neon : blobs + +// One-time-use / transactional OAuth state — backend-selectable. +export const putAuthRequest = (...a) => backend.putAuthRequest(...a) +export const takeAuthRequest = (...a) => backend.takeAuthRequest(...a) +export const putAuthCode = (...a) => backend.putAuthCode(...a) +export const takeAuthCode = (...a) => backend.takeAuthCode(...a) +export const putRefresh = (...a) => backend.putRefresh(...a) +export const getRefresh = (...a) => backend.getRefresh(...a) +export const consumeRefresh = (...a) => backend.consumeRefresh(...a) +export const putFamily = (...a) => backend.putFamily(...a) +export const getFamily = (...a) => backend.getFamily(...a) +export const revokeFamily = (...a) => backend.revokeFamily(...a) + +// DCR-registered clients always live on Blobs: they are plain persistence (not +// one-time-use), so the Neon migration's atomicity buys nothing here. Keeping +// them on Blobs keeps the migration surface small. +export const putClient = blobs.putClient +export const getStoredClient = blobs.getStoredClient diff --git a/netlify/functions/mcp-oauth.mjs b/netlify/functions/mcp-oauth.mjs index 332744b1..39438ea8 100644 --- a/netlify/functions/mcp-oauth.mjs +++ b/netlify/functions/mcp-oauth.mjs @@ -12,7 +12,7 @@ import { getJwks, signAccessToken } from './lib/oauth/keys.mjs' import { putAuthRequest, takeAuthRequest, putAuthCode, takeAuthCode, - putRefresh, getRefresh, markRefreshUsed, putFamily, getFamily, revokeFamily, + putRefresh, getRefresh, consumeRefresh, putFamily, getFamily, revokeFamily, } from './lib/oauth/store.mjs' import { buildAuthorizeUrl, exchangeCode, UPSTREAM_MODE } from './lib/oauth/upstream.mjs' import { verifyChallenge, generatePair } from './lib/oauth/pkce.mjs' @@ -258,7 +258,16 @@ export default async (request) => { return json({ error: 'invalid_grant', error_description: 'client_id does not match the refresh token' }, 400) } - await markRefreshUsed(oldHash) // rotate: supersede the presented token + // Atomically consume (supersede) the presented token. On the Neon backend + // this is a single UPDATE…WHERE used=false RETURNING, so only one of two + // concurrent requests wins; the loser gets null and is treated as reuse + // (theft signal), closing the rotation race. On Blobs this is best-effort. + const consumed = await consumeRefresh(oldHash) + if (!consumed) { + await revokeFamily(record.familyId) + return json({ error: 'invalid_grant', error_description: 'refresh token reuse detected; session revoked' }, 400) + } + const refresh_token = await issueRefresh(record.familyId, record.clientId, record.user, record.scope) const access_token = await mintAccess(record.user, record.scope) return json({ access_token, token_type: 'Bearer', expires_in: ACCESS_TOKEN_TTL_SEC, scope: record.scope, refresh_token }) diff --git a/netlify/functions/oauth-cleanup.mjs b/netlify/functions/oauth-cleanup.mjs new file mode 100644 index 00000000..41480446 --- /dev/null +++ b/netlify/functions/oauth-cleanup.mjs @@ -0,0 +1,25 @@ +// Scheduled cleanup of expired OAuth state (Neon backend only). +// +// Runs daily; deletes expired auth requests/codes and past-expiry refresh +// tokens, then sweeps empty token families. No-op unless STORE_BACKEND=neon and +// a database URL is configured, so it's safe to keep deployed during rollout. + +import { cleanupExpired } from './lib/oauth/db/neon.mjs' + +export default async () => { + const backend = (process.env.STORE_BACKEND || 'blobs').toLowerCase() + if (backend !== 'neon' || !process.env.NETLIFY_DATABASE_URL) { + console.log(JSON.stringify({ event: 'oauth_cleanup_skipped', reason: 'not_neon_backend' })) + return + } + try { + const deleted = await cleanupExpired() + console.log(JSON.stringify({ event: 'oauth_cleanup_ran', deleted })) + } catch (e) { + console.warn('[oauth-cleanup] failed', { error: e?.message }) + } +} + +export const config = { + schedule: '@daily', +} diff --git a/package-lock.json b/package-lock.json index 58299089..d6024173 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,12 @@ "@modelcontextprotocol/sdk": "1.17.0", "@modelfetch/netlify": "0.15.2", "@netlify/blobs": "^9.1.6", + "@netlify/database": "^1.0.0", "@redpanda-data/docs-extensions-and-macros": "^5.0.0", "@sntke/antora-mermaid-extension": "^0.0.6", "algoliasearch": "^5.35.0", "hono-rate-limiter": "0.1.0", + "jose": "^5.9.6", "mcpcat": "^0.1.15", "puppeteer": "^24.15.0", "zod": "3.22.4" @@ -1239,6 +1241,15 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@neondatabase/serverless": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.1.0.tgz", + "integrity": "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==", + "license": "MIT", + "engines": { + "node": ">=19.0.0" + } + }, "node_modules/@netlify/blobs": { "version": "9.1.6", "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-9.1.6.tgz", @@ -1252,6 +1263,52 @@ "node": "^14.16.0 || >=16.0.0" } }, + "node_modules/@netlify/database": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@netlify/database/-/database-1.0.0.tgz", + "integrity": "sha512-k4rft5pkJEXp3Ps1HeuaK2F4a5wuFMeTlMr8PHfRVAdXjit+OsWAqcEqM9mk0lFyyh4zAWNZLXinrQhlZHpUBQ==", + "license": "MIT", + "dependencies": { + "@neondatabase/serverless": "^1.1.0", + "@netlify/runtime-utils": "2.3.0", + "pg": "^8.13.0", + "waddler": "^0.1.1", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.6.1" + } + }, + "node_modules/@netlify/database/node_modules/@netlify/runtime-utils": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-2.3.0.tgz", + "integrity": "sha512-cW8weDvsKV7zfia2m5EcBy6KILGoPD+eYZ3qWNGnIo05DGF28goPES0xKSDkNYgAF/2rRSIhie2qcBhbGVgSRg==", + "license": "MIT", + "engines": { + "node": "^18.14.0 || >=20" + } + }, + "node_modules/@netlify/database/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@netlify/dev-utils": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-3.2.0.tgz", @@ -9531,6 +9588,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -12528,6 +12594,95 @@ "integrity": "sha512-rixgxw3SxyJbCaSpo1n35A/fwI1r2rdwMKOTCg/AcG+xOEyZcE8UHVjpZMFCVImzsFoCZeJTT+M/rdEIQYO2nw==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -12832,6 +12987,45 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prepend-http": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-3.0.1.tgz", @@ -16424,6 +16618,94 @@ "node": ">=18" } }, + "node_modules/waddler": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/waddler/-/waddler-0.1.1.tgz", + "integrity": "sha512-lBJXYFBLEpYe+scAeCJmLj6Iqweuq1whM6Am3I9WfopOCFxvKz8Nq5hXoy8/b3zwJqHIQMglFIvM4skRydSpZg==", + "license": "MIT", + "peerDependencies": { + "@clickhouse/client": "^1.11.2", + "@duckdb/node-api": "^1.1.2-alpha.4", + "@electric-sql/pglite": "^0.2.17", + "@libsql/client": "^0.15.4", + "@libsql/client-wasm": "^0.15.4", + "@neondatabase/serverless": "^1.0.0", + "@planetscale/database": "^1.19.0", + "@tidbcloud/serverless": "^0.2.0", + "@vercel/postgres": "^0.10.0", + "@xata.io/client": "^0.30.1", + "better-sqlite3": "^11.9.1", + "bun-types": "*", + "duckdb": "^1.2.1", + "gel": "^2.0.2", + "mysql2": "^3.14.0", + "pg": "^8.14.0", + "pg-query-stream": "^4.8.0", + "postgres": "^3.4.5" + }, + "peerDependenciesMeta": { + "@clickhouse/client": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@duckdb/node-api": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "duckdb": { + "optional": true + }, + "gel": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "postgres": { + "optional": true + } + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index 364df339..c1ef42ec 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,12 @@ "@modelcontextprotocol/sdk": "1.17.0", "@modelfetch/netlify": "0.15.2", "@netlify/blobs": "^9.1.6", - "jose": "^5.9.6", + "@netlify/database": "^1.0.0", "@redpanda-data/docs-extensions-and-macros": "^5.0.0", "@sntke/antora-mermaid-extension": "^0.0.6", "algoliasearch": "^5.35.0", "hono-rate-limiter": "0.1.0", + "jose": "^5.9.6", "mcpcat": "^0.1.15", "puppeteer": "^24.15.0", "zod": "3.22.4" diff --git a/tests/mcp-oauth-neon.test.ts b/tests/mcp-oauth-neon.test.ts new file mode 100644 index 00000000..62f3f18c --- /dev/null +++ b/tests/mcp-oauth-neon.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +// Atomicity tests for the Neon backend. These MUST run against a real Postgres +// (a fake honoring atomic semantics would prove nothing), so they're skipped +// unless TEST_NEON_URL points at a disposable test database, e.g.: +// TEST_NEON_URL=postgres://... npx vitest run tests/mcp-oauth-neon.test.ts +const TEST_DB = process.env.TEST_NEON_URL + +const migrationPath = fileURLToPath( + new URL('../netlify/database/migrations/20260622110000_oauth_state.sql', import.meta.url) +) + +describe.skipIf(!TEST_DB)('Neon backend — atomic one-time-use', () => { + let store: typeof import('../netlify/functions/lib/oauth/db/neon.mjs') + + beforeAll(async () => { + process.env.NETLIFY_DATABASE_URL = TEST_DB + process.env.STORE_BACKEND = 'neon' + + const { getDatabase } = await import('@netlify/database') + const sql = getDatabase().httpClient // zero-config, reads NETLIFY_DATABASE_URL + // Apply schema, then clear any leftover rows from a previous run. + for (const stmt of readFileSync(migrationPath, 'utf8').split(';').map((s) => s.trim()).filter(Boolean)) { + await sql.query(stmt) + } + await sql.query('TRUNCATE auth_requests, auth_codes, refresh_tokens, refresh_families') + + store = await import('../netlify/functions/lib/oauth/db/neon.mjs') + }) + + it('auth code: two concurrent consumes -> exactly one succeeds', async () => { + const code = await store.putAuthCode({ + clientId: 'c1', + clientRedirectUri: 'https://c1/cb', + clientCodeChallenge: 'chal', + user: { sub: 'u1', email: 'a@b.com' }, + }) + const results = await Promise.all([store.takeAuthCode(code), store.takeAuthCode(code)]) + expect(results.filter(Boolean)).toHaveLength(1) + // A third attempt always fails (already consumed). + expect(await store.takeAuthCode(code)).toBeNull() + }) + + it('refresh token: two concurrent rotations -> exactly one wins (loser = reuse)', async () => { + const familyId = crypto.randomUUID() + const hash = 'hash_' + crypto.randomUUID() + await store.putFamily(familyId, { revoked: false, clientId: 'c1', createdAt: Date.now() }) + await store.putRefresh(hash, { + familyId, + clientId: 'c1', + user: { sub: 'u1' }, + scope: 'openid', + used: false, + expiresAt: Date.now() + 60_000, + }) + + const results = await Promise.all([store.consumeRefresh(hash), store.consumeRefresh(hash)]) + expect(results.filter(Boolean)).toHaveLength(1) // only one rotation wins + expect(await store.consumeRefresh(hash)).toBeNull() // already consumed -> reuse signal + }) + + it('cleanupExpired removes past-expiry rows and empty families', async () => { + const familyId = crypto.randomUUID() + const hash = 'expired_' + crypto.randomUUID() + await store.putFamily(familyId, { revoked: false, clientId: 'c1', createdAt: Date.now() - 120_000 }) + await store.putRefresh(hash, { + familyId, + clientId: 'c1', + user: { sub: 'u1' }, + scope: 'openid', + used: true, + expiresAt: Date.now() - 1_000, // already expired + }) + const deleted = await store.cleanupExpired() + expect(deleted.refreshTokens).toBeGreaterThanOrEqual(1) + expect(await store.getRefresh(hash)).toBeNull() + }) +})