Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions netlify/database/migrations/20260622110000_oauth_state.sql
Original file line number Diff line number Diff line change
@@ -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
);
108 changes: 108 additions & 0 deletions netlify/functions/lib/oauth/db/blobs.mjs
Original file line number Diff line number Diff line change
@@ -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() })
}
183 changes: 183 additions & 0 deletions netlify/functions/lib/oauth/db/neon.mjs
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading