diff --git a/server/supabase/migrations/20260929000005_device_session_revocation.sql b/server/supabase/migrations/20260929000005_device_session_revocation.sql new file mode 100644 index 0000000..d4fac02 --- /dev/null +++ b/server/supabase/migrations/20260929000005_device_session_revocation.sql @@ -0,0 +1,188 @@ +-- 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; diff --git a/server/supabase/tests/device-session-revocation.integration.sql b/server/supabase/tests/device-session-revocation.integration.sql new file mode 100644 index 0000000..ac8f017 --- /dev/null +++ b/server/supabase/tests/device-session-revocation.integration.sql @@ -0,0 +1,167 @@ +\set ON_ERROR_STOP on + +-- Regression for 20260929000005_device_session_revocation.sql. +-- Revoking a device must end that device's auth session (and, by cascade, its +-- refresh tokens) so a stolen device cannot keep refreshing its token and +-- reading account data through PostgREST. The session binding is +-- server-managed: clients cannot forge it, and it can never point at another +-- user's session. + +BEGIN; + +CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text) +RETURNS void +LANGUAGE plpgsql +AS $$ +BEGIN + IF condition IS NOT TRUE THEN + RAISE EXCEPTION 'assertion_failed: %', message; + END IF; +END; +$$; + +CREATE OR REPLACE FUNCTION pg_temp.act_as(uid uuid, sid uuid) +RETURNS void +LANGUAGE sql +AS $$ + SELECT set_config( + 'request.jwt.claims', + json_build_object('sub', uid, 'role', 'authenticated', 'session_id', sid)::text, + true + ); +$$; + +INSERT INTO auth.users ( + id, aud, role, email, encrypted_password, email_confirmed_at, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at +) VALUES + ('33000000-0000-4000-8000-000000000001', 'authenticated', 'authenticated', + 'device-session-owner@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()), + ('33000000-0000-4000-8000-000000000002', 'authenticated', 'authenticated', + 'device-session-other@example.invalid', crypt('fixture-password', gen_salt('bf')), now(), + '{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()); + +-- Sessions: laptop (L), phone (P), a second laptop (L2) of the owner, and one +-- session (O) that belongs to a different user. +INSERT INTO auth.sessions (id, user_id, created_at, updated_at) VALUES + ('33000000-0000-4000-8000-00000000a001', '33000000-0000-4000-8000-000000000001', now(), now()), + ('33000000-0000-4000-8000-00000000a002', '33000000-0000-4000-8000-000000000001', now(), now()), + ('33000000-0000-4000-8000-00000000a003', '33000000-0000-4000-8000-000000000001', now(), now()), + ('33000000-0000-4000-8000-00000000a0ff', '33000000-0000-4000-8000-000000000002', now(), now()); + +INSERT INTO auth.refresh_tokens (token, user_id, revoked, created_at, updated_at, session_id) VALUES + ('device-session-laptop-token', '33000000-0000-4000-8000-000000000001', false, now(), now(), + '33000000-0000-4000-8000-00000000a001'), + ('device-session-phone-token', '33000000-0000-4000-8000-000000000001', false, now(), now(), + '33000000-0000-4000-8000-00000000a002'); + +-- 1. Registration stamps the caller's own session; a forged value is ignored. +SET LOCAL ROLE authenticated; +SELECT pg_temp.act_as('33000000-0000-4000-8000-000000000001', '33000000-0000-4000-8000-00000000a001'); +INSERT INTO public.devices (id, user_id, installation_id, platform, device_name, app_version, auth_session_id) +VALUES ('33000000-0000-4000-8000-00000000d001', '33000000-0000-4000-8000-000000000001', + '33000000-0000-4000-8000-00000000e001', 'windows', 'Laptop', '1.0.0', + '33000000-0000-4000-8000-00000000a002'); + +SELECT pg_temp.act_as('33000000-0000-4000-8000-000000000001', '33000000-0000-4000-8000-00000000a002'); +INSERT INTO public.devices (id, user_id, installation_id, platform, device_name, app_version) +VALUES ('33000000-0000-4000-8000-00000000d002', '33000000-0000-4000-8000-000000000001', + '33000000-0000-4000-8000-00000000e002', 'android', 'Phone', '1.0.0'); +RESET ROLE; + +SELECT pg_temp.assert_true( + (SELECT auth_session_id FROM public.devices WHERE id = '33000000-0000-4000-8000-00000000d001') + = '33000000-0000-4000-8000-00000000a001', + 'laptop registration binds the laptop session, not the forged phone session' +); +SELECT pg_temp.assert_true( + (SELECT auth_session_id FROM public.devices WHERE id = '33000000-0000-4000-8000-00000000d002') + = '33000000-0000-4000-8000-00000000a002', + 'phone registration binds the phone session' +); + +-- 2. A client cannot re-point its row at another session: a plain update keeps +-- the binding, and a check-in only ever stamps the caller's own session. +SET LOCAL ROLE authenticated; +SELECT pg_temp.act_as('33000000-0000-4000-8000-000000000001', '33000000-0000-4000-8000-00000000a001'); +UPDATE public.devices +SET auth_session_id = '33000000-0000-4000-8000-00000000a002', device_name = 'Renamed laptop' +WHERE id = '33000000-0000-4000-8000-00000000d001'; +UPDATE public.devices +SET auth_session_id = '33000000-0000-4000-8000-00000000a0ff', last_seen_at = now() + interval '1 minute' +WHERE id = '33000000-0000-4000-8000-00000000d001'; +RESET ROLE; + +SELECT pg_temp.assert_true( + (SELECT auth_session_id FROM public.devices WHERE id = '33000000-0000-4000-8000-00000000d001') + = '33000000-0000-4000-8000-00000000a001', + 'client writes to auth_session_id are ignored' +); + +-- 3. Revoking the laptop from the phone ends the laptop session and its +-- refresh tokens; the phone session is untouched. +SET LOCAL ROLE authenticated; +SELECT pg_temp.act_as('33000000-0000-4000-8000-000000000001', '33000000-0000-4000-8000-00000000a002'); +CREATE TEMP TABLE revoke_result ON COMMIT DROP AS +SELECT public.revoke_device('33000000-0000-4000-8000-00000000d001') AS payload; +RESET ROLE; + +SELECT pg_temp.assert_true( + (SELECT (payload->>'revoked_at') IS NOT NULL AND NOT (payload ? 'auth_session_id') FROM revoke_result), + 'revoke_device returns the revoked row without the session binding' +); +SELECT pg_temp.assert_true( + NOT EXISTS (SELECT 1 FROM auth.sessions WHERE id = '33000000-0000-4000-8000-00000000a001'), + 'revoked device session is deleted' +); +SELECT pg_temp.assert_true( + NOT EXISTS (SELECT 1 FROM auth.refresh_tokens WHERE token = 'device-session-laptop-token'), + 'revoked device refresh token is gone with its session' +); +SELECT pg_temp.assert_true( + EXISTS (SELECT 1 FROM auth.sessions WHERE id = '33000000-0000-4000-8000-00000000a002') + AND EXISTS (SELECT 1 FROM auth.refresh_tokens WHERE token = 'device-session-phone-token'), + 'revoking another device keeps the caller session' +); + +-- 4. A revoke never deletes a session of a different user, even if the binding +-- was tampered with by a privileged path. +INSERT INTO public.devices (id, user_id, installation_id, platform, device_name, app_version, auth_session_id) +VALUES ('33000000-0000-4000-8000-00000000d003', '33000000-0000-4000-8000-000000000001', + '33000000-0000-4000-8000-00000000e003', 'macos', 'Laptop 2', '1.0.0', + '33000000-0000-4000-8000-00000000a0ff'); +SET LOCAL ROLE authenticated; +SELECT pg_temp.act_as('33000000-0000-4000-8000-000000000001', '33000000-0000-4000-8000-00000000a002'); +SELECT public.revoke_device('33000000-0000-4000-8000-00000000d003'); +RESET ROLE; + +SELECT pg_temp.assert_true( + EXISTS (SELECT 1 FROM auth.sessions WHERE id = '33000000-0000-4000-8000-00000000a0ff'), + 'revoke_device only deletes sessions of the caller' +); + +-- 5. A device that removes itself from the list ends its own session, so a +-- stolen session cannot hide its device row and survive. +SET LOCAL ROLE authenticated; +SELECT pg_temp.act_as('33000000-0000-4000-8000-000000000001', '33000000-0000-4000-8000-00000000a003'); +INSERT INTO public.devices (id, user_id, installation_id, platform, device_name, app_version) +VALUES ('33000000-0000-4000-8000-00000000d004', '33000000-0000-4000-8000-000000000001', + '33000000-0000-4000-8000-00000000e004', 'windows', 'Laptop 3', '1.0.0'); +SELECT public.unregister_current_device('33000000-0000-4000-8000-00000000e004'); +RESET ROLE; + +SELECT pg_temp.assert_true( + NOT EXISTS (SELECT 1 FROM public.devices WHERE id = '33000000-0000-4000-8000-00000000d004'), + 'unregistered device row is removed' +); +SELECT pg_temp.assert_true( + NOT EXISTS (SELECT 1 FROM auth.sessions WHERE id = '33000000-0000-4000-8000-00000000a003'), + 'unregistered device session is deleted' +); +SELECT pg_temp.assert_true( + EXISTS (SELECT 1 FROM auth.sessions WHERE id = '33000000-0000-4000-8000-00000000a002'), + 'unregister leaves other sessions of the user alone' +); + +ROLLBACK;