diff --git a/src/app/api/auth/register-anon/route.js b/src/app/api/auth/register-anon/route.js index 74068c18..97d1cc0c 100644 --- a/src/app/api/auth/register-anon/route.js +++ b/src/app/api/auth/register-anon/route.js @@ -15,6 +15,19 @@ import { verifyInviteToken, InviteVerificationError } from '@/lib/invites/verify /** Postgres unique-violation error code. */ const PG_UNIQUE_VIOLATION = '23505'; +/** + * @param {string | null} authHeader + * @returns {string | null} + */ +function getBearerToken(authHeader) { + if (typeof authHeader !== 'string') return null; + + const match = authHeader.match(/^Bearer\s+(.+)$/i); + const token = match?.[1]?.trim(); + + return token || null; +} + /** * Build a service-role Supabase client (bypasses RLS). * Mirrors the pattern used by the phone/SMS verify-sms route. @@ -62,10 +75,10 @@ export async function POST(request) { // --- Validate anonymous Bearer session --- const authHeader = request.headers.get('authorization'); - if (!authHeader) { + const token = getBearerToken(authHeader); + if (!token) { return NextResponse.json({ error: 'Missing authorization header' }, { status: 401 }); } - const token = authHeader.replace('Bearer ', ''); const supabase = await createSupabaseServerClient(); const { data: { user: authUser }, error: userError } = await supabase.auth.getUser(token); diff --git a/src/app/api/auth/register-anon/route.test.js b/src/app/api/auth/register-anon/route.test.js new file mode 100644 index 00000000..d4bed114 --- /dev/null +++ b/src/app/api/auth/register-anon/route.test.js @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + serverAuthGetUser: vi.fn(), + createSupabaseServerClient: vi.fn(() => ({ + auth: { + getUser: mocks.serverAuthGetUser + } + })), + createClient: vi.fn(() => ({})), + verifyInviteToken: vi.fn(), +})); + +vi.mock('@/lib/supabase.js', () => ({ + createSupabaseServerClient: mocks.createSupabaseServerClient +})); + +vi.mock('@supabase/supabase-js', () => ({ + createClient: mocks.createClient +})); + +vi.mock('@/lib/invites/verify.js', () => ({ + verifyInviteToken: mocks.verifyInviteToken, + InviteVerificationError: class InviteVerificationError extends Error {} +})); + +function registerRequest(authorization) { + return new Request('https://example.com/api/auth/register-anon', { + method: 'POST', + headers: authorization ? { authorization } : {}, + body: JSON.stringify({ + inviteToken: 'qci1.payload.signature', + username: 'anon-user', + publicKey: 'ml-kem-public-key' + }) + }); +} + +describe('register-anon bearer authentication', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.serverAuthGetUser.mockResolvedValue({ + data: { user: null }, + error: { message: 'invalid session' } + }); + }); + + it('normalizes bearer scheme casing and extra spaces before validating the session', async () => { + const { POST } = await import('./route.js'); + + const response = await POST(registerRequest('bearer access-token-123 ')); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body.error).toBe('Invalid or expired session'); + expect(mocks.serverAuthGetUser).toHaveBeenCalledWith('access-token-123'); + expect(mocks.verifyInviteToken).not.toHaveBeenCalled(); + }); + + it('does not validate an empty bearer header', async () => { + const { POST } = await import('./route.js'); + + const response = await POST(registerRequest('Bearer ')); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body.error).toBe('Missing authorization header'); + expect(mocks.createSupabaseServerClient).not.toHaveBeenCalled(); + expect(mocks.serverAuthGetUser).not.toHaveBeenCalled(); + expect(mocks.verifyInviteToken).not.toHaveBeenCalled(); + }); +});