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;
|
||||||
167
server/supabase/tests/device-session-revocation.integration.sql
Normal file
167
server/supabase/tests/device-session-revocation.integration.sql
Normal file
|
|
@ -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;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue