-- 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;