From 1d8f40e38b4b0238b5807d1269a8e4c82bbe7527 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:59:50 +0000 Subject: [PATCH 1/3] fix: normalize email casing/whitespace across register and login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Postgres's unique constraint on User.email is case-sensitive, and login looked users up by exact string with no normalization — a user registering as "Foo@Example.com" could fail to log back in with different casing or incidental whitespace, and case-variant duplicate signups were treated as distinct accounts. Added a shared normalizeEmail() helper (trim + lowercase) used by both the registration route (validate + store) and auth.ts's Credentials authorize() (lookup), so both sides agree on the same canonical form. Editing src/lib/auth.ts done with explicit approval per this repo's protected-file convention. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../api/auth/register/__tests__/route.test.ts | 18 ++++++++++++++++++ src/app/api/auth/register/route.ts | 18 ++++++++++++++---- src/lib/__tests__/email.test.ts | 16 ++++++++++++++++ src/lib/auth.ts | 3 ++- src/lib/email.ts | 11 +++++++++++ 5 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 src/lib/__tests__/email.test.ts create mode 100644 src/lib/email.ts diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts index 566261e..9ae79e7 100644 --- a/src/app/api/auth/register/__tests__/route.test.ts +++ b/src/app/api/auth/register/__tests__/route.test.ts @@ -61,4 +61,22 @@ 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.findUnique).toHaveBeenCalledWith({ where: { email: 'foo@example.com' } }); + }); + + 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.findUnique.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(); + }); }); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 2945c6c..6385067 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import bcrypt from 'bcryptjs'; import { prisma } from '@/lib/prisma'; +import { normalizeEmail } from '@/lib/email'; export async function POST(request: Request) { try { @@ -13,7 +14,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 } @@ -28,7 +38,7 @@ export async function POST(request: Request) { } const existingUser = await prisma.user.findUnique({ - where: { email }, + where: { email: normalizedEmail }, }); if (existingUser) { @@ -42,8 +52,8 @@ export async function POST(request: Request) { const user = await prisma.user.create({ data: { - email, - name: name || email.split('@')[0], + email: normalizedEmail, + name: name || normalizedEmail.split('@')[0], passwordHash, }, }); 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..28ef2ca 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -2,6 +2,7 @@ import NextAuth from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; import bcrypt from 'bcryptjs'; import { prisma } from './prisma'; +import { normalizeEmail } from './email'; export const { handlers, signIn, signOut, auth } = NextAuth({ providers: [ @@ -17,7 +18,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ } const user = await prisma.user.findUnique({ - where: { email: credentials.email as string }, + where: { email: normalizeEmail(credentials.email as string) }, }); if (!user) { 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(); +} From f2f7ed9f4c25a52af002cf7c8025dadaa609ef6c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 08:11:53 +0000 Subject: [PATCH 2/3] fix: use case-insensitive email lookup to avoid locking out legacy accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to Copilot review comments on this PR: normalizing only the login query to lowercase would have broken login for any account already registered with a mixed-case email (their stored row wouldn't exact-match the now-lowercased lookup) — a regression the previous commit would have introduced. Switched login and registration's duplicate-check to a case- insensitive lookup (Prisma's `mode: 'insensitive'`) instead of an exact match, so legacy rows are found without a data migration. Login also fails closed (denies + logs) if a case-insensitive lookup somehow matches more than one row, rather than silently picking one account under the caller's identity. Also fixed a smaller related gap: `authorize()` cast `credentials.email` to `string` without checking it actually was one — NextAuth doesn't enforce that at runtime. Extracted the login logic into authorize-credentials.ts (with zero next-auth import) so it's unit testable — next-auth's own import chain pulls in next/server, which isn't available in the Vitest environment. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../api/auth/register/__tests__/route.test.ts | 10 ++- src/app/api/auth/register/route.ts | 7 +- .../__tests__/authorize-credentials.test.ts | 83 +++++++++++++++++++ src/lib/auth.ts | 33 +------- src/lib/authorize-credentials.ts | 55 ++++++++++++ 5 files changed, 151 insertions(+), 37 deletions(-) create mode 100644 src/lib/__tests__/authorize-credentials.test.ts create mode 100644 src/lib/authorize-credentials.ts diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts index 9ae79e7..0c04d26 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(), }, }, @@ -38,7 +38,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'); }); @@ -64,7 +64,9 @@ describe('POST /api/auth/register', () => { it('normalizes email casing/whitespace before checking for an existing user', async () => { await POST(makeRequest({ email: ' Foo@Example.COM ', password: 'longenough' })); - expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ where: { email: 'foo@example.com' } }); + expect(prismaMock.user.findFirst).toHaveBeenCalledWith({ + where: { email: { equals: 'foo@example.com', mode: 'insensitive' } }, + }); }); it('stores the normalized email, not the raw input casing', async () => { @@ -74,7 +76,7 @@ describe('POST /api/auth/register', () => { }); it('treats a case-variant of an existing email as a duplicate', async () => { - prismaMock.user.findUnique.mockResolvedValueOnce({ id: 'existing-user' }); + 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(); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 6385067..0f7f28d 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -37,8 +37,11 @@ export async function POST(request: Request) { ); } - const existingUser = await prisma.user.findUnique({ - where: { email: normalizedEmail }, + // 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) { 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/auth.ts b/src/lib/auth.ts index 28ef2ca..f687986 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,8 +1,6 @@ import NextAuth from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; -import bcrypt from 'bcryptjs'; -import { prisma } from './prisma'; -import { normalizeEmail } from './email'; +import { authorizeCredentials } from './authorize-credentials'; export const { handlers, signIn, signOut, auth } = NextAuth({ providers: [ @@ -12,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: normalizeEmail(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..b2f55af --- /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', + { email: normalizedEmail, 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, + }; +} From e71e2bbd9f7d3e1ec214f6e8513a5cf186773f3d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 08:31:11 +0000 Subject: [PATCH 3/3] fix: address CodeRabbit findings on email-normalization PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Registration's findFirst duplicate-check and create() aren't atomic, so a concurrent registration with the same normalized email could slip past the check and hit the DB's unique constraint on create(), which the generic catch turned into an unhelpful 500 instead of the same 400 "User already exists" the check path already returns. Wrapped create() to catch Prisma's P2002 (unique constraint violation) specifically and return the same duplicate response; any other error still rethrows to the existing 500 handler. - Dropped the raw email address from the "multiple users matched case-insensitively" log line in authorize-credentials.ts — the userIds already let an engineer trace the anomaly in the DB, and PII doesn't need to also sit in log storage. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012Jpq3N94psa27k8xASDy8B --- .../api/auth/register/__tests__/route.test.ts | 24 +++++++++++ src/app/api/auth/register/route.ts | 41 ++++++++++++++----- src/lib/authorize-credentials.ts | 2 +- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/app/api/auth/register/__tests__/route.test.ts b/src/app/api/auth/register/__tests__/route.test.ts index 0c04d26..00f41fa 100644 --- a/src/app/api/auth/register/__tests__/route.test.ts +++ b/src/app/api/auth/register/__tests__/route.test.ts @@ -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', { @@ -81,4 +82,27 @@ describe('POST /api/auth/register', () => { 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 0f7f28d..5ecceef 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -2,6 +2,7 @@ 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 { @@ -53,18 +54,36 @@ export async function POST(request: Request) { const passwordHash = await bcrypt.hash(password, 10); - const user = await prisma.user.create({ - data: { - email: normalizedEmail, - name: name || normalizedEmail.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/authorize-credentials.ts b/src/lib/authorize-credentials.ts index b2f55af..3ee7a9d 100644 --- a/src/lib/authorize-credentials.ts +++ b/src/lib/authorize-credentials.ts @@ -34,7 +34,7 @@ export async function authorizeCredentials( if (matches.length > 1) { console.error( '[auth] multiple users matched email case-insensitively; refusing to pick one', - { email: normalizedEmail, userIds: matches.map((u) => u.id) }, + { userIds: matches.map((u) => u.id) }, ); return null; }