5078b4e02d
* 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>
234 lines
9.7 KiB
PL/PgSQL
234 lines
9.7 KiB
PL/PgSQL
-- Rotation grace + idempotent refresh for MCP OAuth (issue #710).
|
|
--
|
|
-- Problem: handleRefreshTokenGrant rotated BOTH the refresh token and the
|
|
-- access key in one CAS-guarded UPDATE with zero grace. A client (Claude Code
|
|
-- CLI) that fails to persist the rotated refresh token — or fires concurrent
|
|
-- refreshes — presents the just-superseded token, the CAS matches 0 rows, and
|
|
-- the grant dies with `invalid_grant`, forcing a full re-auth roughly every
|
|
-- ~60s in an endless loop.
|
|
--
|
|
-- Fix: keep rotation (RFC 9700 §4.14.2 requires public-client refresh tokens to
|
|
-- be rotated or sender-constrained) but add a bounded GRACE WINDOW with
|
|
-- idempotent replay:
|
|
-- * The just-superseded access key (`previous_key_hash`) and refresh token
|
|
-- (`previous_refresh_token_hash`) stay valid for a grace window.
|
|
-- * Presenting the previous refresh token WITHIN the window re-issues a fresh
|
|
-- pair (idempotent replay) and slides the window, so an actively-refreshing
|
|
-- client that cannot persist the rotated token is never stranded.
|
|
-- * Presenting it AFTER the window is a reuse/breach signal → the grant is
|
|
-- revoked (reuse detection preserved).
|
|
-- All four shadow columns default NULL → existing keys are unaffected. Because
|
|
-- the shadow columns live on the SAME row gated by `revoked_at IS NULL`, a
|
|
-- single `revoked_at` UPDATE atomically kills the current AND both previous
|
|
-- credentials (RFC 9700 grant-family revocation, no fan-out).
|
|
|
|
ALTER TABLE public.api_keys
|
|
ADD COLUMN IF NOT EXISTS previous_key_hash text,
|
|
ADD COLUMN IF NOT EXISTS previous_key_expires_at timestamptz,
|
|
ADD COLUMN IF NOT EXISTS previous_refresh_token_hash text,
|
|
ADD COLUMN IF NOT EXISTS previous_refresh_expires_at timestamptz;
|
|
|
|
-- Partial-UNIQUE on each previous hash: lookups are point reads and two rows can
|
|
-- never claim the same previous hash. Separate from the current-hash indexes so
|
|
-- current + previous coexist on one row without collision.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_previous_key_hash
|
|
ON public.api_keys (previous_key_hash)
|
|
WHERE previous_key_hash IS NOT NULL;
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_previous_refresh_token_hash
|
|
ON public.api_keys (previous_refresh_token_hash)
|
|
WHERE previous_refresh_token_hash IS NOT NULL;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- validate_and_increment_api_key: accept the current key_hash OR an unexpired
|
|
-- previous_key_hash (access-token grace). Identical return shape, so callers in
|
|
-- lib/auth/api-keys.ts are unaffected. The rate-limit UPDATE is now keyed off
|
|
-- the resolved row id (not p_key_hash) so a grace-hash hit still increments.
|
|
-- (CREATE OR REPLACE cannot keep an identical signature across a body change
|
|
-- cleanly here; DROP+CREATE mirrors the pattern in 20260512162506.)
|
|
-- ---------------------------------------------------------------------------
|
|
DROP FUNCTION IF EXISTS public.validate_and_increment_api_key(text);
|
|
|
|
CREATE FUNCTION public.validate_and_increment_api_key(p_key_hash text)
|
|
RETURNS TABLE(
|
|
user_id uuid,
|
|
company_id uuid,
|
|
api_key_id uuid,
|
|
api_key_name text,
|
|
rate_limited boolean,
|
|
scopes text[],
|
|
mode text
|
|
)
|
|
LANGUAGE plpgsql SECURITY DEFINER AS $$
|
|
DECLARE
|
|
v_id uuid;
|
|
v_user_id uuid;
|
|
v_company_id uuid;
|
|
v_api_key_name text;
|
|
v_rate_limit_rpm integer;
|
|
v_request_count integer;
|
|
v_window_start timestamptz;
|
|
v_scopes text[];
|
|
v_mode text;
|
|
BEGIN
|
|
-- Match the live key_hash, OR a previous (just-rotated) key_hash that is still
|
|
-- inside its grace window. Both gated by revoked_at IS NULL.
|
|
SELECT ak.id, ak.user_id, ak.company_id, ak.name,
|
|
ak.rate_limit_rpm, ak.request_count, ak.rate_limit_window_start, ak.scopes, ak.mode
|
|
INTO v_id, v_user_id, v_company_id, v_api_key_name,
|
|
v_rate_limit_rpm, v_request_count, v_window_start, v_scopes, v_mode
|
|
FROM public.api_keys ak
|
|
WHERE ak.revoked_at IS NULL
|
|
AND (
|
|
ak.key_hash = p_key_hash
|
|
OR (
|
|
ak.previous_key_hash = p_key_hash
|
|
AND ak.previous_key_expires_at IS NOT NULL
|
|
AND ak.previous_key_expires_at > now()
|
|
)
|
|
)
|
|
FOR UPDATE;
|
|
|
|
IF v_id IS NULL THEN
|
|
RETURN; -- no live match (incl. expired grace) → caller returns 401, as before
|
|
END IF;
|
|
|
|
-- Reset the rate-limit window if it is unset or older than one minute.
|
|
IF v_window_start IS NULL OR v_window_start < now() - interval '1 minute' THEN
|
|
UPDATE public.api_keys
|
|
SET request_count = 1,
|
|
rate_limit_window_start = now(),
|
|
last_used_at = now()
|
|
WHERE id = v_id;
|
|
RETURN QUERY SELECT v_user_id, v_company_id, v_id, v_api_key_name, false, v_scopes, v_mode;
|
|
RETURN;
|
|
END IF;
|
|
|
|
IF v_request_count >= v_rate_limit_rpm THEN
|
|
RETURN QUERY SELECT v_user_id, v_company_id, v_id, v_api_key_name, true, v_scopes, v_mode;
|
|
RETURN;
|
|
END IF;
|
|
|
|
UPDATE public.api_keys
|
|
SET request_count = request_count + 1,
|
|
last_used_at = now()
|
|
WHERE id = v_id;
|
|
|
|
RETURN QUERY SELECT v_user_id, v_company_id, v_id, v_api_key_name, false, v_scopes, v_mode;
|
|
END;
|
|
$$;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- rotate_mcp_refresh_token: atomic lookup + rotate + demote + idempotent replay
|
|
-- for the OAuth refresh_token grant. Replaces the JS SELECT-then-CAS (which had
|
|
-- a TOCTOU gap). The caller pre-generates the candidate new credentials; this
|
|
-- function decides whether to use them.
|
|
--
|
|
-- Outcomes:
|
|
-- 'rotated' presented hash = current refresh token → normal rotation;
|
|
-- current creds demoted to previous_* with a grace window.
|
|
-- 'replayed' presented hash = an unexpired previous refresh token → the
|
|
-- client retried / mis-persisted the rotated token (or a
|
|
-- concurrent sibling already rotated). Re-issue a fresh pair
|
|
-- and SLIDE the grace window so an actively-refreshing client
|
|
-- keeps working. The previous hash is preserved (not chained)
|
|
-- so a truly idle gap longer than the window still trips reuse.
|
|
-- 'reuse_revoked' presented hash = an EXPIRED previous refresh token → reuse
|
|
-- after grace = breach (RFC 9700 §4.14.2) → revoke the grant.
|
|
-- 'revoked' the matched grant is already revoked.
|
|
-- 'invalid' no matching grant.
|
|
-- ---------------------------------------------------------------------------
|
|
CREATE OR REPLACE FUNCTION public.rotate_mcp_refresh_token(
|
|
p_presented_hash text,
|
|
p_new_refresh_hash text,
|
|
p_new_key_hash text,
|
|
p_new_key_prefix text,
|
|
p_grace_seconds integer
|
|
)
|
|
RETURNS TABLE(outcome text, scopes text[])
|
|
LANGUAGE plpgsql SECURITY DEFINER AS $$
|
|
DECLARE
|
|
v_id uuid;
|
|
v_revoked timestamptz;
|
|
v_scopes text[];
|
|
v_cur_key_hash text;
|
|
v_prev_refresh_expires timestamptz;
|
|
v_match text;
|
|
BEGIN
|
|
-- Current refresh token?
|
|
SELECT ak.id, ak.revoked_at, ak.scopes, ak.key_hash
|
|
INTO v_id, v_revoked, v_scopes, v_cur_key_hash
|
|
FROM public.api_keys ak
|
|
WHERE ak.refresh_token_hash = p_presented_hash
|
|
FOR UPDATE;
|
|
|
|
IF v_id IS NOT NULL THEN
|
|
v_match := 'current';
|
|
ELSE
|
|
-- Previous (just-rotated) refresh token?
|
|
SELECT ak.id, ak.revoked_at, ak.scopes, ak.key_hash, ak.previous_refresh_expires_at
|
|
INTO v_id, v_revoked, v_scopes, v_cur_key_hash, v_prev_refresh_expires
|
|
FROM public.api_keys ak
|
|
WHERE ak.previous_refresh_token_hash = p_presented_hash
|
|
FOR UPDATE;
|
|
IF v_id IS NOT NULL THEN
|
|
v_match := 'previous';
|
|
END IF;
|
|
END IF;
|
|
|
|
IF v_id IS NULL THEN
|
|
RETURN QUERY SELECT 'invalid'::text, NULL::text[];
|
|
RETURN;
|
|
END IF;
|
|
|
|
IF v_revoked IS NOT NULL THEN
|
|
RETURN QUERY SELECT 'revoked'::text, NULL::text[];
|
|
RETURN;
|
|
END IF;
|
|
|
|
IF v_match = 'current' THEN
|
|
-- Normal rotation. Demote the consumed refresh token and the superseded
|
|
-- access key to previous_* with a grace window, then promote the new pair.
|
|
UPDATE public.api_keys
|
|
SET previous_refresh_token_hash = p_presented_hash,
|
|
previous_refresh_expires_at = now() + make_interval(secs => p_grace_seconds),
|
|
previous_key_hash = v_cur_key_hash,
|
|
previous_key_expires_at = now() + make_interval(secs => p_grace_seconds),
|
|
refresh_token_hash = p_new_refresh_hash,
|
|
key_hash = p_new_key_hash,
|
|
key_prefix = p_new_key_prefix
|
|
WHERE id = v_id;
|
|
RETURN QUERY SELECT 'rotated'::text, v_scopes;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- v_match = 'previous'
|
|
IF v_prev_refresh_expires IS NOT NULL AND v_prev_refresh_expires > now() THEN
|
|
-- Idempotent in-grace replay: a retried / mis-persisted / concurrent
|
|
-- refresh. Re-issue a fresh current pair and SLIDE the grace window. The
|
|
-- previous refresh hash stays the presented one (no chaining), so reuse of
|
|
-- the original token after an idle gap longer than the window still trips
|
|
-- reuse detection below.
|
|
UPDATE public.api_keys
|
|
SET previous_refresh_expires_at = now() + make_interval(secs => p_grace_seconds),
|
|
previous_key_hash = v_cur_key_hash,
|
|
previous_key_expires_at = now() + make_interval(secs => p_grace_seconds),
|
|
refresh_token_hash = p_new_refresh_hash,
|
|
key_hash = p_new_key_hash,
|
|
key_prefix = p_new_key_prefix
|
|
WHERE id = v_id;
|
|
RETURN QUERY SELECT 'replayed'::text, v_scopes;
|
|
RETURN;
|
|
END IF;
|
|
|
|
-- Reuse of a previous refresh token AFTER its grace window = breach signal.
|
|
-- Revoke the grant family (single row → kills current + both previous creds).
|
|
UPDATE public.api_keys
|
|
SET revoked_at = now()
|
|
WHERE id = v_id;
|
|
RETURN QUERY SELECT 'reuse_revoked'::text, NULL::text[];
|
|
END;
|
|
$$;
|
|
|
|
NOTIFY pgrst, 'reload schema';
|