From 789a1b12c984406123aae501343357cc1a1130c1 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:17:48 +0200 Subject: [PATCH] Treat email addresses case-insensitively Addresses were stored exactly as typed and looked up the same way, so an account registered with capitals could not sign in when the address was typed in lower case, and a second registration created a separate account with its own workspace and trial. - Add common/email.util: normalizeEmail, sameEmail and a case-insensitive user lookup. Prisma's insensitive equals is an unescaped ILIKE, so LIKE wildcards are escaped and rows are re-checked in code. When legacy duplicates match, the exact spelling wins, then the most recently updated row. - Use it for register, password and MCP OAuth login, recovery login, forgot password, invite and accept-invite, self-hosted email change, SSO JIT and SCIM create/update/filter, audit user resolution and the unsubscribe lookup. New addresses and invitations are stored normalised; trial and community licence requests send the normalised address. - Migration lower-cases users.email only where no other row collides, and normalises invitation emails. Colliding rows are left untouched. - scripts/report-duplicate-emails.sql lists the colliding groups for manual merging (read-only). --- .../migration.sql | 29 ++++ .../scripts/report-duplicate-emails.sql | 39 +++++ packages/backend/src/audit/audit.service.ts | 8 +- .../backend/src/auth/auth.controller.spec.ts | 143 +++++++++++++++++- packages/backend/src/auth/auth.controller.ts | 25 +-- .../backend/src/auth/login.controller.spec.ts | 47 +++++- packages/backend/src/auth/login.controller.ts | 9 +- .../backend/src/common/email.util.spec.ts | 121 +++++++++++++++ packages/backend/src/common/email.util.ts | 117 ++++++++++++++ .../scim/scim-users.service.spec.ts | 36 ++++- .../scim/scim-users.service.ts | 30 ++-- .../identity-providers/scim/scim.parser.ts | 3 +- .../identity-providers/sso.service.spec.ts | 34 ++++- .../src/identity-providers/sso.service.ts | 10 +- .../src/license/license-trial.service.spec.ts | 10 ++ .../backend/src/license/license.service.ts | 9 +- .../src/settings/email.service.spec.ts | 9 +- .../backend/src/settings/email.service.ts | 5 +- .../backend/src/users/users.controller.ts | 14 +- .../backend/src/users/users.service.spec.ts | 39 +++-- packages/backend/src/users/users.service.ts | 11 +- 21 files changed, 669 insertions(+), 79 deletions(-) create mode 100644 packages/backend/prisma/migrations/20261019120000_normalize_user_emails/migration.sql create mode 100644 packages/backend/scripts/report-duplicate-emails.sql create mode 100644 packages/backend/src/common/email.util.spec.ts create mode 100644 packages/backend/src/common/email.util.ts diff --git a/packages/backend/prisma/migrations/20261019120000_normalize_user_emails/migration.sql b/packages/backend/prisma/migrations/20261019120000_normalize_user_emails/migration.sql new file mode 100644 index 000000000..daf54c973 --- /dev/null +++ b/packages/backend/prisma/migrations/20261019120000_normalize_user_emails/migration.sql @@ -0,0 +1,29 @@ +-- Normalise stored email addresses (trimmed, lower case). +-- +-- Addresses used to be stored exactly as typed and looked up the same way, so +-- `Name@Example.com` and `name@example.com` could become two accounts. The +-- application now writes addresses normalised and looks them up ignoring +-- case; this brings the existing rows in line. +-- +-- A row is only rewritten when no OTHER row has the same address in another +-- case. Rows that do collide are left exactly as they are: they are separate +-- accounts that an operator has to merge by hand (list them with +-- scripts/report-duplicate-emails.sql). Nothing is merged or deleted here, +-- and `updated_at` is not touched. +-- +-- The unique index on "users"."email" stays case-sensitive for now; a unique +-- index on lower("email") can only be added once the duplicates are merged. +UPDATE "users" AS u +SET "email" = lower(btrim(u."email")) +WHERE u."email" <> lower(btrim(u."email")) + AND NOT EXISTS ( + SELECT 1 + FROM "users" AS o + WHERE o."id" <> u."id" + AND lower(btrim(o."email")) = lower(btrim(u."email")) + ); + +-- Invitations are not unique per address, so every one can be normalised. +UPDATE "invitation_tokens" +SET "email" = lower(btrim("email")) +WHERE "email" <> lower(btrim("email")); diff --git a/packages/backend/scripts/report-duplicate-emails.sql b/packages/backend/scripts/report-duplicate-emails.sql new file mode 100644 index 000000000..0e644672e --- /dev/null +++ b/packages/backend/scripts/report-duplicate-emails.sql @@ -0,0 +1,39 @@ +-- Accounts whose email addresses differ only in case (or surrounding spaces). +-- +-- Read-only: lists the groups the 20261019120000_normalize_user_emails +-- migration left untouched, so an operator can decide by hand which account +-- to keep and move workspaces, memberships and licences onto it. Nothing here +-- changes data. +-- +-- Run with: +-- psql "$DATABASE_URL" -f packages/backend/scripts/report-duplicate-emails.sql +-- +-- One row per account, grouped by the normalised address; within a group the +-- most recently updated account comes first (the one a sign-in that does not +-- match any spelling exactly will pick). +WITH dup AS ( + SELECT lower(btrim("email")) AS normalized + FROM "users" + GROUP BY lower(btrim("email")) + HAVING count(*) > 1 +) +SELECT + d.normalized, + u."id" AS user_id, + u."email" AS stored_email, + u."email_verified", + u."password_hash" IS NOT NULL AS has_password, + u."created_at", + u."updated_at", + u."first_successful_invocation_at", + u."organization_id" AS active_organization_id, + (SELECT count(*) FROM "organization_members" m WHERE m."user_id" = u."id") AS memberships, + (SELECT count(*) FROM "organization_members" m WHERE m."user_id" = u."id" AND m."role" = 'ADMIN') AS admin_memberships, + ( + SELECT string_agg(l."plan" || ':' || l."status", ', ' ORDER BY l."created_at") + FROM "licenses" l + WHERE l."organization_id" = u."organization_id" + ) AS active_org_licences +FROM dup d +JOIN "users" u ON lower(btrim(u."email")) = d.normalized +ORDER BY d.normalized, u."updated_at" DESC, u."created_at" DESC; diff --git a/packages/backend/src/audit/audit.service.ts b/packages/backend/src/audit/audit.service.ts index 5ea2a8188..80a9d3a8a 100644 --- a/packages/backend/src/audit/audit.service.ts +++ b/packages/backend/src/audit/audit.service.ts @@ -26,6 +26,7 @@ const INVOCATION_FULL_PAYLOADS_PER_HOUR = envInt('INVOCATION_FULL_PAYLOADS_PER_H const INVOCATION_LOG_VOLUME_EXCERPT_BYTES = 512; const REPEAT_KEYS_MAX = 10_000; import { PrismaService } from '../common/prisma.service'; +import { findUserByEmail, normalizeEmail } from '../common/email.util'; import { InvocationStatus, Prisma } from '../generated/prisma/client'; import { AdsActivationService } from './ads-activation.service'; import { FirstSuccessService } from './first-success.service'; @@ -293,14 +294,11 @@ export class AuditService implements OnModuleDestroy { } if (!userEmail) return undefined; - const key = userEmail.toLowerCase(); + const key = normalizeEmail(userEmail); const cached = this.emailToIdCache.get(key); if (cached) return cached; - const byEmail = await this.prisma.user.findUnique({ - where: { email: key }, - select: { id: true }, - }); + const byEmail = await findUserByEmail<{ id: string; email: string }>(this.prisma, userEmail, { id: true }); if (!byEmail) return undefined; this.emailToIdCache.set(key, byEmail.id); diff --git a/packages/backend/src/auth/auth.controller.spec.ts b/packages/backend/src/auth/auth.controller.spec.ts index 60ee63f9f..73c838a6f 100644 --- a/packages/backend/src/auth/auth.controller.spec.ts +++ b/packages/backend/src/auth/auth.controller.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { AuthController, NEUTRAL_PASSWORD_RESET, NEUTRAL_REGISTRATION } from './auth.controller'; import { ProductEventService } from '../audit/product-event.service'; +import { findUserByEmail, normalizeEmail } from '../common/email.util'; /** * Sign-up, password reset and login must not say — by their answer or by how @@ -51,13 +52,18 @@ function makeController({ throw new Error('invalid token'); }), }; + // Looks up and stores addresses the way UsersService does: in any case, and + // normalised on write. const usersService = { - findByEmail: jest.fn(async (email: string) => users.find((u) => u.email === email) ?? null), + findByEmail: jest.fn(async (email: string) => + findUserByEmail({ user: { findMany: async () => users } }, email), + ), + findById: jest.fn(async (id: string) => users.find((u) => u.id === id) ?? null), count: jest.fn(async () => users.length), create: jest.fn(async (data: any) => { const u: Account = { id: `u${users.length + 1}`, - email: data.email, + email: normalizeEmail(data.email), name: data.name, role: data.role, organizationId: data.organizationId, @@ -93,6 +99,11 @@ function makeController({ invitationToken: { findUnique: jest.fn(async ({ where }: any) => invites.find((i) => i.token === where.token) ?? null), findFirst: jest.fn(async () => null), + // Returns the organisation's pending invitations; the controller picks + // the one for the address. + findMany: jest.fn(async ({ where }: any) => + invites.filter((i) => i.organizationId === where.organizationId && !i.usedAt), + ), create: jest.fn(async ({ data }: any) => { invites.push({ ...data }); return data; @@ -123,6 +134,10 @@ function makeController({ sent.push(`reset ${to}`); return true; }), + sendInvitationEmail: jest.fn(async (to: string) => { + sent.push(`invite ${to}`); + return { sent: true }; + }), }; const configService = { get: jest.fn((key: string) => { @@ -576,3 +591,127 @@ describe('AuthController — verified sign-up reported for ChatGPT Ads', () => { expect(res.redirect).toHaveBeenCalled(); }); }); + +/** + * An address is one account whatever its case: a user who registered with + * capitals and later typed the address in lower case could not sign in, signed + * up again and got a second, empty workspace. + */ +describe('AuthController — email addresses ignore case', () => { + const OLD_FLOOR = process.env.AUTH_NEUTRAL_FLOOR_MS; + beforeEach(() => { + process.env.AUTH_NEUTRAL_FLOOR_MS = '0'; + }); + afterAll(() => { + process.env.AUTH_NEUTRAL_FLOOR_MS = OLD_FLOOR; + }); + + /** An account written before addresses were normalised. */ + const LEGACY: Account = { ...EXISTING, id: 'u-legacy', email: 'Legacy.User@Example.COM' }; + + it('cloud sign-up refuses a case variant of an existing address, with the usual neutral answer', async () => { + const { controller, users, sent } = makeController({ mode: 'cloud', accounts: [EXISTING, LEGACY] }); + await expect(controller.register({}, signup('TAKEN@Example.com'))).resolves.toEqual(NEUTRAL_REGISTRATION); + await expect(controller.register({}, signup('legacy.user@example.com'))).resolves.toEqual(NEUTRAL_REGISTRATION); + await flush(); + + expect(users.map((u) => u.id)).toEqual(['u-existing', 'u-legacy']); + // The notice goes to the account's own address. + expect(sent).toEqual(['existing taken@example.com', 'existing Legacy.User@Example.COM']); + }); + + it('cloud sign-up stores a new address normalised', async () => { + const { controller, users, sent } = makeController({ mode: 'cloud' }); + await controller.register({}, signup('New.Person@Example.com')); + await flush(); + expect(users.map((u) => u.email)).toEqual(['new.person@example.com']); + expect(sent).toEqual(['verify new.person@example.com']); + }); + + it('self-hosted sign-up answers a conflict for a case variant, as for the exact address', async () => { + const { controller, users } = makeController({ mode: 'self-hosted', accounts: [EXISTING, LEGACY] }); + await expect(controller.register({}, signup('Taken@Example.com'))).rejects.toBeInstanceOf(ConflictException); + await expect(controller.register({}, signup('legacy.user@example.com'))).rejects.toBeInstanceOf(ConflictException); + expect(users).toHaveLength(2); + }); + + it('signs in with the address typed in another case', async () => { + const { controller } = makeController({ mode: 'self-hosted', accounts: [EXISTING, LEGACY] }); + await expect( + controller.login({}, { email: 'TAKEN@example.com', password: 'Correct#Horse1' }), + ).resolves.toMatchObject({ user: { id: 'u-existing' } }); + await expect( + controller.login({}, { email: 'legacy.user@example.com', password: 'Correct#Horse1' }), + ).resolves.toMatchObject({ user: { id: 'u-legacy', email: 'Legacy.User@Example.COM' } }); + }); + + it('signs in to the exact spelling when legacy duplicates exist, and creates no third account', async () => { + const upper: Account = { ...EXISTING, id: 'u-upper', email: 'DUP@example.com', passwordHash: 'hash:Upper#Pass1' }; + const lower: Account = { ...EXISTING, id: 'u-lower', email: 'dup@example.com', passwordHash: 'hash:Lower#Pass1' }; + const { controller, users } = makeController({ mode: 'cloud', accounts: [upper, lower] }); + + await expect( + controller.login({}, { email: 'DUP@example.com', password: 'Upper#Pass1' }), + ).resolves.toMatchObject({ user: { id: 'u-upper' } }); + await expect( + controller.login({}, { email: 'dup@example.com', password: 'Lower#Pass1' }), + ).resolves.toMatchObject({ user: { id: 'u-lower' } }); + + await expect(controller.register({}, signup('Dup@Example.com'))).resolves.toEqual(NEUTRAL_REGISTRATION); + expect(users).toHaveLength(2); + }); + + it('sends a password reset for the address typed in another case', async () => { + const { controller, sent } = makeController({ mode: 'cloud', accounts: [LEGACY] }); + await expect(controller.forgotPassword({}, { email: 'legacy.user@example.com' })).resolves.toEqual( + NEUTRAL_PASSWORD_RESET, + ); + await flush(); + expect(sent).toEqual(['reset Legacy.User@Example.COM']); + }); + + it('accepts an invitation for an account stored with capitals, proven by a session in any case', async () => { + const a = makeController({ mode: 'cloud', accounts: [LEGACY] }); + a.invites.push({ + id: 'inv1', + token: 'invite-token', + email: 'legacy.user@example.com', + role: 'EDITOR', + mcpRoleId: null, + mcpRoleIds: [], + organizationId: 'org-new', + expiresAt: new Date(Date.now() + 3600_000), + usedAt: null, + }); + expect(await a.controller.verifyInvite('invite-token')).toMatchObject({ exists: true }); + const res = await a.controller.acceptInvite( + { headers: { authorization: 'Bearer sess:LEGACY.user@example.com' } }, + { token: 'invite-token' }, + ); + expect(res.accessToken).toBe('jwt'); + expect(a.organizationsService.addMember).toHaveBeenCalledWith('u-legacy', 'org-new', 'EDITOR'); + expect(a.users).toHaveLength(1); + }); + + it('stores an invitation normalised and reuses a pending one whatever its case', async () => { + const a = makeController({ mode: 'cloud', accounts: [EXISTING] }); + const req = { user: { sub: 'u-existing', organizationId: 'org-x' } }; + a.invites.push({ + id: 'inv-old', + token: 'old-token', + email: 'Invitee@Example.com', + organizationId: 'org-x', + expiresAt: new Date(Date.now() + 3600_000), + usedAt: null, + }); + + const reused = await a.controller.inviteUser(req, { email: 'INVITEE@example.com', role: 'EDITOR' as any }); + expect(reused.inviteUrl).toContain('token=old-token'); + expect(a.invites).toHaveLength(1); + + const created = await a.controller.inviteUser(req, { email: 'Other.Person@Example.com', role: 'EDITOR' as any }); + expect(created.inviteUrl).not.toContain('old-token'); + expect(a.invites.map((i) => i.email)).toEqual(['Invitee@Example.com', 'other.person@example.com']); + expect(a.sent).toContain('invite other.person@example.com'); + }); +}); diff --git a/packages/backend/src/auth/auth.controller.ts b/packages/backend/src/auth/auth.controller.ts index 4b59147bf..903341cfe 100644 --- a/packages/backend/src/auth/auth.controller.ts +++ b/packages/backend/src/auth/auth.controller.ts @@ -44,6 +44,7 @@ import { SelfHostedOnlyGuard } from '../common/self-hosted-only.guard'; import { EditionService } from '../ee/licensing/edition.service'; import { SignupAttributionDto } from './signup-attribution.dto'; import { DISPOSABLE_EMAIL_MESSAGE, isDisposableEmail } from './disposable-email.util'; +import { emailLookupWhere, normalizeEmail, sameEmail } from '../common/email.util'; /** * Registration and password reset answer the same whether or not the address @@ -259,7 +260,7 @@ export class AuthController { try { const payload = this.authService.verifyToken(header.slice(7).trim()); const email = (payload as any)?.email; - return typeof email === 'string' ? email.toLowerCase() : null; + return typeof email === 'string' ? normalizeEmail(email) : null; } catch { return null; } @@ -784,14 +785,19 @@ export class AuthController { // Reuse an existing pending invitation instead of rejecting: throwing a // 409 here left admins stuck with no way to get the link after a failed // email send (the response with the URL only comes with a NEW invite). - const existingInvite = await this.prisma.invitationToken.findFirst({ + // Matched in any case: invitations written before addresses were + // normalised keep their original spelling. + const inviteEmail = normalizeEmail(dto.email); + const pendingInvites = await this.prisma.invitationToken.findMany({ where: { - email: dto.email, + ...emailLookupWhere(inviteEmail), organizationId: req.user.organizationId, usedAt: null, expiresAt: { gt: new Date() }, }, + orderBy: { createdAt: 'desc' }, }); + const existingInvite = pendingInvites.find((i) => sameEmail(i.email, inviteEmail)) ?? null; const inviteToken = existingInvite ? existingInvite.token @@ -807,7 +813,7 @@ export class AuthController { const expiresAt = new Date(Date.now() + 48 * 60 * 60 * 1000); // 48 hours await this.prisma.invitationToken.create({ data: { - email: dto.email, + email: inviteEmail, token: inviteToken, role: dto.role, mcpRoleId: invitedMcpRoleIds[0] ?? null, @@ -844,7 +850,7 @@ export class AuthController { // Send email const emailResult = await this.emailService.sendInvitationEmail( - dto.email, + inviteEmail, inviteUrl, inviterName, roleName, @@ -855,9 +861,9 @@ export class AuthController { return { message: emailResult.sent ? existingInvite - ? `${created} for ${dto.email} — the email was re-sent.` - : `Invitation sent to ${dto.email}` - : `${created} for ${dto.email}, but the email could not be sent. Share the link manually.`, + ? `${created} for ${inviteEmail} — the email was re-sent.` + : `Invitation sent to ${inviteEmail}` + : `${created} for ${inviteEmail}, but the email could not be sent. Share the link manually.`, inviteUrl, emailSent: emailResult.sent, ...(emailResult.error ? { emailError: emailResult.error } : {}), @@ -918,8 +924,7 @@ export class AuthController { // workspace admin, i.e. every signed-up user) could take over an // arbitrary account by inviting it and accepting the invite themselves. const sessionEmail = this.sessionEmailFromRequest(req); - const provenBySession = - !!sessionEmail && sessionEmail === invite.email.toLowerCase(); + const provenBySession = sameEmail(sessionEmail, invite.email); if (!provenBySession) { if (!existing.passwordHash) { throw new UnauthorizedException( diff --git a/packages/backend/src/auth/login.controller.spec.ts b/packages/backend/src/auth/login.controller.spec.ts index 85fa85792..b3fa1adce 100644 --- a/packages/backend/src/auth/login.controller.spec.ts +++ b/packages/backend/src/auth/login.controller.spec.ts @@ -66,7 +66,7 @@ function makeReq(overrides: Partial = {}): Request { describe('LoginController', () => { let controller: LoginController; let authService: jest.Mocked>; - let prisma: { user: { findUnique: jest.Mock } }; + let prisma: { user: { findUnique: jest.Mock; findMany: jest.Mock } }; let config: jest.Mocked>; let store: jest.Mocked< Pick @@ -86,7 +86,7 @@ describe('LoginController', () => { throw new Error('invalid'); }), }; - prisma = { user: { findUnique: jest.fn() } }; + prisma = { user: { findUnique: jest.fn(), findMany: jest.fn().mockResolvedValue([]) } }; config = { get: jest.fn().mockReturnValue(undefined) }; store = { getOAuthSession: jest.fn(), getClient: jest.fn() }; sso = { startMcp: jest.fn() }; @@ -283,12 +283,12 @@ describe('LoginController', () => { makeReq({ signedCookies: { login_csrf: 'tok' }, headers: { host: 'mcp.test' } }); it('sets login_user and redirects to /callback on valid credentials', async () => { - prisma.user.findUnique.mockResolvedValue({ + prisma.user.findMany.mockResolvedValue([{ id: 'u1', email: 'a@b.com', name: 'A', passwordHash: 'hash', - }); + }]); authService.comparePassword.mockResolvedValue(true); const res = makeRes(); @@ -304,12 +304,42 @@ describe('LoginController', () => { expect(res._redirect).toBe('http://mcp.test/callback'); }); + it('signs in an account stored with capitals from the lower-case address', async () => { + prisma.user.findMany.mockResolvedValue([{ + id: 'u1', + email: 'Name@Example.com', + name: 'A', + passwordHash: 'hash', + }]); + authService.comparePassword.mockResolvedValue(true); + + const res = makeRes(); + await controller.handleLogin( + okReq(), + { email: 'name@example.com', password: 'pw', csrf: 'tok', action: 'approve' }, + res, + ); + + expect(prisma.user.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { email: { in: ['name@example.com'] } }, + { email: { equals: 'name@example.com', mode: 'insensitive' } }, + ], + }, + }); + expect(authService.comparePassword).toHaveBeenCalledWith('pw', 'hash'); + const profile = JSON.parse(Buffer.from(res._cookies['login_user'].value, 'base64url').toString()); + expect(profile.id).toBe('u1'); + expect(res._redirect).toBe('http://mcp.test/callback'); + }); + it('redirects with an error on wrong password (no login_user set)', async () => { - prisma.user.findUnique.mockResolvedValue({ + prisma.user.findMany.mockResolvedValue([{ id: 'u1', email: 'a@b.com', passwordHash: 'hash', - }); + }]); authService.comparePassword.mockResolvedValue(false); const res = makeRes(); @@ -522,7 +552,7 @@ describe('LoginController', () => { let licenseGuard: { getTrialState: jest.Mock }; let eligibility: { hadSubscription: jest.Mock }; let cloudPrisma: { - user: { findUnique: jest.Mock }; + user: { findUnique: jest.Mock; findMany: jest.Mock }; organizationMember: { findFirst: jest.Mock }; connector: { count: jest.Mock }; }; @@ -545,6 +575,7 @@ describe('LoginController', () => { ? { organizationId: 'org-1', email: 'a@b.com' } : { id: 'u1', email: 'a@b.com', name: 'A', passwordHash: 'hash' }, ); + cloudPrisma.user.findMany.mockResolvedValue([{ id: 'u1', email: 'a@b.com', name: 'A', passwordHash: 'hash' }]); cloudPrisma.organizationMember.findFirst.mockResolvedValue({ role: opts.role ?? 'ADMIN' }); cloudPrisma.connector.count.mockResolvedValue(opts.connectors ?? 0); authService.comparePassword.mockResolvedValue(true); @@ -566,7 +597,7 @@ describe('LoginController', () => { beforeEach(() => { cloudPrisma = { - user: { findUnique: jest.fn() }, + user: { findUnique: jest.fn(), findMany: jest.fn() }, organizationMember: { findFirst: jest.fn() }, connector: { count: jest.fn() }, }; diff --git a/packages/backend/src/auth/login.controller.ts b/packages/backend/src/auth/login.controller.ts index 98196949b..8bc67120d 100644 --- a/packages/backend/src/auth/login.controller.ts +++ b/packages/backend/src/auth/login.controller.ts @@ -21,6 +21,8 @@ import { } from './auth.service'; import { PrismaService } from '../common/prisma.service'; import { DeploymentService } from '../common/deployment.service'; +import { findUserByEmail } from '../common/email.util'; +import type { User } from '../generated/prisma/client'; import { PrismaOAuthStore } from './prisma-oauth.store'; import { SsoService } from '../identity-providers/sso.service'; import { MCP_RESOURCE_COOKIE } from './resource-indicator.middleware'; @@ -270,10 +272,9 @@ export class LoginController { ); } - // Find user by email - const user = await this.prisma.user.findUnique({ - where: { email }, - }); + // Find user by email, in any case: an address registered with capitals + // must still sign in when typed in lower case, and the other way round. + const user = await findUserByEmail(this.prisma, email); if (!user) { this.logger.warn(`Login attempt for non-existent user: ${email}`); diff --git a/packages/backend/src/common/email.util.spec.ts b/packages/backend/src/common/email.util.spec.ts new file mode 100644 index 000000000..6049e3ced --- /dev/null +++ b/packages/backend/src/common/email.util.spec.ts @@ -0,0 +1,121 @@ +import { + emailLookupWhere, + findUserByEmail, + findUsersByEmail, + normalizeEmail, + pickEmailMatch, + sameEmail, +} from './email.util'; + +describe('normalizeEmail', () => { + it('trims and lower-cases', () => { + expect(normalizeEmail(' Name@Example.COM ')).toBe('name@example.com'); + }); + + it('leaves an already normalised address alone', () => { + expect(normalizeEmail('name@example.com')).toBe('name@example.com'); + }); +}); + +describe('sameEmail', () => { + it('ignores case and surrounding spaces', () => { + expect(sameEmail('NAME@example.com', ' name@EXAMPLE.com')).toBe(true); + }); + + it('is false for different addresses, empty values and non-strings', () => { + expect(sameEmail('a@example.com', 'b@example.com')).toBe(false); + expect(sameEmail('', '')).toBe(false); + expect(sameEmail(null, 'a@example.com')).toBe(false); + expect(sameEmail('a@example.com', undefined)).toBe(false); + }); +}); + +describe('emailLookupWhere', () => { + it('matches the exact spellings and the address in any case', () => { + expect(emailLookupWhere(' Name@Example.com')).toEqual({ + OR: [ + { email: { in: ['Name@Example.com', 'name@example.com'] } }, + { email: { equals: 'name@example.com', mode: 'insensitive' } }, + ], + }); + }); + + it('escapes LIKE wildcards, which Prisma passes to ILIKE as they are', () => { + const where = emailLookupWhere('a_b%c\\d@example.com'); + expect(where.OR[1]).toEqual({ + email: { equals: 'a\\_b\\%c\\\\d@example.com', mode: 'insensitive' }, + }); + }); +}); + +describe('pickEmailMatch', () => { + const older = { id: 'old', email: 'NAME@example.com', createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-02-01') }; + const newer = { id: 'new', email: 'name@example.com', createdAt: new Date('2026-03-01'), updatedAt: new Date('2026-03-01') }; + const touched = { id: 'touched', email: 'Name@Example.com', createdAt: new Date('2025-12-01'), updatedAt: new Date('2026-04-01') }; + + it('returns null for no rows and the row for one', () => { + expect(pickEmailMatch([], 'x@example.com')).toBeNull(); + expect(pickEmailMatch([older], 'name@example.com')).toBe(older); + }); + + it('prefers the row spelled exactly as typed', () => { + expect(pickEmailMatch([older, newer, touched], 'NAME@example.com')).toBe(older); + expect(pickEmailMatch([older, newer, touched], ' name@example.com ')).toBe(newer); + }); + + it('otherwise takes the most recently updated row', () => { + expect(pickEmailMatch([older, newer, touched], 'nAmE@example.com')).toBe(touched); + }); + + it('breaks an updatedAt tie on createdAt', () => { + const a = { id: 'a', email: 'X@example.com', createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-05-01') }; + const b = { id: 'b', email: 'x@EXAMPLE.com', createdAt: new Date('2026-02-01'), updatedAt: new Date('2026-05-01') }; + expect(pickEmailMatch([a, b], 'x@example.COM')?.id).toBe('b'); + }); +}); + +describe('findUsersByEmail / findUserByEmail', () => { + const db = (rows: Array>) => ({ user: { findMany: jest.fn().mockResolvedValue(rows) } }); + + it('finds a row stored with capitals from a lower-case address', async () => { + const prisma = db([{ id: 'u1', email: 'Name@Example.com' }]); + const user = await findUserByEmail(prisma, 'name@example.com'); + expect(user?.id).toBe('u1'); + expect(prisma.user.findMany).toHaveBeenCalledWith({ where: emailLookupWhere('name@example.com') }); + }); + + it('drops rows that are not the same address (a wildcard match)', async () => { + const prisma = db([ + { id: 'u1', email: 'a_b@example.com' }, + { id: 'u2', email: 'axb@example.com' }, + ]); + const rows = await findUsersByEmail(prisma, 'A_B@example.com'); + expect(rows.map((r) => r.id)).toEqual(['u1']); + }); + + it('adds the columns the tie-break needs to a narrow select', async () => { + const prisma = db([]); + await findUsersByEmail(prisma, 'a@example.com', { name: true }); + expect(prisma.user.findMany).toHaveBeenCalledWith({ + where: emailLookupWhere('a@example.com'), + select: { name: true, id: true, email: true, createdAt: true, updatedAt: true }, + }); + }); + + it('does not query for an empty or missing address', async () => { + const prisma = db([{ id: 'u1', email: 'a@example.com' }]); + expect(await findUserByEmail(prisma, ' ')).toBeNull(); + expect(await findUserByEmail(prisma, undefined)).toBeNull(); + expect(await findUserByEmail(prisma, null)).toBeNull(); + expect(prisma.user.findMany).not.toHaveBeenCalled(); + }); + + it('picks the exact spelling among legacy duplicates', async () => { + const prisma = db([ + { id: 'upper', email: 'NAME@example.com', updatedAt: new Date('2026-01-01') }, + { id: 'lower', email: 'name@example.com', updatedAt: new Date('2026-02-01') }, + ]); + expect((await findUserByEmail(prisma, 'NAME@example.com'))?.id).toBe('upper'); + expect((await findUserByEmail(prisma, 'Name@Example.com'))?.id).toBe('lower'); + }); +}); diff --git a/packages/backend/src/common/email.util.ts b/packages/backend/src/common/email.util.ts new file mode 100644 index 000000000..dead53e91 --- /dev/null +++ b/packages/backend/src/common/email.util.ts @@ -0,0 +1,117 @@ +import { Logger } from '@nestjs/common'; +import type { Prisma } from '../generated/prisma/client'; + +/** + * One address, one account. + * + * `users.email` used to be stored exactly as typed and looked up the same way, + * so `Name@Example.com` and `name@example.com` were two different people: a + * user who registered with capitals and later typed the address in lower case + * could not sign in, signed up again, and got a second, empty workspace with a + * second trial, while anything bought on the first one stayed there. + * + * Every address is now normalised before it is written, and every lookup by + * address is case-insensitive, so rows written before the change are still + * found. Rows that only differ in case already exist in the database; they + * are left for an operator to merge (see + * scripts/report-duplicate-emails.sql), and a lookup that meets them picks one + * deterministically (see `pickEmailMatch`) rather than failing. + */ + +/** Trimmed and lower-cased. The only form an address is written in. */ +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +/** Whether two addresses are the same address, ignoring case and surrounding spaces. */ +export function sameEmail(a: string | null | undefined, b: string | null | undefined): boolean { + if (typeof a !== 'string' || typeof b !== 'string') return false; + const left = normalizeEmail(a); + return left !== '' && left === normalizeEmail(b); +} + +/** + * Prisma's case-insensitive `equals` is `ILIKE` on PostgreSQL, unescaped: an + * `_` or `%` in the address would act as a wildcard, and `a_b@example.com` + * would also match `axb@example.com`. Escaped here, and the rows are checked + * again in code (`findUsersByEmail`), so a match is always the same address. + */ +function escapeLikePattern(value: string): string { + return value.replace(/[\\%_]/g, (c) => `\\${c}`); +} + +/** + * `where` for the rows whose `email` column equals `email` in any case (users, + * invitations). The exact forms are matched by plain equality too, so they are + * found even if the insensitive branch ever stops treating the escapes as + * escapes. Callers must still filter the rows with `sameEmail`; + * `findUsersByEmail` does. + */ +export function emailLookupWhere(email: string): { OR: Array<{ email: Prisma.StringFilter }> } { + const trimmed = email.trim(); + const normalized = normalizeEmail(email); + return { + OR: [ + { email: { in: [...new Set([trimmed, normalized])] } }, + { email: { equals: escapeLikePattern(normalized), mode: 'insensitive' } }, + ], + }; +} + +type EmailRow = { email: string; createdAt?: Date | null; updatedAt?: Date | null }; + +/** + * The account an address means when several rows share it in different case + * (written before addresses were normalised): the row spelled exactly as + * typed, otherwise the most recently updated, then the most recently created. + * Never null for a non-empty list. + */ +export function pickEmailMatch(rows: T[], typed: string): T | null { + if (rows.length === 0) return null; + if (rows.length === 1) return rows[0]; + const exact = rows.find((r) => r.email === typed.trim()); + if (exact) return exact; + const time = (d: Date | null | undefined) => (d ? new Date(d).getTime() : 0); + return [...rows].sort( + (a, b) => time(b.updatedAt) - time(a.updatedAt) || time(b.createdAt) - time(a.createdAt), + )[0]; +} + +/** Anything with a `user.findMany`: PrismaService, or a transaction client. */ +type UserFinder = { user: { findMany: (args: any) => Promise } }; + +const logger = new Logger('EmailLookup'); + +/** Every user row whose address is `email`, in any case. */ +export async function findUsersByEmail( + db: UserFinder, + email: string, + select?: Prisma.UserSelect, +): Promise { + if (!normalizeEmail(email)) return []; + const rows = (await db.user.findMany({ + where: emailLookupWhere(email), + // The tie-break in pickEmailMatch and the duplicate warning read these. + ...(select ? { select: { ...select, id: true, email: true, createdAt: true, updatedAt: true } } : {}), + })) as T[]; + return rows.filter((r) => sameEmail(r.email, email)); +} + +/** + * The user an address belongs to, matched case-insensitively, or null. When + * more than one row matches (legacy duplicates), see `pickEmailMatch`. + */ +export async function findUserByEmail( + db: UserFinder, + email: string | null | undefined, + select?: Prisma.UserSelect, +): Promise { + if (typeof email !== 'string') return null; + const rows = await findUsersByEmail(db, email, select); + if (rows.length > 1) { + // Ids only: these are accounts an operator should merge by hand. + const ids = rows.map((r) => (r as { id?: string }).id).filter(Boolean); + logger.warn(`${rows.length} accounts share one address in different case${ids.length ? ` (${ids.join(', ')})` : ''}`); + } + return pickEmailMatch(rows, email); +} diff --git a/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts b/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts index 06d3d5be5..2ed5197ae 100644 --- a/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts +++ b/packages/backend/src/identity-providers/scim/scim-users.service.spec.ts @@ -57,6 +57,7 @@ describe('ScimUsersService', () => { }, user: { findUnique: jest.fn(async () => null), + findMany: jest.fn(async (): Promise => []), create: jest.fn(async () => ({ id: 'u-new' })), update: jest.fn(async () => ({})), delete: jest.fn(), @@ -123,7 +124,7 @@ describe('ScimUsersService', () => { // The same anti-takeover rule as JIT: a directory never claims a local account. it('refuses to adopt an existing unlinked local account', async () => { - prisma.user.findUnique.mockResolvedValue({ id: 'local' }); + prisma.user.findMany.mockResolvedValue([{ id: 'local', email: 'anna.rossi@x.com' }]); const err = await service.create(provider, body, ctx).catch((e) => e); expect(err).toBeInstanceOf(ScimError); expect(err.status).toBe(409); @@ -131,6 +132,16 @@ describe('ScimUsersService', () => { expect(prisma.user.create).not.toHaveBeenCalled(); }); + it('treats a local account stored with capitals as owning the address', async () => { + prisma.user.findMany.mockResolvedValue([{ id: 'local', email: 'ANNA.ROSSI@X.COM' }]); + const err = await service.create(provider, body, ctx).catch((e) => e); + expect(err.status).toBe(409); + expect(prisma.user.findMany.mock.calls[0][0].where.OR).toContainEqual({ + email: { equals: 'anna.rossi@x.com', mode: 'insensitive' }, + }); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + it('deactivates immediately when created with active:false', async () => { prisma.userIdentity.findUnique .mockResolvedValueOnce(null) @@ -251,15 +262,22 @@ describe('ScimUsersService', () => { it.each([ ['has_password', { passwordHash: 'x' }, null], ['multi_org', { memberships: [{ organizationId: ORG, deactivatedAt: null }, { organizationId: 'org-2', deactivatedAt: null }] }, null], - ['conflict', {}, { id: 'someone' }], + ['conflict', {}, { id: 'someone', email: 'NEW@x.com' }], ])('is skipped (%s) but the request still succeeds', async (reason, userOver, owner) => { prisma.userIdentity.findUnique.mockResolvedValue(identity({}, userOver as any)); - prisma.user.findUnique.mockResolvedValue(owner); + prisma.user.findMany.mockResolvedValue(owner ? [owner] : []); await service.patch(provider, 'u1', emailOp, ctx); expect(prisma.user.update).not.toHaveBeenCalled(); expect(events[0].metadata.emailChangeSkipped).toBe(reason); }); + it('lower-cases an address stored with capitals: the only match is the user itself', async () => { + prisma.userIdentity.findUnique.mockResolvedValue(identity({}, { email: 'Anna@X.com' })); + prisma.user.findMany.mockResolvedValue([{ id: 'u1', email: 'Anna@X.com' }]); + await service.patch(provider, 'u1', patch([{ op: 'replace', path: 'userName', value: 'anna@x.com' }]), ctx); + expect(prisma.user.update).toHaveBeenCalledWith({ where: { id: 'u1' }, data: { email: 'anna@x.com' } }); + }); + it('a deactivation in the same request is never blocked by an email conflict', async () => { prisma.userIdentity.findUnique.mockResolvedValue(identity({}, { passwordHash: 'x' })); await service.patch(provider, 'u1', patch([ @@ -295,10 +313,18 @@ describe('ScimUsersService', () => { })); }); - it('filters by lower-cased email within the provider', async () => { + it('filters by email in any case within the provider', async () => { await service.list(provider, { attr: 'userName', value: 'Anna@X.com' }, { startIndex: 1, count: 100 }, ctx); expect(prisma.userIdentity.findMany).toHaveBeenCalledWith(expect.objectContaining({ - where: { providerId: 'idp-1', user: { email: 'anna@x.com' } }, + where: { + providerId: 'idp-1', + user: { + OR: [ + { email: { in: ['Anna@X.com', 'anna@x.com'] } }, + { email: { equals: 'anna@x.com', mode: 'insensitive' } }, + ], + }, + }, })); }); diff --git a/packages/backend/src/identity-providers/scim/scim-users.service.ts b/packages/backend/src/identity-providers/scim/scim-users.service.ts index 05096daf5..72b5987bb 100644 --- a/packages/backend/src/identity-providers/scim/scim-users.service.ts +++ b/packages/backend/src/identity-providers/scim/scim-users.service.ts @@ -20,6 +20,7 @@ import { ScimFilter, } from './scim.parser'; import { SCIM_LIST_SCHEMA, SCIM_USER_SCHEMA } from './scim.schemas'; +import { emailLookupWhere, findUserByEmail, findUsersByEmail, normalizeEmail } from '../../common/email.util'; import type { ScimProvider } from './scim-auth.guard'; export interface ScimCtx { @@ -144,16 +145,13 @@ export class ScimUsersService { throw new ScimError(409, 'User already provisioned', 'uniqueness'); } - const email = parsed.primaryEmail ?? parsed.userName.toLowerCase(); + const email = parsed.primaryEmail ?? normalizeEmail(parsed.userName); // The same anti-takeover rule as JIT provisioning: a local account that - // owns this address is never claimed by a directory. Binding this - // provider's object id to it would let whoever controls the tenant sign - // in as that person everywhere they are a member. - const collision = await this.prisma.user.findUnique({ - where: { email }, - select: { id: true }, - }); + // owns this address, in any case, is never claimed by a directory. + // Binding this provider's object id to it would let whoever controls the + // tenant sign in as that person everywhere they are a member. + const collision = await findUserByEmail(this.prisma, email, { id: true }); if (collision) { throw new ScimError( 409, @@ -222,7 +220,7 @@ export class ScimUsersService { const changes: UserChanges = { // PUT with `active` absent must not silently reactivate. ...(body && typeof (body as any).active !== 'undefined' ? { active: parsed.active } : {}), - email: parsed.primaryEmail ?? parsed.userName.toLowerCase(), + email: parsed.primaryEmail ?? normalizeEmail(parsed.userName), name: displayNameOf(parsed), externalId: parsed.externalId, }; @@ -268,7 +266,9 @@ export class ScimUsersService { switch (filter.attr) { case 'userName': case 'emails.value': - return { user: { email: filter.value.toLowerCase() } }; + // In any case: a row written before addresses were normalised keeps + // its capitals, and Entra looks users up by what it holds. + return { user: emailLookupWhere(filter.value) }; case 'externalId': return { externalSubject: filter.value }; case 'id': @@ -294,12 +294,12 @@ export class ScimUsersService { if (lower === 'active') { c.active = op.op === 'remove' ? false : coerceActive(op.value); } else if (lower === 'username') { - if (typeof op.value === 'string' && op.value.trim()) c.email = op.value.trim().toLowerCase(); + if (typeof op.value === 'string' && op.value.trim()) c.email = normalizeEmail(op.value); } else if (lower === 'emails') { const e = pickEmail(op.value); if (e) c.email = e; } else if (/^emails\[.*\]\.value$/i.test(path)) { - if (typeof op.value === 'string' && op.value.trim()) c.email = op.value.trim().toLowerCase(); + if (typeof op.value === 'string' && op.value.trim()) c.email = normalizeEmail(op.value); } else if (lower === 'externalid') { if (typeof op.value === 'string') c.externalId = op.value; } else if (lower === 'displayname') { @@ -370,11 +370,15 @@ export class ScimUsersService { // other workspace, and the address unclaimed. Otherwise keep the old // address and carry on — a deactivation in the same request must never // be blocked by an email conflict. + // The conflict check ignores case and this user's own row, so a change + // that only lower-cases the stored address goes through. const skipped = row.user.passwordHash ? 'has_password' : row.user.memberships.some((m) => m.organizationId !== orgId) ? 'multi_org' - : (await this.prisma.user.findUnique({ where: { email: changes.email }, select: { id: true } })) + : (await findUsersByEmail<{ id: string; email: string }>(this.prisma, changes.email, { id: true })).some( + (u) => u.id !== row.userId, + ) ? 'conflict' : null; if (skipped) metadata.emailChangeSkipped = skipped; diff --git a/packages/backend/src/identity-providers/scim/scim.parser.ts b/packages/backend/src/identity-providers/scim/scim.parser.ts index 13dd34eed..778a0c1ea 100644 --- a/packages/backend/src/identity-providers/scim/scim.parser.ts +++ b/packages/backend/src/identity-providers/scim/scim.parser.ts @@ -1,4 +1,5 @@ import { ScimError } from './scim.errors'; +import { normalizeEmail } from '../../common/email.util'; import { SCIM_ENTERPRISE_USER_SCHEMA, SCIM_PATCH_SCHEMA } from './scim.schemas'; /** @@ -163,7 +164,7 @@ export function pickEmail(emails: unknown): string | undefined { entries.find((e) => e.primary === true || e.primary === 'true') ?? entries.find((e) => String(e.type ?? '').toLowerCase() === 'work') ?? entries[0]; - return chosen ? String(chosen.value).trim().toLowerCase() : undefined; + return chosen ? normalizeEmail(String(chosen.value)) : undefined; } /** A display name for the `users.name` column, from whatever Entra sent. */ diff --git a/packages/backend/src/identity-providers/sso.service.spec.ts b/packages/backend/src/identity-providers/sso.service.spec.ts index 75ccc63ee..cc76c8c91 100644 --- a/packages/backend/src/identity-providers/sso.service.spec.ts +++ b/packages/backend/src/identity-providers/sso.service.spec.ts @@ -39,7 +39,7 @@ describe('SsoService', () => { beforeEach(() => { prisma = { userIdentity: { findUnique: jest.fn(), update: jest.fn(), create: jest.fn() }, - user: { findUnique: jest.fn(), create: jest.fn() }, + user: { findUnique: jest.fn(), findMany: jest.fn().mockResolvedValue([]), create: jest.fn() }, organizationMember: { create: jest.fn() }, ssoLoginAttempt: { updateMany: jest.fn(), findUnique: jest.fn() }, $transaction: jest.fn((fn: any) => fn(prisma)), @@ -130,6 +130,7 @@ describe('SsoService', () => { }); // The users table is never consulted by email on the known-identity path. expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(prisma.user.findMany).not.toHaveBeenCalled(); }); it('refuses an unknown identity when JIT is off', async () => { @@ -145,7 +146,7 @@ describe('SsoService', () => { // tenant could otherwise assert a victim's address and be handed their // account. Linking must be a deliberate, authenticated action. prisma.userIdentity.findUnique.mockResolvedValue(null); - prisma.user.findUnique.mockResolvedValue({ id: 'victim' }); + prisma.user.findMany.mockResolvedValue([{ id: 'victim', email: goodClaims.email }]); await expect(resolve({ ...provider, jitProvisioning: true })).rejects.toThrow( expect.objectContaining({ reason: 'email_belongs_to_existing_account' }), @@ -154,14 +155,38 @@ describe('SsoService', () => { expect(prisma.userIdentity.create).not.toHaveBeenCalled(); }); + it('treats an account stored with capitals as owning the address', async () => { + // The unique index is case-sensitive: without a case-insensitive check, + // a JIT sign-in for `user@…` would create a second account next to a + // local `USER@…` instead of refusing. + prisma.userIdentity.findUnique.mockResolvedValue(null); + prisma.user.findMany.mockResolvedValue([{ id: 'victim', email: goodClaims.email.toUpperCase() }]); + + await expect(resolve({ ...provider, jitProvisioning: true })).rejects.toThrow( + expect.objectContaining({ reason: 'email_belongs_to_existing_account' }), + ); + expect(prisma.user.findMany.mock.calls[0][0].where.OR).toContainEqual({ + email: { equals: goodClaims.email, mode: 'insensitive' }, + }); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + it('provisions a passwordless VIEWER when JIT is on', async () => { prisma.userIdentity.findUnique.mockResolvedValue(null); - prisma.user.findUnique.mockResolvedValue(null); prisma.user.create.mockResolvedValue({ id: 'new-user' }); - expect(await resolve({ ...provider, jitProvisioning: true })).toBe('new-user'); + expect( + await service.resolveUser( + { ...provider, jitProvisioning: true }, + 'a-stable-object-id', + goodClaims.tid, + { ...goodClaims, email: ' New.User@Example.com ' }, + {}, + ), + ).toBe('new-user'); const created = prisma.user.create.mock.calls[0][0].data; + expect(created.email).toBe('new.user@example.com'); // No password: the account exists only through the provider, so there is // no second way in that bypasses its MFA and Conditional Access. expect(created.passwordHash).toBeNull(); @@ -184,7 +209,6 @@ describe('SsoService', () => { beforeEach(() => { prisma.userIdentity.findUnique.mockResolvedValue(null); - prisma.user.findUnique.mockResolvedValue(null); prisma.user.create.mockResolvedValue({ id: 'new-user' }); }); diff --git a/packages/backend/src/identity-providers/sso.service.ts b/packages/backend/src/identity-providers/sso.service.ts index 1ca1d5409..346bf535a 100644 --- a/packages/backend/src/identity-providers/sso.service.ts +++ b/packages/backend/src/identity-providers/sso.service.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { randomBytes } from 'crypto'; import * as client from 'openid-client'; import { PrismaService } from '../common/prisma.service'; +import { findUserByEmail, normalizeEmail } from '../common/email.util'; import { ROLE_SYNC_PROVIDER_SELECT, RoleSyncService } from './role-sync.service'; import { DeploymentService } from '../common/deployment.service'; import { AuthService } from '../auth/auth.service'; @@ -879,13 +880,12 @@ export class SsoService { // Provision. The email is used ONLY to populate a display field on a // brand-new row — never to find or claim an existing account. - const email = String(claims.email ?? claims.preferred_username ?? '').toLowerCase(); + const email = normalizeEmail(String(claims.email ?? claims.preferred_username ?? '')); if (!email) throw new SsoError('jit_without_email'); - const collision = await this.prisma.user.findUnique({ - where: { email }, - select: { id: true }, - }); + // In any case: a local account stored as `Name@Example.com` owns + // `name@example.com` too. + const collision = await findUserByEmail(this.prisma, email, { id: true }); if (!collision) { // Creating accounts on first sign-in needs Enterprise and a free seat. // People who already have an identity here never reach this point, so diff --git a/packages/backend/src/license/license-trial.service.spec.ts b/packages/backend/src/license/license-trial.service.spec.ts index 01faab753..c036232f2 100644 --- a/packages/backend/src/license/license-trial.service.spec.ts +++ b/packages/backend/src/license/license-trial.service.spec.ts @@ -131,6 +131,16 @@ describe('LicenseService — trial acquisition', () => { const [, payload] = mockedAxios.post.mock.calls[0] as any[]; expect(payload).toEqual({ email: 'user@example.com', name: 'User', instanceId: 'instance-1' }); }); + + it('sends the address normalised, so a case variant is not a new address to the licence site', async () => { + mockedAxios.post.mockResolvedValue({ data: TRIAL_RESPONSE } as any); + const { svc } = makeService(); + + await svc.requestTrialLicense(' User@Example.COM', 'User', 'org-1'); + + const [, payload] = mockedAxios.post.mock.calls[0] as any[]; + expect(payload.email).toBe('user@example.com'); + }); }); describe('LicenseService — a trial answer without a key', () => { diff --git a/packages/backend/src/license/license.service.ts b/packages/backend/src/license/license.service.ts index 153c1d762..3a671793d 100644 --- a/packages/backend/src/license/license.service.ts +++ b/packages/backend/src/license/license.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/commo import axios from 'axios'; import * as crypto from 'crypto'; import { PrismaService } from '../common/prisma.service'; +import { normalizeEmail } from '../common/email.util'; import { DeploymentService } from '../common/deployment.service'; import { SiteSettingsService } from '../settings/site-settings.service'; import { CheckoutIntentPayload, CheckoutUnavailableError } from './license-checkout'; @@ -320,9 +321,11 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { const instanceId = await this.getInstanceId(); try { + // Normalised: the licence site keys one licence per address, and + // `Name@Example.com` is the same inbox as `name@example.com`. await axios.post( `${this.apiBase}/api/license/register`, - { email, name, instanceId }, + { email: normalizeEmail(email), name, instanceId }, { timeout: 10000 }, ); @@ -402,7 +405,9 @@ export class LicenseService implements OnModuleInit, OnModuleDestroy { const instanceId = await this.getInstanceId(); try { - const data = await this.postTrialWithRetry({ email, name, instanceId }); + // Normalised: the licence site grants one trial per address, and an + // account stored with capitals must not count as a different one. + const data = await this.postTrialWithRetry({ email: normalizeEmail(email), name, instanceId }); if (!data?.licenseKey) { // The licence API hands the key back only to this server, by its // service token; anyone else is told it went by email. Without a key diff --git a/packages/backend/src/settings/email.service.spec.ts b/packages/backend/src/settings/email.service.spec.ts index 4685a141a..7390b384b 100644 --- a/packages/backend/src/settings/email.service.spec.ts +++ b/packages/backend/src/settings/email.service.spec.ts @@ -13,11 +13,16 @@ describe('EmailService', () => { const sendMail = jest.fn().mockResolvedValue({}); const ENV = { ...process.env }; - function make(opts: { cloud?: boolean; stats?: any; user?: { id: string } | null } = {}) { + function make(opts: { cloud?: boolean; stats?: any; user?: { id: string; email: string } | null } = {}) { const siteSettings = { getSmtpConfig: jest.fn().mockResolvedValue(null), get: jest.fn() }; const orgSettings = { getJson: jest.fn().mockResolvedValue(null) }; const prisma = { - user: { findUnique: jest.fn().mockResolvedValue(opts.user === undefined ? { id: 'user-1' } : opts.user) }, + // Stored with capitals: the recipient is looked up in any case. + user: { + findMany: jest + .fn() + .mockResolvedValue(opts.user === undefined ? [{ id: 'user-1', email: 'A@b.com' }] : opts.user ? [opts.user] : []), + }, license: { findFirst: jest.fn().mockResolvedValue(null) }, }; const cloud = opts.cloud ?? true; diff --git a/packages/backend/src/settings/email.service.ts b/packages/backend/src/settings/email.service.ts index dbc35bdd6..35a18e88f 100644 --- a/packages/backend/src/settings/email.service.ts +++ b/packages/backend/src/settings/email.service.ts @@ -4,6 +4,7 @@ import axios from 'axios'; import { SiteSettingsService } from './site-settings.service'; import { OrgSettingsService } from './org-settings.service'; import { PrismaService } from '../common/prisma.service'; +import { findUserByEmail } from '../common/email.util'; import { DeploymentService } from '../common/deployment.service'; import { TrustStatsService } from '../public-stats/trust-stats.service'; import { EMPTY_TRUST_STATS, TrustStats, formatTrustStats } from '../public-stats/trust-stats.format'; @@ -105,9 +106,7 @@ export class EmailService { const cloudUrl = this.cloudUrl(); const secret = unsubscribeSecret(); const user = secret - ? await this.prisma.user - .findUnique({ where: { email: to }, select: { id: true } }) - .catch(() => null) + ? await findUserByEmail<{ id: string; email: string }>(this.prisma, to, { id: true }).catch(() => null) : null; if (secret && user) { const url = buildUnsubscribeUrl(cloudUrl, user.id, secret); diff --git a/packages/backend/src/users/users.controller.ts b/packages/backend/src/users/users.controller.ts index 8d1bccb69..1b9603ef6 100644 --- a/packages/backend/src/users/users.controller.ts +++ b/packages/backend/src/users/users.controller.ts @@ -15,6 +15,7 @@ import { BadRequestException, NotFoundException, ForbiddenException, + ConflictException, } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; @@ -26,6 +27,7 @@ import { Roles, RolesGuard } from '../auth/roles.guard'; import { OrganizationsService } from '../organizations/organizations.service'; import { UserLifecycleService, LifecycleContext } from './user-lifecycle.service'; import { SEAT_LIMIT, seatLimitMessage } from '../ee/licensing/edition.service'; +import { normalizeEmail, sameEmail } from '../common/email.util'; class RevokeSessionsDto { @ApiPropertyOptional({ @@ -164,13 +166,21 @@ export class UsersController { // keep the old behaviour. if (process.env.DEPLOYMENT_MODE === 'cloud') { const current = await this.usersService.findById(req.user.sub); - if (dto.email.trim().toLowerCase() !== current?.email?.trim().toLowerCase()) { + if (!sameEmail(dto.email, current?.email)) { throw new BadRequestException( 'Your email address cannot be changed here. Contact support@anythingmcp.com to change it.', ); } } else { - data.email = dto.email; + // Stored normalised, and refused when another account already has + // the address in any case: the unique index only sees exact + // spellings, so `Name@Example.com` would otherwise sit next to + // `name@example.com` as a second account. + const owners = await this.usersService.findAllByEmail(dto.email); + if (owners.some((u) => u.id !== req.user.sub)) { + throw new ConflictException('Email already registered'); + } + data.email = normalizeEmail(dto.email); } } diff --git a/packages/backend/src/users/users.service.spec.ts b/packages/backend/src/users/users.service.spec.ts index b05641f68..06e10268d 100644 --- a/packages/backend/src/users/users.service.spec.ts +++ b/packages/backend/src/users/users.service.spec.ts @@ -47,17 +47,36 @@ describe('UsersService', () => { }); describe('findByEmail', () => { - it('should call findUnique with email', async () => { - mockPrisma.user.findUnique.mockResolvedValue(mockUser); - const result = await service.findByEmail('test@example.com'); + it('looks the address up in any case', async () => { + mockPrisma.user.findMany.mockResolvedValue([mockUser]); + const result = await service.findByEmail('Test@Example.com'); expect(result).toBe(mockUser); - expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ - where: { email: 'test@example.com' }, + expect(mockPrisma.user.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + { email: { in: ['Test@Example.com', 'test@example.com'] } }, + { email: { equals: 'test@example.com', mode: 'insensitive' } }, + ], + }, }); }); + it('finds a row stored with capitals from the lower-case address', async () => { + const upper = { ...mockUser, email: 'TEST@EXAMPLE.COM' }; + mockPrisma.user.findMany.mockResolvedValue([upper]); + expect(await service.findByEmail('test@example.com')).toBe(upper); + }); + + it('prefers the exact spelling when legacy duplicates exist', async () => { + const upper = { ...mockUser, id: 'upper', email: 'TEST@example.com', updatedAt: new Date('2026-01-01') }; + const lower = { ...mockUser, id: 'lower', email: 'test@example.com', updatedAt: new Date('2026-02-01') }; + mockPrisma.user.findMany.mockResolvedValue([upper, lower]); + expect((await service.findByEmail('TEST@example.com'))?.id).toBe('upper'); + expect((await service.findByEmail('test@example.com'))?.id).toBe('lower'); + }); + it('should return null when not found', async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.user.findMany.mockResolvedValue([]); const result = await service.findByEmail('nobody@example.com'); expect(result).toBeNull(); }); @@ -75,17 +94,17 @@ describe('UsersService', () => { }); describe('create', () => { - it('should call create with provided data', async () => { + it('should call create with provided data, the address normalised', async () => { const data = { - email: 'new@example.com', + email: ' New@Example.com', passwordHash: 'hash', name: 'New User', organizationId: 'org-1', }; mockPrisma.user.create.mockResolvedValue({ ...mockUser, ...data }); const result = await service.create(data); - expect(mockPrisma.user.create).toHaveBeenCalledWith({ data }); - expect(result.email).toBe('new@example.com'); + expect(mockPrisma.user.create).toHaveBeenCalledWith({ data: { ...data, email: 'new@example.com' } }); + expect(result.email).toBe(' New@Example.com'); }); }); diff --git a/packages/backend/src/users/users.service.ts b/packages/backend/src/users/users.service.ts index 62dc1d636..0930487bb 100644 --- a/packages/backend/src/users/users.service.ts +++ b/packages/backend/src/users/users.service.ts @@ -5,6 +5,7 @@ import { OrganizationsService } from '../organizations/organizations.service'; import { UserLifecycleService, LifecycleContext } from './user-lifecycle.service'; import { SecurityEventService, SecurityEvents } from '../audit/security-event.service'; import { LicenseReleaseService } from '../license/license-release.service'; +import { findUserByEmail, findUsersByEmail, normalizeEmail } from '../common/email.util'; @Injectable() export class UsersService { @@ -18,8 +19,14 @@ export class UsersService { private readonly licenseRelease: LicenseReleaseService, ) {} + /** Case-insensitive: rows written before addresses were normalised are still found. */ async findByEmail(email: string): Promise { - return this.prisma.user.findUnique({ where: { email } }); + return findUserByEmail(this.prisma, email); + } + + /** Every account with this address in any case (more than one only for legacy duplicates). */ + async findAllByEmail(email: string): Promise { + return findUsersByEmail(this.prisma, email); } async findById(id: string): Promise { @@ -33,7 +40,7 @@ export class UsersService { role?: UserRole; organizationId: string; }): Promise { - return this.prisma.user.create({ data }); + return this.prisma.user.create({ data: { ...data, email: normalizeEmail(data.email) } }); } async count(): Promise {