From f662b64a37530280f3b58a8342d2a559fed450da Mon Sep 17 00:00:00 2001 From: Shaun Hamilton Date: Tue, 29 Jul 2025 09:46:50 +0200 Subject: [PATCH] fix(api): use lowercase email address (#61490) Co-authored-by: Oliver Eyton-Williams --- api/src/routes/helpers/auth-helpers.test.ts | 33 ++++++++++++++++++++- api/src/routes/helpers/auth-helpers.ts | 10 +++++-- api/src/routes/protected/settings.ts | 9 ++++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/api/src/routes/helpers/auth-helpers.test.ts b/api/src/routes/helpers/auth-helpers.test.ts index 7bef32befde..7414d752abf 100644 --- a/api/src/routes/helpers/auth-helpers.test.ts +++ b/api/src/routes/helpers/auth-helpers.test.ts @@ -24,7 +24,13 @@ describe('findOrCreateUser', () => { }); afterEach(async () => { - await fastify.prisma.user.deleteMany({ where: { email } }); + await fastify.prisma.user.deleteMany({ + where: { + email: { + in: [email, email.toUpperCase()] + } + } + }); await fastify.close(); jest.clearAllMocks(); }); @@ -60,4 +66,29 @@ describe('findOrCreateUser', () => { expect(captureException).not.toHaveBeenCalled(); }); + + it("should NOT create a user if there is already an account with the lowercase version of the user's email", async () => { + const upperCaseEmail = email.toUpperCase(); + + // Create a user with lowercase email + const existingUser = await fastify.prisma.user.create({ + data: createUserInput(email) + }); + + // Try to find or create with uppercase email + const result = await findOrCreateUser(fastify, upperCaseEmail); + + // Should return the existing user, not create a new one + expect(result.id).toBe(existingUser.id); + + // Verify only one user exists in the database + const allUsers = await fastify.prisma.user.findMany({ + where: { + email: { + in: [upperCaseEmail, email] + } + } + }); + expect(allUsers).toHaveLength(1); + }); }); diff --git a/api/src/routes/helpers/auth-helpers.ts b/api/src/routes/helpers/auth-helpers.ts index 741bcb2998c..7f2ed983e3e 100644 --- a/api/src/routes/helpers/auth-helpers.ts +++ b/api/src/routes/helpers/auth-helpers.ts @@ -11,10 +11,16 @@ export const findOrCreateUser = async ( fastify: FastifyInstance, email: string ): Promise<{ id: string; acceptedPrivacyTerms: boolean }> => { + const lowerCaseEmail = email.toLowerCase(); // TODO: handle the case where there are multiple users with the same email. // e.g. use findMany and throw an error if more than one is found. const existingUser = await fastify.prisma.user.findMany({ - where: { email }, + where: { + // https://www.mongodb.com/docs/manual/reference/operator/query/or/#-or-versus--in + email: { + in: [email, lowerCaseEmail] + } + }, select: { id: true, acceptedPrivacyTerms: true } }); if (existingUser.length > 1) { @@ -28,7 +34,7 @@ export const findOrCreateUser = async ( return ( existingUser[0] ?? (await fastify.prisma.user.create({ - data: createUserInput(email), + data: createUserInput(lowerCaseEmail), select: { id: true, acceptedPrivacyTerms: true } })) ); diff --git a/api/src/routes/protected/settings.ts b/api/src/routes/protected/settings.ts index ef7c4ac911b..b8e031fde11 100644 --- a/api/src/routes/protected/settings.ts +++ b/api/src/routes/protected/settings.ts @@ -765,7 +765,9 @@ export const settingRedirectRoutes: FastifyPluginCallbackTypebox = ( }, async (req, reply) => { const logger = fastify.log.child({ req, res: reply }); - const email = Buffer.from(req.query.email, 'base64').toString(); + const email = Buffer.from(req.query.email, 'base64') + .toString() + .toLowerCase(); const { origin } = getRedirectParams(req); if (!isEmail(email)) { @@ -791,9 +793,12 @@ export const settingRedirectRoutes: FastifyPluginCallbackTypebox = ( // TODO(Post-MVP): should this fail if it's not the currently signed in // user? const targetUser = await fastify.prisma.user.findUnique({ - where: { id: authToken.userId } + where: { id: authToken.userId }, + select: { id: true, newEmail: true } }); + // TODO: update redirect message to be specific about issue + // Most likely cause being user tampered callback url if (targetUser?.newEmail !== email) { return reply.redirectWithMessage(origin, redirectMessage); }