fix(supabase): end the auth session when a device is revoked
This commit is contained in:
parent
ed790e672b
commit
824ee42f1e
2 changed files with 355 additions and 0 deletions
|
|
@ -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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue