-
Notifications
You must be signed in to change notification settings - Fork 0
fix: normalize email casing/whitespace across register and login #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
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, | ||
| }; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.