diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts index 566261e..00f41fa 100644 --- a/src/app/api/auth/register/__tests__/route.test.ts +++ b/src/app/api/auth/register/__tests__/route.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const { prismaMock, bcryptMock } = vi.hoisted(() => ({ prismaMock: { user: { - findUnique: vi.fn(), + findFirst: vi.fn(), create: vi.fn(), }, }, @@ -27,6 +27,7 @@ vi.mock('bcryptjs', () => ({ })); import { POST } from '../route'; +import { Prisma } from '@/generated/prisma/client'; function makeRequest(body: unknown): Request { return new Request('http://localhost/api/auth/register', { @@ -38,7 +39,7 @@ function makeRequest(body: unknown): Request { beforeEach(() => { vi.clearAllMocks(); - prismaMock.user.findUnique.mockResolvedValue(null); + prismaMock.user.findFirst.mockResolvedValue(null); prismaMock.user.create.mockResolvedValue({ id: 'user-1' }); bcryptMock.hash.mockResolvedValue('hashed'); }); @@ -61,4 +62,47 @@ describe('POST /api/auth/register', () => { expect(res.status).toBe(201); expect(prismaMock.user.create).toHaveBeenCalledTimes(1); }); + + it('normalizes email casing/whitespace before checking for an existing user', async () => { + await POST(makeRequest({ email: ' Foo@Example.COM ', password: 'longenough' })); + expect(prismaMock.user.findFirst).toHaveBeenCalledWith({ + where: { email: { equals: 'foo@example.com', mode: 'insensitive' } }, + }); + }); + + it('stores the normalized email, not the raw input casing', async () => { + await POST(makeRequest({ email: 'Foo@Example.COM', password: 'longenough' })); + const [args] = prismaMock.user.create.mock.calls[0] as [{ data: { email: string } }]; + expect(args.data.email).toBe('foo@example.com'); + }); + + it('treats a case-variant of an existing email as a duplicate', async () => { + prismaMock.user.findFirst.mockResolvedValueOnce({ id: 'existing-user' }); + const res = await POST(makeRequest({ email: 'FOO@example.com', password: 'longenough' })); + expect(res.status).toBe(400); + expect(prismaMock.user.create).not.toHaveBeenCalled(); + }); + + it('treats a concurrent duplicate registration (unique constraint race) as "already exists", not a 500', async () => { + // findFirst didn't see it yet, but create() hits the DB's unique + // constraint because another request won the race in between. + prismaMock.user.create.mockRejectedValueOnce( + new Prisma.PrismaClientKnownRequestError('Unique constraint failed on the fields: (`email`)', { + code: 'P2002', + clientVersion: 'test', + }), + ); + const res = await POST(makeRequest({ email: 'a@b.com', password: 'longenough' })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/already exists/i); + }); + + it('still returns 500 for a create() failure that is not a unique constraint violation', async () => { + prismaMock.user.create.mockRejectedValueOnce(new Error('connection reset')); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const res = await POST(makeRequest({ email: 'a@b.com', password: 'longenough' })); + expect(res.status).toBe(500); + consoleSpy.mockRestore(); + }); }); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 2945c6c..5ecceef 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from 'next/server'; import bcrypt from 'bcryptjs'; import { prisma } from '@/lib/prisma'; +import { normalizeEmail } from '@/lib/email'; +import { Prisma } from '@/generated/prisma/client'; export async function POST(request: Request) { try { @@ -13,7 +15,16 @@ export async function POST(request: Request) { ); } - if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + if (typeof email !== 'string') { + return NextResponse.json( + { error: 'Enter a valid email address' }, + { status: 400 } + ); + } + + const normalizedEmail = normalizeEmail(email); + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) { return NextResponse.json( { error: 'Enter a valid email address' }, { status: 400 } @@ -27,8 +38,11 @@ export async function POST(request: Request) { ); } - const existingUser = await prisma.user.findUnique({ - where: { email }, + // Case-insensitive check so a legacy mixed-case row (from before email + // normalization) still counts as a duplicate — the DB's unique + // constraint is case-sensitive and wouldn't catch it on its own. + const existingUser = await prisma.user.findFirst({ + where: { email: { equals: normalizedEmail, mode: 'insensitive' } }, }); if (existingUser) { @@ -40,18 +54,36 @@ export async function POST(request: Request) { const passwordHash = await bcrypt.hash(password, 10); - const user = await prisma.user.create({ - data: { - email, - name: name || email.split('@')[0], - passwordHash, - }, - }); + // The findFirst check above and this create() aren't atomic, so a + // concurrent registration with the same normalized email can still + // slip past it and hit the DB's unique constraint here. Treat that + // race the same as the check finding it first, rather than letting it + // fall through to the generic 500 below. + try { + const user = await prisma.user.create({ + data: { + email: normalizedEmail, + name: name || normalizedEmail.split('@')[0], + passwordHash, + }, + }); - return NextResponse.json( - { message: 'User created', userId: user.id }, - { status: 201 } - ); + return NextResponse.json( + { message: 'User created', userId: user.id }, + { status: 201 } + ); + } catch (createError) { + if ( + createError instanceof Prisma.PrismaClientKnownRequestError && + createError.code === 'P2002' + ) { + return NextResponse.json( + { error: 'User already exists' }, + { status: 400 } + ); + } + throw createError; + } } catch (error) { console.error('Registration error:', error); return NextResponse.json( diff --git a/src/lib/__tests__/authorize-credentials.test.ts b/src/lib/__tests__/authorize-credentials.test.ts new file mode 100644 index 0000000..e487f64 --- /dev/null +++ b/src/lib/__tests__/authorize-credentials.test.ts @@ -0,0 +1,83 @@ +/** + * Tests for the extracted authorizeCredentials login logic. + * + * Covers: case-insensitive email matching (so accounts registered with + * mixed-case emails before normalization can still log in), fail-closed + * behavior on an ambiguous case-insensitive match, and runtime type + * guards on the credentials NextAuth hands in (not compile-time enforced). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { prismaMock, bcryptMock } = vi.hoisted(() => ({ + prismaMock: { + user: { + findMany: vi.fn(), + }, + }, + bcryptMock: { + compare: vi.fn(), + }, +})); + +vi.mock('../prisma', () => ({ + prisma: prismaMock, +})); + +vi.mock('bcryptjs', () => ({ + default: bcryptMock, +})); + +import { authorizeCredentials } from '../authorize-credentials'; + +beforeEach(() => { + vi.clearAllMocks(); + prismaMock.user.findMany.mockResolvedValue([]); + bcryptMock.compare.mockResolvedValue(false); +}); + +describe('authorizeCredentials', () => { + it('returns null when email or password is missing or not a string', async () => { + expect(await authorizeCredentials(undefined)).toBeNull(); + expect(await authorizeCredentials({})).toBeNull(); + expect(await authorizeCredentials({ email: 'a@b.com' })).toBeNull(); + expect(await authorizeCredentials({ email: 12345 as unknown as string, password: 'x' })).toBeNull(); + }); + + it('looks up the user case-insensitively with a normalized email', async () => { + await authorizeCredentials({ email: ' Foo@Example.COM ', password: 'x' }); + expect(prismaMock.user.findMany).toHaveBeenCalledWith({ + where: { email: { equals: 'foo@example.com', mode: 'insensitive' } }, + }); + }); + + it('returns null when no user matches', async () => { + prismaMock.user.findMany.mockResolvedValueOnce([]); + expect(await authorizeCredentials({ email: 'a@b.com', password: 'x' })).toBeNull(); + }); + + it('fails closed instead of picking one when multiple users match case-insensitively', async () => { + prismaMock.user.findMany.mockResolvedValueOnce([ + { id: 'u1', email: 'Foo@example.com', passwordHash: 'h1' }, + { id: 'u2', email: 'foo@example.com', passwordHash: 'h2' }, + ]); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = await authorizeCredentials({ email: 'foo@example.com', password: 'x' }); + expect(result).toBeNull(); + expect(bcryptMock.compare).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('returns null when the password does not match', async () => { + prismaMock.user.findMany.mockResolvedValueOnce([{ id: 'u1', email: 'a@b.com', passwordHash: 'hashed' }]); + bcryptMock.compare.mockResolvedValueOnce(false); + expect(await authorizeCredentials({ email: 'a@b.com', password: 'wrong' })).toBeNull(); + }); + + it('returns the user on a successful match, matching a legacy mixed-case row', async () => { + prismaMock.user.findMany.mockResolvedValueOnce([{ id: 'u1', email: 'Foo@Example.com', name: 'Foo', passwordHash: 'hashed' }]); + bcryptMock.compare.mockResolvedValueOnce(true); + const result = await authorizeCredentials({ email: 'foo@example.com', password: 'correct' }); + expect(result).toEqual({ id: 'u1', email: 'Foo@Example.com', name: 'Foo' }); + }); +}); diff --git a/src/lib/__tests__/email.test.ts b/src/lib/__tests__/email.test.ts new file mode 100644 index 0000000..107ef8e --- /dev/null +++ b/src/lib/__tests__/email.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeEmail } from '../email'; + +describe('normalizeEmail', () => { + it('lowercases the email', () => { + expect(normalizeEmail('Foo@Example.com')).toBe('foo@example.com'); + }); + + it('trims surrounding whitespace', () => { + expect(normalizeEmail(' foo@example.com ')).toBe('foo@example.com'); + }); + + it('is idempotent for an already-normalized email', () => { + expect(normalizeEmail('foo@example.com')).toBe('foo@example.com'); + }); +}); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1037527..f687986 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,7 +1,6 @@ import NextAuth from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; -import bcrypt from 'bcryptjs'; -import { prisma } from './prisma'; +import { authorizeCredentials } from './authorize-credentials'; export const { handlers, signIn, signOut, auth } = NextAuth({ providers: [ @@ -11,34 +10,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ email: { label: 'Email', type: 'email' }, password: { label: 'Password', type: 'password' }, }, - async authorize(credentials) { - if (!credentials?.email || !credentials?.password) { - return null; - } - - const user = await prisma.user.findUnique({ - where: { email: credentials.email as string }, - }); - - if (!user) { - return null; - } - - const isValid = await bcrypt.compare( - credentials.password as string, - user.passwordHash - ); - - if (!isValid) { - return null; - } - - return { - id: user.id, - email: user.email, - name: user.name, - }; - }, + authorize: authorizeCredentials, }), ], session: { diff --git a/src/lib/authorize-credentials.ts b/src/lib/authorize-credentials.ts new file mode 100644 index 0000000..3ee7a9d --- /dev/null +++ b/src/lib/authorize-credentials.ts @@ -0,0 +1,55 @@ +/** + * Credentials-login lookup logic, kept free of any next-auth import so it + * can be unit tested directly — importing next-auth itself pulls in + * next/server, which isn't available in the Vitest/jsdom environment. + */ +import bcrypt from 'bcryptjs'; +import { prisma } from './prisma'; +import { normalizeEmail } from './email'; + +export async function authorizeCredentials( + credentials: Partial> | undefined, +) { + if ( + typeof credentials?.email !== 'string' || + typeof credentials?.password !== 'string' + ) { + return null; + } + + const normalizedEmail = normalizeEmail(credentials.email); + + // Case-insensitive lookup so accounts registered before email + // normalization (potentially stored with mixed casing) can still log in. + // If more than one row matches case-insensitively — a legacy data + // anomaly the case-sensitive unique constraint didn't prevent — fail + // closed rather than silently picking one account. + const matches = await prisma.user.findMany({ + where: { email: { equals: normalizedEmail, mode: 'insensitive' } }, + }); + + if (matches.length === 0) { + return null; + } + if (matches.length > 1) { + console.error( + '[auth] multiple users matched email case-insensitively; refusing to pick one', + { userIds: matches.map((u) => u.id) }, + ); + return null; + } + + const [user] = matches; + + const isValid = await bcrypt.compare(credentials.password, user.passwordHash); + + if (!isValid) { + return null; + } + + return { + id: user.id, + email: user.email, + name: user.name, + }; +} diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..dc421bf --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,11 @@ +/** + * Shared email normalization for auth. Postgres's unique constraint on + * User.email is case-sensitive, and NextAuth's Credentials `authorize` + * looks users up by exact string — without normalizing both write + * (registration) and read (login) paths the same way, a user who signs up + * as "Foo@Example.com" can fail to log back in with different casing or + * incidental whitespace. + */ +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +}