tests/console-auth-proxy.unit.test.ts
Rama main · réplica de lectura del repositorio original.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { app, consoleAuthPath, requiresCaptcha } from '../supabase/functions/_backend/private/console_auth_proxy'
const fetchMock = vi.fn()
const BASE_ENV: Record<string, string> = { SUPABASE_URL: 'https://auth.example.co/', SUPABASE_ANON_KEY: 'anon-key' }
const FORWARDING_ENV = { ...BASE_ENV, SUPABASE_SERVICE_ROLE_KEY: 'sb_secret_test', CAPTCHA_SECRET_KEY: 'turnstile-secret' }
const USER_JWT = 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1In0.sig'
const TURNSTILE_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
let env = BASE_ENV
vi.mock('../supabase/functions/_backend/utils/hono.ts', () => ({
quickError: (status: number, error: string) => Response.json({ error }, { status }),
}))
vi.mock('../supabase/functions/_backend/utils/utils.ts', () => ({
getEnv: (_c: unknown, key: string) => env[key] ?? '',
}))
beforeEach(() => {
env = BASE_ENV
vi.stubGlobal('fetch', fetchMock)
fetchMock.mockReset()
fetchMock.mockResolvedValue(new Response('{"access_token":"t"}', { status: 200, headers: { 'content-type': 'application/json', 'set-cookie': 'leak=1' } }))
})
afterEach(() => vi.unstubAllGlobals())
describe('console auth relay', () => {
it('relays allowed endpoints with the anon key and the browser address', async () => {
const response = await app.request('https://api.example.com/private/console/auth/token?grant_type=password', {
method: 'POST',
headers: { 'content-type': 'application/json', 'cf-connecting-ip': '203.0.113.9', 'cookie': 'session=secret', 'x-client-info': 'capgo-console' },
body: JSON.stringify({ email: 'a', password: 'b' }),
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ access_token: 't' })
expect(response.headers.get('set-cookie')).toBeNull()
expect(response.headers.get('cache-control')).toBe('no-store')
const [url, init] = fetchMock.mock.calls[0]
expect(url.href).toBe('https://auth.example.co/auth/v1/token?grant_type=password')
expect(init.method).toBe('POST')
expect(init.redirect).toBe('manual')
const headers = new Headers(init.headers)
expect(headers.get('apikey')).toBe('anon-key')
expect(headers.get('authorization')).toBe('Bearer anon-key')
// Supabase Auth rate limits, CAPTCHA checks and audit logs key on these.
expect(headers.get('x-forwarded-for')).toBe('203.0.113.9')
expect(headers.get('sb-forwarded-for')).toBe('203.0.113.9')
expect(headers.get('x-client-info')).toBe('capgo-console')
expect(headers.has('cookie')).toBe(false)
expect(Buffer.from(init.body).toString()).toBe('{"email":"a","password":"b"}')
})
it('forwards the user session for authenticated endpoints', async () => {
await app.request('https://api.example.com/private/console/auth/user', { headers: { authorization: 'Bearer user-jwt', 'x-forwarded-for': '198.51.100.4, 10.0.0.1' } })
const headers = new Headers(fetchMock.mock.calls[0][1].headers)
expect(headers.get('authorization')).toBe('Bearer user-jwt')
expect(headers.get('x-forwarded-for')).toBe('198.51.100.4')
expect(fetchMock.mock.calls[0][1].body).toBeUndefined()
})
it('passes verification redirects back untouched so email links complete', async () => {
fetchMock.mockResolvedValueOnce(new Response(null, { status: 303, headers: { location: 'https://console.example/login#access_token=x' } }))
const response = await app.request('https://api.example.com/private/console/auth/verify?token=abc&type=signup')
expect(response.status).toBe(303)
expect(response.headers.get('location')).toBe('https://console.example/login#access_token=x')
expect(fetchMock.mock.calls[0][0].href).toBe('https://auth.example.co/auth/v1/verify?token=abc&type=signup')
})
it('never relays admin or unknown endpoints', async () => {
for (const path of ['admin/users', 'admin/generate_link', 'invite', '../rest/v1/users', 'factors/x/y/z', '']) {
const response = await app.request(`https://api.example.com/private/console/auth/${path}`, { method: 'POST' })
expect(response.status, path).toBe(404)
}
expect(fetchMock).not.toHaveBeenCalled()
expect(consoleAuthPath('/private/console/auth/factors/abc-123/verify')).toBe('factors/abc-123/verify')
expect(consoleAuthPath('/private/console/auth/admin/users')).toBeNull()
})
it('rejects oversized bodies before contacting the auth provider', async () => {
const response = await app.request('https://api.example.com/private/console/auth/signup', { method: 'POST', body: 'x'.repeat(70 * 1024) })
expect(response.status).toBe(413)
const declared = await app.request('https://api.example.com/private/console/auth/signup', { method: 'POST', headers: { 'content-length': String(80 * 1024) }, body: '{}' })
expect(declared.status).toBe(413)
expect(fetchMock).not.toHaveBeenCalled()
})
})
describe('console auth relay client address forwarding', () => {
let turnstile: { success: boolean, 'error-codes'?: string[] }
beforeEach(() => {
env = FORWARDING_ENV
turnstile = { success: true }
fetchMock.mockImplementation(async (url: URL | string) => {
if (String(url) === TURNSTILE_URL)
return Response.json(turnstile)
return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } })
})
})
function authCalls() {
return fetchMock.mock.calls.filter(([url]) => String(url) !== TURNSTILE_URL)
}
function turnstileCalls() {
return fetchMock.mock.calls.filter(([url]) => String(url) === TURNSTILE_URL)
}
async function relay(path: string, headers: Record<string, string>, body: unknown = { gotrue_meta_security: { captcha_token: 'cf-token' } }) {
const response = await app.request(`https://api.example.com/private/console/auth/${path}`, { method: 'POST', headers, body: JSON.stringify(body) })
const call = authCalls()[0]
return { response, headers: call ? new Headers(call[1].headers) : null }
}
it('verifies Turnstile itself, then relays with the secret key and the Cloudflare address', async () => {
const { response, headers } = await relay('signup', { 'cf-connecting-ip': '203.0.113.9', 'user-agent': 'Mozilla/5.0 Chrome/130', 'authorization': 'Bearer sb_publishable_x' })
expect(response.status).toBe(200)
const [, init] = turnstileCalls()[0]
expect(init.body.get('secret')).toBe('turnstile-secret')
expect(init.body.get('response')).toBe('cf-token')
expect(init.body.get('remoteip')).toBe('203.0.113.9')
expect(headers?.get('apikey')).toBe('sb_secret_test')
// A client-chosen non-JWT bearer is dropped, never relayed.
expect(headers?.has('authorization')).toBe(false)
expect(headers?.get('sb-forwarded-for')).toBe('203.0.113.9')
expect(headers?.get('x-forwarded-for')).toBe('203.0.113.9')
// Supabase rejects secret-key requests that carry a browser User-Agent.
expect(headers?.get('user-agent')).toBe('capgo-console-auth-relay')
})
it('rejects missing or failed captchas with GoTrue\'s error before contacting Supabase Auth', async () => {
const missing = await relay('token?grant_type=password', { 'cf-connecting-ip': '203.0.113.9' }, { email: 'a', password: 'b' })
expect(missing.response.status).toBe(400)
expect(missing.response.headers.get('x-sb-error-code')).toBe('captcha_failed')
expect(await missing.response.json()).toMatchObject({ error_code: 'captcha_failed', msg: 'captcha protection: request disallowed (no captcha_token found)' })
expect(turnstileCalls()).toHaveLength(0)
turnstile = { 'success': false, 'error-codes': ['timeout-or-duplicate'] }
const failed = await relay('recover', { 'cf-connecting-ip': '203.0.113.9' })
expect(failed.response.status).toBe(400)
expect(await failed.response.json()).toMatchObject({ error_code: 'captcha_failed', msg: 'captcha protection: request disallowed (timeout-or-duplicate)' })
const unparsable = await app.request('https://api.example.com/private/console/auth/otp', { method: 'POST', headers: { 'cf-connecting-ip': '203.0.113.9' }, body: 'not json' })
expect(unparsable.status).toBe(400)
expect(authCalls()).toHaveLength(0)
})
it('leaves the CAPTCHA to GoTrue when a user session is the caller', async () => {
const { headers } = await relay('signup', { 'cf-connecting-ip': '203.0.113.9', 'authorization': USER_JWT })
expect(turnstileCalls()).toHaveLength(0)
expect(headers?.get('apikey')).toBe('sb_secret_test')
expect(headers?.get('authorization')).toBe(USER_JWT)
})
it('does not ask for a captcha on routes GoTrue does not gate', async () => {
for (const path of ['token?grant_type=refresh_token', 'token?grant_type=pkce', 'verify', 'logout']) {
fetchMock.mockClear()
const { response } = await relay(path, { 'cf-connecting-ip': '203.0.113.9' }, {})
expect(response.status, path).toBe(200)
expect(turnstileCalls(), path).toHaveLength(0)
}
})
it('only forwards the Cloudflare address, never a client-supplied x-forwarded-for, with the secret key', async () => {
const { headers } = await relay('recover', { 'x-forwarded-for': '198.51.100.4', 'user-agent': 'Mozilla/5.0' })
expect(turnstileCalls()).toHaveLength(0)
expect(headers?.get('apikey')).toBe('anon-key')
expect(headers?.get('authorization')).toBe('Bearer anon-key')
expect(headers?.get('user-agent')).toBe('Mozilla/5.0')
})
it('falls back to the publishable key, and GoTrue\'s own CAPTCHA, without a secret key or captcha secret', async () => {
const unusable: Record<string, string>[] = [{ CAPTCHA_SECRET_KEY: '' }, { SUPABASE_SERVICE_ROLE_KEY: 'eyJ.legacy.jwt' }]
for (const missing of unusable) {
env = { ...FORWARDING_ENV, ...missing }
fetchMock.mockClear()
const { headers } = await relay('otp', { 'cf-connecting-ip': '203.0.113.9' })
expect(turnstileCalls()).toHaveLength(0)
expect(headers?.get('apikey')).toBe('anon-key')
expect(headers?.get('authorization')).toBe('Bearer anon-key')
expect(headers?.get('sb-forwarded-for')).toBe('203.0.113.9')
}
})
})
describe('captcha-gated routes', () => {
it('matches the GoTrue routes behind verifyCaptcha', () => {
const search = (query: string) => new URLSearchParams(query)
for (const path of ['signup', 'recover', 'resend', 'otp', 'sso'])
expect(requiresCaptcha(path, 'POST', search('')), path).toBe(true)
expect(requiresCaptcha('token', 'POST', search('grant_type=password'))).toBe(true)
expect(requiresCaptcha('token', 'POST', search('grant_type=web3'))).toBe(true)
expect(requiresCaptcha('token', 'POST', search(''))).toBe(true)
for (const grant of ['pkce', 'refresh_token', 'id_token'])
expect(requiresCaptcha('token', 'POST', search(`grant_type=${grant}`)), grant).toBe(false)
expect(requiresCaptcha('signup', 'GET', search(''))).toBe(false)
expect(requiresCaptcha('verify', 'POST', search(''))).toBe(false)
})
})