d3ro-voice/server/supabase/migrations/20260929000005_device_session_revocation.sql

188 lines
6.5 KiB
PL/PgSQL

-- Device revocation must end the revoked device's auth session.
--
-- Before this migration revoke_device only set devices.revoked_at and dropped
-- push tokens. The device's Supabase auth session (and its refresh token) stayed
-- valid, and every data policy checks only user_id = auth.uid(), so whoever held
-- the refresh token of a revoked (for example stolen) device could keep
-- refreshing it and read or write all account data. Only the unmodified desktop
-- app signed itself out when it saw revoked_at.
--
-- Fix:
-- 1. devices.auth_session_id records the auth session that registered or last
-- checked in the device. It is server-managed: a trigger stamps it from the
-- caller's JWT (session_id claim) on INSERT and on a check-in UPDATE (one
-- that moves last_seen_at, which both clients send only for their own row).
-- Clients cannot write the column directly, and a caller can only ever stamp
-- its own session, so a revoked holder cannot point its row at someone
-- else's session.
-- 2. revoke_device deletes that auth.sessions row. auth.refresh_tokens
-- references auth.sessions ON DELETE CASCADE, so the session's refresh tokens
-- go with it and can no longer be exchanged for new access tokens.
-- 3. unregister_current_device (the signing-out device removing itself) ends
-- the recorded session too. Otherwise a holder of a stolen session could
-- unregister the device row to hide it from the device list, leaving the
-- owner nothing to revoke.
--
-- Residual: an access token that was already issued stays valid until it
-- expires (auth.jwt_expiry), because PostgREST verifies JWT signatures only.
-- Rows registered before this migration get their session on the next check-in.
BEGIN;
ALTER TABLE public.devices
ADD COLUMN IF NOT EXISTS auth_session_id uuid;
COMMENT ON COLUMN public.devices.auth_session_id IS
'Server-managed: auth session that registered or last checked in this device. revoke_device deletes it.';
CREATE OR REPLACE FUNCTION public.current_auth_session_id()
RETURNS uuid
LANGUAGE plpgsql
STABLE
SET search_path = ''
AS $$
DECLARE
claimed text := nullif(auth.jwt()->>'session_id', '');
BEGIN
IF claimed IS NULL THEN
RETURN NULL;
END IF;
RETURN claimed::uuid;
EXCEPTION WHEN invalid_text_representation THEN
RETURN NULL;
END;
$$;
REVOKE ALL ON FUNCTION public.current_auth_session_id() FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.current_auth_session_id() TO authenticated, service_role;
CREATE OR REPLACE FUNCTION public.stamp_device_auth_session()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = ''
AS $$
BEGIN
-- Server-side paths (SECURITY DEFINER RPCs, service role) manage the column
-- themselves.
IF current_user IN ('postgres', 'service_role', 'supabase_admin') THEN
RETURN NEW;
END IF;
IF TG_OP = 'INSERT' THEN
NEW.auth_session_id := public.current_auth_session_id();
RETURN NEW;
END IF;
-- UPDATE: only a check-in of a still-active device re-binds the session.
IF OLD.revoked_at IS NULL
AND NEW.last_seen_at IS DISTINCT FROM OLD.last_seen_at
AND public.current_auth_session_id() IS NOT NULL THEN
NEW.auth_session_id := public.current_auth_session_id();
ELSE
NEW.auth_session_id := OLD.auth_session_id;
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS stamp_device_auth_session ON public.devices;
CREATE TRIGGER stamp_device_auth_session
BEFORE INSERT OR UPDATE ON public.devices
FOR EACH ROW EXECUTE FUNCTION public.stamp_device_auth_session();
CREATE OR REPLACE FUNCTION public.revoke_device(target_device_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
target_device public.devices;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF target_device_id IS NULL THEN
RAISE EXCEPTION 'invalid_device' USING ERRCODE = '22023';
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(target_device_id::text, 73052));
SELECT * INTO target_device
FROM public.devices
WHERE id = target_device_id AND user_id = current_user_id
FOR UPDATE;
IF target_device.id IS NULL THEN
RAISE EXCEPTION 'device_not_found' USING ERRCODE = 'P0002';
END IF;
IF target_device.revoked_at IS NULL THEN
UPDATE public.devices
SET revoked_at = now(), push_token = NULL
WHERE id = target_device.id
RETURNING * INTO target_device;
END IF;
DELETE FROM public.push_tokens
WHERE device_id = target_device.id AND user_id = current_user_id;
-- End the device's auth session; its refresh tokens cascade with it.
-- Idempotent: repeating a revoke also retries a session that survived.
IF target_device.auth_session_id IS NOT NULL THEN
DELETE FROM auth.sessions
WHERE id = target_device.auth_session_id AND user_id = current_user_id;
END IF;
RETURN to_jsonb(target_device) - 'auth_session_id';
END;
$$;
CREATE OR REPLACE FUNCTION public.unregister_current_device(
current_installation_id uuid
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
current_user_id uuid := auth.uid();
target_device_id uuid;
target_session_id uuid;
BEGIN
IF current_user_id IS NULL THEN
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
END IF;
IF current_installation_id IS NULL THEN
RAISE EXCEPTION 'invalid_device' USING ERRCODE = '22023';
END IF;
SELECT id, auth_session_id INTO target_device_id, target_session_id
FROM public.devices
WHERE user_id = current_user_id
AND installation_id = current_installation_id
AND revoked_at IS NULL
FOR UPDATE;
IF target_device_id IS NULL THEN
RETURN jsonb_build_object('removed', false);
END IF;
DELETE FROM public.push_tokens
WHERE device_id = target_device_id AND user_id = current_user_id;
DELETE FROM public.devices
WHERE id = target_device_id AND user_id = current_user_id AND revoked_at IS NULL;
-- The device is signing out: its recorded session ends with the row, so
-- removing a device from the list can never leave its session alive.
IF target_session_id IS NOT NULL THEN
DELETE FROM auth.sessions
WHERE id = target_session_id AND user_id = current_user_id;
END IF;
RETURN jsonb_build_object('removed', true, 'device_id', target_device_id);
END;
$$;
REVOKE ALL ON FUNCTION public.revoke_device(uuid) FROM PUBLIC, anon;
REVOKE ALL ON FUNCTION public.unregister_current_device(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.revoke_device(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.unregister_current_device(uuid) TO authenticated;
COMMIT;