Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions src/app/api/auth/register/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
},
Expand All @@ -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', {
Expand All @@ -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');
});
Expand All @@ -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();
});
});
60 changes: 46 additions & 14 deletions src/app/api/auth/register/route.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 }
Expand All @@ -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' } },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

if (existingUser) {
Expand All @@ -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(
Expand Down
83 changes: 83 additions & 0 deletions src/lib/__tests__/authorize-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
16 changes: 16 additions & 0 deletions src/lib/__tests__/email.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
32 changes: 2 additions & 30 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -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: [
Expand All @@ -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: {
Expand Down
55 changes: 55 additions & 0 deletions src/lib/authorize-credentials.ts
Original file line number Diff line number Diff line change
@@ -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<Record<'email' | 'password', unknown>> | 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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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,
};
}
11 changes: 11 additions & 0 deletions src/lib/email.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading