* fix(mcp): grace window + idempotent refresh-token replay for OAuth (#710) OAuth refresh rotated BOTH the refresh token and the access key in one zero-grace CAS. Claude Code's MCP OAuth client fails to persist the rotated refresh token (or fires concurrent refreshes), re-presents the stale one, the CAS matches 0 rows, and the grant dies with invalid_grant — forcing a full re-authorization roughly every 60s in a loop. Regression from #392. Keep rotation (RFC 9700 §4.14.2 requires it for public clients) but add a bounded grace window with idempotent replay, atomic in one SECURITY DEFINER RPC: - Migration adds previous_key_hash / previous_refresh_token_hash (+ *_expires_at) shadow columns. validate_and_increment_api_key accepts the current OR an unexpired previous key_hash, with the rate-limit increment keyed off the resolved row id. - New rotate_mcp_refresh_token RPC: rotated | replayed | reuse_revoked | revoked | invalid. In-grace replay re-issues a fresh pair and slides the window so an actively-refreshing client that cannot persist the rotated token keeps working; reuse after the window revokes the grant family (RFC 9700 4.14.2 reuse detection preserved). - The refresh grant now calls the one RPC, closing the old SELECT-then-CAS TOCTOU gap. All previous_* columns default NULL, so existing keys are unaffected and the RPC return shape is unchanged (callers untouched). Tests: rewired the token-route unit tests to the RPC and replaced the test that codified the bug with a #710 regression (in-grace replay returns 200, not 400); added tests/pg/mcp-oauth-rotation-grace.pg.test.ts (grace accept/expire, revoke-never-graced, rotate->demote, idempotent replay, reuse-after-grace->revoke). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: retrigger checks for #714 No code change — re-running CI. The Supabase Preview check fails on a pre-existing main-branch migration-history drift ("Remote migration versions not found in local migrations directory"), not this PR; pg-real (full migration replay) passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
import { randomUUID } from 'crypto'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { seedCompany } from '@/tests/pg/fixtures'
|
|
import { getPool } from '@/tests/pg/setup'
|
|
|
|
/**
|
|
* pg-real coverage for migration 20260621130000_api_keys_rotation_grace.
|
|
*
|
|
* Locks in the issue #710 fix:
|
|
* - The four previous_* shadow columns exist.
|
|
* - validate_and_increment_api_key accepts the current key_hash OR an
|
|
* unexpired previous_key_hash (access-token grace), rejects an expired one,
|
|
* never matches a revoked key, and increments the rate-limit counter on the
|
|
* resolved row even when matched via the grace hash.
|
|
* - rotate_mcp_refresh_token: 'rotated' demotes the consumed pair to previous_*;
|
|
* presenting the previous refresh token within grace 'replayed's (re-issues
|
|
* + slides the window); reuse AFTER grace is 'reuse_revoked' and sets
|
|
* revoked_at (RFC 9700 §4.14.2); unknown → 'invalid'; revoked grant → 'revoked'.
|
|
*
|
|
* Inserts go through the pool (superuser, RLS-bypassing) — this is an RPC /
|
|
* schema behaviour test, not an RLS test. Hashes are opaque text the RPCs
|
|
* compare by equality, so any unique strings work.
|
|
*/
|
|
|
|
type KeyRow = {
|
|
key_hash: string
|
|
refresh_token_hash: string | null
|
|
previous_key_hash?: string | null
|
|
previous_key_expires_at?: string | null
|
|
previous_refresh_token_hash?: string | null
|
|
previous_refresh_expires_at?: string | null
|
|
scopes?: string[] | null
|
|
revoked?: boolean
|
|
}
|
|
|
|
function h(label: string): string {
|
|
// Opaque 64-char unique hash-shaped string.
|
|
return `${label}_${randomUUID().replace(/-/g, '')}`.padEnd(64, '0').slice(0, 64)
|
|
}
|
|
|
|
async function insertKey(
|
|
userId: string,
|
|
companyId: string,
|
|
row: KeyRow,
|
|
): Promise<string> {
|
|
const id = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.api_keys
|
|
(id, user_id, company_id, key_hash, key_prefix, name, scopes,
|
|
refresh_token_hash, previous_key_hash, previous_key_expires_at,
|
|
previous_refresh_token_hash, previous_refresh_expires_at, revoked_at)
|
|
VALUES ($1,$2,$3,$4,'gnubok_sk_test','pg-real oauth key',$5,$6,$7,$8,$9,$10,$11)`,
|
|
[
|
|
id,
|
|
userId,
|
|
companyId,
|
|
row.key_hash,
|
|
row.scopes ?? null,
|
|
row.refresh_token_hash ?? null,
|
|
row.previous_key_hash ?? null,
|
|
row.previous_key_expires_at ?? null,
|
|
row.previous_refresh_token_hash ?? null,
|
|
row.previous_refresh_expires_at ?? null,
|
|
row.revoked ? new Date() : null,
|
|
],
|
|
)
|
|
return id
|
|
}
|
|
|
|
async function validate(keyHash: string) {
|
|
const { rows } = await getPool().query(
|
|
`SELECT * FROM public.validate_and_increment_api_key($1)`,
|
|
[keyHash],
|
|
)
|
|
return rows
|
|
}
|
|
|
|
async function rotate(
|
|
presented: string,
|
|
newRefresh: string,
|
|
newKey: string,
|
|
graceSeconds = 120,
|
|
) {
|
|
const { rows } = await getPool().query<{ outcome: string; scopes: string[] | null }>(
|
|
`SELECT * FROM public.rotate_mcp_refresh_token($1,$2,$3,'gnubok_sk_new',$4)`,
|
|
[presented, newRefresh, newKey, graceSeconds],
|
|
)
|
|
return rows[0]
|
|
}
|
|
|
|
async function rowById(id: string) {
|
|
const { rows } = await getPool().query(
|
|
`SELECT key_hash, refresh_token_hash, previous_key_hash, previous_key_expires_at,
|
|
previous_refresh_token_hash, previous_refresh_expires_at, revoked_at, request_count
|
|
FROM public.api_keys WHERE id = $1`,
|
|
[id],
|
|
)
|
|
return rows[0]
|
|
}
|
|
|
|
describe('api_keys rotation grace (issue #710)', () => {
|
|
it('adds the four previous_* shadow columns', async () => {
|
|
const { rows } = await getPool().query<{ column_name: string; data_type: string }>(
|
|
`SELECT column_name, data_type FROM information_schema.columns
|
|
WHERE table_schema='public' AND table_name='api_keys'
|
|
AND column_name IN ('previous_key_hash','previous_key_expires_at',
|
|
'previous_refresh_token_hash','previous_refresh_expires_at')
|
|
ORDER BY column_name`,
|
|
)
|
|
const byName = Object.fromEntries(rows.map((r) => [r.column_name, r.data_type]))
|
|
expect(byName['previous_key_hash']).toBe('text')
|
|
expect(byName['previous_key_expires_at']).toBe('timestamp with time zone')
|
|
expect(byName['previous_refresh_token_hash']).toBe('text')
|
|
expect(byName['previous_refresh_expires_at']).toBe('timestamp with time zone')
|
|
})
|
|
|
|
describe('validate_and_increment_api_key access-token grace', () => {
|
|
it('accepts the current key_hash', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const cur = h('cur')
|
|
await insertKey(userId, companyId, { key_hash: cur, refresh_token_hash: h('rt') })
|
|
const rows = await validate(cur)
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0].user_id).toBe(userId)
|
|
expect(rows[0].rate_limited).toBe(false)
|
|
})
|
|
|
|
it('accepts an unexpired previous_key_hash and increments the resolved row', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const cur = h('cur')
|
|
const prev = h('prev')
|
|
const id = await insertKey(userId, companyId, {
|
|
key_hash: cur,
|
|
refresh_token_hash: h('rt'),
|
|
previous_key_hash: prev,
|
|
previous_key_expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
})
|
|
// Two grace-hash validations: window resets to 1, then increments to 2 —
|
|
// proving the increment is keyed off the resolved row id, not p_key_hash.
|
|
expect(await validate(prev)).toHaveLength(1)
|
|
expect(await validate(prev)).toHaveLength(1)
|
|
expect((await rowById(id)).request_count).toBe(2)
|
|
})
|
|
|
|
it('rejects an expired previous_key_hash but still accepts the current one', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const cur = h('cur')
|
|
const prev = h('prev')
|
|
await insertKey(userId, companyId, {
|
|
key_hash: cur,
|
|
refresh_token_hash: h('rt'),
|
|
previous_key_hash: prev,
|
|
previous_key_expires_at: new Date(Date.now() - 1_000).toISOString(),
|
|
})
|
|
expect(await validate(prev)).toHaveLength(0)
|
|
expect(await validate(cur)).toHaveLength(1)
|
|
})
|
|
|
|
it('never matches a revoked key (current or previous)', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const cur = h('cur')
|
|
const prev = h('prev')
|
|
await insertKey(userId, companyId, {
|
|
key_hash: cur,
|
|
refresh_token_hash: h('rt'),
|
|
previous_key_hash: prev,
|
|
previous_key_expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
revoked: true,
|
|
})
|
|
expect(await validate(cur)).toHaveLength(0)
|
|
expect(await validate(prev)).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('rotate_mcp_refresh_token', () => {
|
|
it('rotates a current refresh token and demotes the consumed pair to previous_*', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const r1 = h('r1')
|
|
const k1 = h('k1')
|
|
const id = await insertKey(userId, companyId, {
|
|
key_hash: k1,
|
|
refresh_token_hash: r1,
|
|
scopes: ['transactions:read', 'invoices:read'],
|
|
})
|
|
const r2 = h('r2')
|
|
const k2 = h('k2')
|
|
const res = await rotate(r1, r2, k2)
|
|
expect(res.outcome).toBe('rotated')
|
|
expect(res.scopes).toEqual(['transactions:read', 'invoices:read'])
|
|
|
|
const row = await rowById(id)
|
|
expect(row.refresh_token_hash).toBe(r2)
|
|
expect(row.key_hash).toBe(k2)
|
|
expect(row.previous_refresh_token_hash).toBe(r1)
|
|
expect(row.previous_key_hash).toBe(k1)
|
|
expect(new Date(row.previous_refresh_expires_at).getTime()).toBeGreaterThan(Date.now())
|
|
expect(new Date(row.previous_key_expires_at).getTime()).toBeGreaterThan(Date.now())
|
|
|
|
// The just-superseded access key still validates within grace.
|
|
expect(await validate(k1)).toHaveLength(1)
|
|
})
|
|
|
|
it("replays idempotently when the PREVIOUS refresh token is presented within grace (issue #710)", async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const r1 = h('r1')
|
|
const k1 = h('k1')
|
|
const id = await insertKey(userId, companyId, {
|
|
key_hash: k1,
|
|
refresh_token_hash: r1,
|
|
scopes: ['reports:read'],
|
|
})
|
|
// First, a normal rotation r1 -> r2 (client receives r2 but mis-persists,
|
|
// keeping r1).
|
|
expect((await rotate(r1, h('r2'), h('k2'))).outcome).toBe('rotated')
|
|
|
|
// Client retries with the stale r1 — must NOT 400; it replays and gets a
|
|
// fresh current pair, and the grace window slides.
|
|
const r3 = h('r3')
|
|
const k3 = h('k3')
|
|
const replay = await rotate(r1, r3, k3)
|
|
expect(replay.outcome).toBe('replayed')
|
|
expect(replay.scopes).toEqual(['reports:read'])
|
|
|
|
const row = await rowById(id)
|
|
expect(row.refresh_token_hash).toBe(r3)
|
|
expect(row.key_hash).toBe(k3)
|
|
expect(row.previous_refresh_token_hash).toBe(r1) // preserved, not chained
|
|
expect(row.revoked_at).toBeNull()
|
|
})
|
|
|
|
it('treats reuse of a previous refresh token AFTER the grace window as a breach and revokes the grant', async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const r1 = h('r1')
|
|
const k1 = h('k1')
|
|
const id = await insertKey(userId, companyId, { key_hash: k1, refresh_token_hash: r1 })
|
|
expect((await rotate(r1, h('r2'), h('k2'))).outcome).toBe('rotated')
|
|
|
|
// Force the previous-refresh grace to have expired.
|
|
await getPool().query(
|
|
`UPDATE public.api_keys SET previous_refresh_expires_at = now() - interval '1 second' WHERE id = $1`,
|
|
[id],
|
|
)
|
|
|
|
const res = await rotate(r1, h('r3'), h('k3'))
|
|
expect(res.outcome).toBe('reuse_revoked')
|
|
|
|
const row = await rowById(id)
|
|
expect(row.revoked_at).not.toBeNull()
|
|
// The whole grant family is dead — neither current nor previous validates.
|
|
expect(await validate(row.key_hash)).toHaveLength(0)
|
|
})
|
|
|
|
it("returns 'invalid' for an unknown refresh token", async () => {
|
|
const res = await rotate(h('nope'), h('r2'), h('k2'))
|
|
expect(res.outcome).toBe('invalid')
|
|
})
|
|
|
|
it("returns 'revoked' when the matched grant is already revoked", async () => {
|
|
const { userId, companyId } = await seedCompany()
|
|
const r1 = h('r1')
|
|
await insertKey(userId, companyId, {
|
|
key_hash: h('k1'),
|
|
refresh_token_hash: r1,
|
|
revoked: true,
|
|
})
|
|
const res = await rotate(r1, h('r2'), h('k2'))
|
|
expect(res.outcome).toBe('revoked')
|
|
})
|
|
})
|
|
})
|