diff --git a/supabase/migrations/20260602145425_lock_down_oauth_used_codes.sql b/supabase/migrations/20260602145425_lock_down_oauth_used_codes.sql new file mode 100644 index 00000000..9fd0ed41 --- /dev/null +++ b/supabase/migrations/20260602145425_lock_down_oauth_used_codes.sql @@ -0,0 +1,9 @@ +-- OAuth authorization-code replay tracking is internal-only. The token +-- endpoint uses a service-role client, so browser-facing API roles must not +-- access this table. +ALTER TABLE public.oauth_used_codes ENABLE ROW LEVEL SECURITY; + +REVOKE ALL ON TABLE public.oauth_used_codes FROM anon, authenticated; + +-- Privilege change: tell PostgREST to reload its schema/role cache. +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/oauth-used-codes-lockdown.pg.test.ts b/tests/pg/oauth-used-codes-lockdown.pg.test.ts new file mode 100644 index 00000000..135271b7 --- /dev/null +++ b/tests/pg/oauth-used-codes-lockdown.pg.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { getClient } from '@/tests/pg/setup' + +/** + * Migration 20260602145425_lock_down_oauth_used_codes.sql enables RLS on + * public.oauth_used_codes and REVOKEs all privileges from the browser-facing + * `anon` and `authenticated` roles. The OAuth token endpoint reaches this + * replay-tracking table only through a service-role client (which bypasses + * both RLS and the revoke), so the API roles must be denied. These tests lock + * that contract in so a future migration cannot silently re-expose the table. + */ +describe('oauth_used_codes lockdown (pg)', () => { + async function expectDenied(role: 'anon' | 'authenticated', sql: string) { + const client = await getClient() + try { + await client.query('BEGIN') + await client.query(`SET LOCAL ROLE ${role}`) + await expect(client.query(sql)).rejects.toThrow(/permission denied/i) + } finally { + await client.query('ROLLBACK').catch(() => {}) + client.release() + } + } + + it('denies SELECT to the anon role', async () => { + await expectDenied('anon', 'SELECT * FROM public.oauth_used_codes LIMIT 1') + }) + + it('denies SELECT to the authenticated role', async () => { + await expectDenied('authenticated', 'SELECT * FROM public.oauth_used_codes LIMIT 1') + }) + + it('denies INSERT to the authenticated role', async () => { + await expectDenied( + 'authenticated', + `INSERT INTO public.oauth_used_codes (code_hash) VALUES ('lockdown-test')`, + ) + }) + + it('still allows the privileged (service-role / owner) connection to read', async () => { + // The app reaches this table through a service-role client, which bypasses + // RLS and the anon/authenticated revoke. Model that with the default + // privileged pg connection (table owner) the pg-real harness uses. + const client = await getClient() + try { + const res = await client.query<{ n: number }>( + 'SELECT count(*)::int AS n FROM public.oauth_used_codes', + ) + expect(res.rows[0]!.n).toBeGreaterThanOrEqual(0) + } finally { + client.release() + } + }) +})