diff --git a/apps/web/supabase/migrations/20251024141215_optimize-verify-nonce-performance.sql b/apps/web/supabase/migrations/20251024141215_optimize-verify-nonce-performance.sql new file mode 100644 index 000000000..dae455488 --- /dev/null +++ b/apps/web/supabase/migrations/20251024141215_optimize-verify-nonce-performance.sql @@ -0,0 +1,124 @@ +-- Add optimized index for verify_nonce function query pattern +-- Matches filter order: purpose → expires_at → user_id +CREATE INDEX IF NOT EXISTS idx_nonces_verify_lookup ON public.nonces (purpose, expires_at DESC, user_id) + WHERE used_at IS NULL AND revoked = FALSE; + +-- Optimize verify_nonce function for performance + +-- New implementation: +-- 1. Adds optimized index for the verify query pattern +-- 2. Uses CTE to filter candidates using index first +-- 3. Only does bcrypt comparison on filtered candidates +-- 4. Updates only the matched token in a single operation +create or replace function public.verify_nonce ( + p_token TEXT, + p_purpose TEXT, + p_user_id UUID default null, + p_required_scopes text[] default null, + p_max_verification_attempts INTEGER default 5, + p_ip INET default null, + p_user_agent TEXT default null +) RETURNS JSONB LANGUAGE plpgsql SECURITY DEFINER + set + SEARCH_PATH to '' as $$ +DECLARE + v_nonce RECORD; +BEGIN + -- Find and update the nonce in a single operation + -- First filter by indexed columns to reduce candidate rows, then do bcrypt comparison + WITH candidate_nonces AS ( + -- Use index to filter candidates by purpose, user_id, expiry, status + SELECT id, client_token, user_id, purpose, metadata, scopes, + verification_attempts, expires_at, used_at, revoked + FROM public.nonces + WHERE purpose = p_purpose + AND used_at IS NULL + AND NOT revoked + AND expires_at > NOW() + -- Only apply user_id filter if the token was created for a specific user + AND ( + -- Case 1: Anonymous token (user_id is NULL in DB) + (user_id IS NULL) + OR + -- Case 2: User-specific token (check if user_id matches) + (user_id = p_user_id) + ) + ORDER BY created_at DESC + -- Safety net: Limit to 100 most recent candidates to cap worst-case performance + -- In production, auto-revocation keeps this low, but this protects against edge cases + LIMIT 100 + -- CRITICAL: Lock rows to prevent race conditions in concurrent verifications + -- SKIP LOCKED ensures other requests fail fast instead of waiting + FOR UPDATE SKIP LOCKED + ), + matched_nonce AS ( + -- Now do the expensive bcrypt comparison only on filtered candidates + SELECT * + FROM candidate_nonces + WHERE client_token = extensions.crypt(p_token, client_token) + LIMIT 1 + ), + updated_nonce AS ( + -- Update only the matched nonce + UPDATE public.nonces + SET verification_attempts = verification_attempts + 1, + last_verification_at = NOW(), + last_verification_ip = COALESCE(p_ip, last_verification_ip), + last_verification_user_agent = COALESCE(p_user_agent, last_verification_user_agent) + WHERE id = (SELECT id FROM matched_nonce) + RETURNING * + ) + SELECT * INTO v_nonce FROM updated_nonce; + + -- Check if nonce exists + IF v_nonce.id IS NULL THEN + RETURN jsonb_build_object( + 'valid', false, + 'message', 'Invalid or expired token' + ); + END IF; + + -- Check if max verification attempts exceeded (using the incremented value) + IF p_max_verification_attempts > 0 AND v_nonce.verification_attempts > p_max_verification_attempts THEN + -- Automatically revoke the token + UPDATE public.nonces + SET revoked = TRUE, + revoked_reason = 'Maximum verification attempts exceeded' + WHERE id = v_nonce.id; + + RETURN jsonb_build_object( + 'valid', false, + 'message', 'Token revoked due to too many verification attempts', + 'max_attempts_exceeded', true + ); + END IF; + + -- Check scopes if required + IF p_required_scopes IS NOT NULL AND array_length(p_required_scopes, 1) > 0 THEN + -- Fix scope validation to properly check if token scopes contain all required scopes + -- Using array containment check: array1 @> array2 (array1 contains array2) + IF NOT (v_nonce.scopes @> p_required_scopes) THEN + RETURN jsonb_build_object( + 'valid', false, + 'message', 'Token does not have required permissions', + 'token_scopes', v_nonce.scopes, + 'required_scopes', p_required_scopes + ); + END IF; + END IF; + + -- Mark nonce as used + UPDATE public.nonces + SET used_at = NOW() + WHERE id = v_nonce.id; + + -- Return success with metadata + RETURN jsonb_build_object( + 'valid', true, + 'user_id', v_nonce.user_id, + 'metadata', v_nonce.metadata, + 'scopes', v_nonce.scopes, + 'purpose', v_nonce.purpose + ); +END; +$$; diff --git a/apps/web/supabase/schemas/12-one-time-tokens.sql b/apps/web/supabase/schemas/12-one-time-tokens.sql index 22f371417..d6906468f 100644 --- a/apps/web/supabase/schemas/12-one-time-tokens.sql +++ b/apps/web/supabase/schemas/12-one-time-tokens.sql @@ -37,6 +37,11 @@ CREATE TABLE IF NOT EXISTS public.nonces ( CREATE INDEX IF NOT EXISTS idx_nonces_status ON public.nonces (client_token, user_id, purpose, expires_at) WHERE used_at IS NULL AND revoked = FALSE; +-- Optimized index for verify_nonce function query pattern +-- Matches filter order: purpose → expires_at → user_id +CREATE INDEX IF NOT EXISTS idx_nonces_verify_lookup ON public.nonces (purpose, expires_at DESC, user_id) + WHERE used_at IS NULL AND revoked = FALSE; + -- Enable Row Level Security (RLS) ALTER TABLE public.nonces ENABLE ROW LEVEL SECURITY; @@ -147,41 +152,52 @@ create or replace function public.verify_nonce ( SEARCH_PATH to '' as $$ DECLARE v_nonce RECORD; - v_matching_count INTEGER; BEGIN - -- Count how many matching tokens exist before verification attempt - SELECT COUNT(*) - INTO v_matching_count - FROM public.nonces - WHERE purpose = p_purpose; - - -- Update verification attempt counter and tracking info for all matching tokens - UPDATE public.nonces - SET verification_attempts = verification_attempts + 1, - last_verification_at = NOW(), - last_verification_ip = COALESCE(p_ip, last_verification_ip), - last_verification_user_agent = COALESCE(p_user_agent, last_verification_user_agent) - WHERE client_token = extensions.crypt(p_token, client_token) - AND purpose = p_purpose; - - -- Find the nonce by token and purpose - -- Modified to handle user-specific tokens better - SELECT * - INTO v_nonce - FROM public.nonces - WHERE client_token = extensions.crypt(p_token, client_token) - AND purpose = p_purpose - -- Only apply user_id filter if the token was created for a specific user - AND ( - -- Case 1: Anonymous token (user_id is NULL in DB) - (user_id IS NULL) - OR - -- Case 2: User-specific token (check if user_id matches) - (user_id = p_user_id) - ) - AND used_at IS NULL - AND NOT revoked - AND expires_at > NOW(); + -- Find and update the nonce in a single operation + -- First filter by indexed columns to reduce candidate rows, then do bcrypt comparison + WITH candidate_nonces AS ( + -- Use index to filter candidates by purpose, user_id, expiry, status + SELECT id, client_token, user_id, purpose, metadata, scopes, + verification_attempts, expires_at, used_at, revoked + FROM public.nonces + WHERE purpose = p_purpose + AND used_at IS NULL + AND NOT revoked + AND expires_at > NOW() + -- Only apply user_id filter if the token was created for a specific user + AND ( + -- Case 1: Anonymous token (user_id is NULL in DB) + (user_id IS NULL) + OR + -- Case 2: User-specific token (check if user_id matches) + (user_id = p_user_id) + ) + ORDER BY created_at DESC + -- Safety net: Limit to 100 most recent candidates to cap worst-case performance + -- In production, auto-revocation keeps this low, but this protects against edge cases + LIMIT 100 + -- CRITICAL: Lock rows to prevent race conditions in concurrent verifications + -- SKIP LOCKED ensures other requests fail fast instead of waiting + FOR UPDATE SKIP LOCKED + ), + matched_nonce AS ( + -- Now do the expensive bcrypt comparison only on filtered candidates + SELECT * + FROM candidate_nonces + WHERE client_token = extensions.crypt(p_token, client_token) + LIMIT 1 + ), + updated_nonce AS ( + -- Update only the matched nonce + UPDATE public.nonces + SET verification_attempts = verification_attempts + 1, + last_verification_at = NOW(), + last_verification_ip = COALESCE(p_ip, last_verification_ip), + last_verification_user_agent = COALESCE(p_user_agent, last_verification_user_agent) + WHERE id = (SELECT id FROM matched_nonce) + RETURNING * + ) + SELECT * INTO v_nonce FROM updated_nonce; -- Check if nonce exists IF v_nonce.id IS NULL THEN @@ -191,7 +207,7 @@ BEGIN ); END IF; - -- Check if max verification attempts exceeded + -- Check if max verification attempts exceeded (using the incremented value) IF p_max_verification_attempts > 0 AND v_nonce.verification_attempts > p_max_verification_attempts THEN -- Automatically revoke the token UPDATE public.nonces diff --git a/package.json b/package.json index 8f0b20cde..d56d3c928 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "next-supabase-saas-kit-turbo", - "version": "2.19.2", + "version": "2.19.3", "private": true, "sideEffects": false, "engines": {