Lock down OAuth used codes table (#641)

* Lock down OAuth used codes table

* test(db): lock oauth_used_codes lockdown contract + reload PostgREST cache

Add a pg-real test asserting anon/authenticated are denied SELECT/INSERT on
public.oauth_used_codes while the privileged (service-role) connection can
still read it, per the project's requirement that RLS changes ship a
*.pg.test.ts. Also append NOTIFY pgrst, 'reload schema' so PostgREST picks up
the privilege change immediately (migration rule 8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joachim Hesthammer
2026-06-08 09:47:33 +02:00
committed by GitHub
parent 5f79a74a2e
commit 5aa449ad3c
2 changed files with 63 additions and 0 deletions
@@ -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';
@@ -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()
}
})
})