import { describe, it, expect, vi, beforeEach } from 'vitest' import { createQueuedMockSupabase } from '@/tests/helpers' const mocks = vi.hoisted(() => ({ supabaseFactory: vi.fn(), })) vi.mock('@/lib/auth/api-keys', async (importOriginal) => { const actual = await importOriginal() return { ...actual, createServiceClientNoCookies: () => mocks.supabaseFactory(), } }) vi.mock('@/lib/auth/oauth-codes', () => ({ decryptAuthCode: vi.fn(), verifyPkce: vi.fn(), hashAuthCode: vi.fn(() => 'auth-code-hash'), })) vi.mock('@/lib/company/context', () => ({ requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) import { POST } from '../route' import { decryptAuthCode, verifyPkce } from '@/lib/auth/oauth-codes' import { generateRefreshToken } from '@/lib/auth/api-keys' function formRequest(body: Record) { return new Request('http://localhost/api/mcp-oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(body).toString(), }) } describe('POST /api/mcp-oauth/token', () => { beforeEach(() => { vi.clearAllMocks() }) describe('grant_type validation', () => { it('rejects unknown grant types', async () => { const res = await POST(formRequest({ grant_type: 'password' })) expect(res.status).toBe(400) const body = await res.json() expect(body.error).toBe('unsupported_grant_type') }) it('rejects unsupported content type', async () => { const req = new Request('http://localhost/api/mcp-oauth/token', { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: 'grant_type=authorization_code', }) const res = await POST(req) expect(res.status).toBe(400) }) }) describe('authorization_code grant', () => { it('returns access_token, refresh_token, and expires_in on success', async () => { vi.mocked(decryptAuthCode).mockReturnValue({ userId: 'user-1', codeChallenge: 'challenge', redirectUri: 'https://claude.ai/api/cb', exp: Date.now() + 60_000, }) vi.mocked(verifyPkce).mockReturnValue(true) const { supabase, enqueueMany } = createQueuedMockSupabase() mocks.supabaseFactory.mockReturnValue(supabase) enqueueMany([ { data: null, error: null }, // insert into oauth_used_codes { data: null, error: null }, // delete expired codes (best-effort) { data: null, error: null }, // insert into api_keys ]) const res = await POST( formRequest({ grant_type: 'authorization_code', code: 'ciphertext', code_verifier: 'verifier', redirect_uri: 'https://claude.ai/api/cb', }) ) expect(res.status).toBe(200) const body = await res.json() expect(body.access_token).toMatch(/^gnubok_sk_/) expect(body.refresh_token).toMatch(/^gnubok_rt_/) expect(body.token_type).toBe('Bearer') expect(body.expires_in).toBe(3600) }) it('rejects an already-used auth code (replay)', async () => { vi.mocked(decryptAuthCode).mockReturnValue({ userId: 'user-1', codeChallenge: 'challenge', redirectUri: 'https://claude.ai/api/cb', exp: Date.now() + 60_000, }) vi.mocked(verifyPkce).mockReturnValue(true) const { supabase, enqueue } = createQueuedMockSupabase() mocks.supabaseFactory.mockReturnValue(supabase) enqueue({ data: null, error: { message: 'unique violation' } }) const res = await POST( formRequest({ grant_type: 'authorization_code', code: 'ciphertext', code_verifier: 'verifier', redirect_uri: 'https://claude.ai/api/cb', }) ) expect(res.status).toBe(400) const body = await res.json() expect(body.error).toBe('invalid_grant') }) it('rejects when PKCE verification fails', async () => { vi.mocked(decryptAuthCode).mockReturnValue({ userId: 'user-1', codeChallenge: 'challenge', redirectUri: 'https://claude.ai/api/cb', exp: Date.now() + 60_000, }) vi.mocked(verifyPkce).mockReturnValue(false) const res = await POST( formRequest({ grant_type: 'authorization_code', code: 'ciphertext', code_verifier: 'wrong', redirect_uri: 'https://claude.ai/api/cb', }) ) expect(res.status).toBe(400) const body = await res.json() expect(body.error).toBe('invalid_grant') expect(body.error_description).toContain('PKCE') }) }) describe('refresh_token grant', () => { it('rotates both tokens and returns a fresh access_token', async () => { const { token: refreshToken } = generateRefreshToken() const { supabase, enqueueMany } = createQueuedMockSupabase() mocks.supabaseFactory.mockReturnValue(supabase) enqueueMany([ { data: { id: 'key-1', revoked_at: null }, error: null }, // SELECT { data: [{ id: 'key-1' }], error: null }, // UPDATE ... RETURNING ]) const res = await POST( formRequest({ grant_type: 'refresh_token', refresh_token: refreshToken, }) ) expect(res.status).toBe(200) const body = await res.json() expect(body.access_token).toMatch(/^gnubok_sk_/) expect(body.refresh_token).toMatch(/^gnubok_rt_/) expect(body.refresh_token).not.toBe(refreshToken) // rotated expect(body.expires_in).toBe(3600) }) it('returns 400 when refresh_token is missing', async () => { const res = await POST(formRequest({ grant_type: 'refresh_token' })) expect(res.status).toBe(400) const body = await res.json() expect(body.error).toBe('invalid_request') }) it('returns 400 when refresh_token is unknown', async () => { const { supabase, enqueue } = createQueuedMockSupabase() mocks.supabaseFactory.mockReturnValue(supabase) enqueue({ data: null, error: null }) // SELECT — no row const res = await POST( formRequest({ grant_type: 'refresh_token', refresh_token: 'gnubok_rt_unknown', }) ) expect(res.status).toBe(400) const body = await res.json() expect(body.error).toBe('invalid_grant') }) it('returns 400 when the api_key is revoked', async () => { const { supabase, enqueue } = createQueuedMockSupabase() mocks.supabaseFactory.mockReturnValue(supabase) enqueue({ data: { id: 'key-1', revoked_at: '2026-05-01T00:00:00Z' }, error: null, }) const res = await POST( formRequest({ grant_type: 'refresh_token', refresh_token: 'gnubok_rt_anything', }) ) expect(res.status).toBe(400) const body = await res.json() expect(body.error).toBe('invalid_grant') expect(body.error_description).toContain('revoked') }) it('returns 500 when the lookup fails with a DB error', async () => { const { supabase, enqueue } = createQueuedMockSupabase() mocks.supabaseFactory.mockReturnValue(supabase) enqueue({ data: null, error: { message: 'connection reset' } }) const res = await POST( formRequest({ grant_type: 'refresh_token', refresh_token: 'gnubok_rt_anything', }) ) expect(res.status).toBe(500) const body = await res.json() expect(body.error).toBe('server_error') }) it('returns 500 when the rotation update fails with a DB error', async () => { const { supabase, enqueueMany } = createQueuedMockSupabase() mocks.supabaseFactory.mockReturnValue(supabase) enqueueMany([ { data: { id: 'key-1', revoked_at: null }, error: null }, // SELECT { data: null, error: { message: 'deadlock detected' } }, // UPDATE — DB error ]) const res = await POST( formRequest({ grant_type: 'refresh_token', refresh_token: 'gnubok_rt_anything', }) ) expect(res.status).toBe(500) const body = await res.json() expect(body.error).toBe('server_error') }) it('returns 400 when the CAS update affects 0 rows (concurrent reuse)', async () => { const { supabase, enqueueMany } = createQueuedMockSupabase() mocks.supabaseFactory.mockReturnValue(supabase) enqueueMany([ { data: { id: 'key-1', revoked_at: null }, error: null }, // SELECT { data: [], error: null }, // UPDATE — 0 rows (lost the CAS race) ]) const res = await POST( formRequest({ grant_type: 'refresh_token', refresh_token: 'gnubok_rt_anything', }) ) expect(res.status).toBe(400) const body = await res.json() expect(body.error).toBe('invalid_grant') expect(body.error_description).toContain('already used') }) }) })