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
Original file line number Diff line number Diff line change
@@ -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"));
39 changes: 39 additions & 0 deletions packages/backend/scripts/report-duplicate-emails.sql
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 3 additions & 5 deletions packages/backend/src/audit/audit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
143 changes: 141 additions & 2 deletions packages/backend/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<Account>({ 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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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');
});
});
25 changes: 15 additions & 10 deletions packages/backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -844,7 +850,7 @@ export class AuthController {

// Send email
const emailResult = await this.emailService.sendInvitationEmail(
dto.email,
inviteEmail,
inviteUrl,
inviterName,
roleName,
Expand All @@ -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 } : {}),
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading