fix: made email addresses case insensitive

This commit is contained in:
Wim Van Laer
2026-08-10 15:25:30 +02:00
parent 30434d176c
commit 682ee8335c
7 changed files with 224 additions and 14 deletions
+26 -1
View File
@@ -1,7 +1,32 @@
import { describe, expect, it } from "vitest";
import { getValidLocaleFromUILocales, isRSCRequest, isValidLanguage, validateAuthRequest } from "./auth-utils";
import {
equalsIgnoreCase,
getValidLocaleFromUILocales,
isRSCRequest,
isValidLanguage,
validateAuthRequest,
} from "./auth-utils";
describe("auth-utils", () => {
describe("equalsIgnoreCase", () => {
it("should match identifiers regardless of case", () => {
expect(equalsIgnoreCase("jackson@example.com", "Jackson@Example.com")).toBe(true);
expect(equalsIgnoreCase("USER@ORGDOMAIN.COM", "user@orgdomain.com")).toBe(true);
expect(equalsIgnoreCase("user", "user")).toBe(true);
});
it("should not match different identifiers", () => {
expect(equalsIgnoreCase("jackson@example.com", "jane@example.com")).toBe(false);
expect(equalsIgnoreCase("user@example.com", "user@other.com")).toBe(false);
});
it("should return false when either value is undefined", () => {
expect(equalsIgnoreCase(undefined, "user@example.com")).toBe(false);
expect(equalsIgnoreCase("user@example.com", undefined)).toBe(false);
expect(equalsIgnoreCase(undefined, undefined)).toBe(false);
});
});
describe("isValidLanguage", () => {
it("should return true for valid language codes", () => {
expect(isValidLanguage("en")).toBe(true);
+15
View File
@@ -4,6 +4,21 @@ import { LANGS } from "@/lib/i18n";
* Authentication utility functions that don't require server actions
*/
/**
* Compare two identifiers (loginname, email, phone) case insensitively.
*
* User lookup is performed with EQUALS_IGNORE_CASE, so any follow up check that
* verifies *which* identifier the user was found by has to ignore case as well.
* Otherwise entering "Jackson@example.com" for the user "jackson@example.com"
* resolves the user but is then rejected as unknown.
*/
export function equalsIgnoreCase(a: string | undefined, b: string | undefined): boolean {
if (a === undefined || b === undefined) {
return false;
}
return a.toLowerCase() === b.toLowerCase();
}
/**
* Check if a language code is valid (supported by the login UI)
*/
@@ -948,6 +948,76 @@ describe("sendLoginname", () => {
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
});
test("should allow login with a differently cased login name when email and phone login are disabled", async () => {
// Regression test for: https://github.com/zitadel/zitadel/issues/12025
// The user search matches case insensitively, so the follow up check that verifies
// the user was found by login name must not reject "Jackson@Example.com".
const mockUser = {
userId: "user123",
preferredLoginName: "jackson@example.com",
details: { resourceOwner: "org123" },
type: { case: "human", value: { email: { email: "jackson@example.com" } } },
state: UserState.ACTIVE,
};
const mockSession = {
factors: { user: { id: "user123", loginName: "jackson@example.com", organizationId: "org123" } },
};
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
mockGetLoginSettings.mockResolvedValue({
disableLoginWithEmail: true,
disableLoginWithPhone: true,
allowLocalAuthentication: true,
});
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
const result = await sendLoginname({
loginName: "Jackson@Example.com",
});
expect(result).not.toEqual({ error: "errors.userNotFound" });
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
});
test("should allow login with a differently cased email when disableLoginWithPhone is true", async () => {
// Regression test for: https://github.com/zitadel/zitadel/issues/12025
const mockUser = {
userId: "user123",
preferredLoginName: "jackson@orgdomain.com",
details: { resourceOwner: "org123" },
type: {
case: "human",
value: { email: { email: "jackson@example.com" }, phone: { phone: "+1234567890" } },
},
state: UserState.ACTIVE,
};
const mockSession = {
factors: { user: { id: "user123", loginName: "jackson@orgdomain.com", organizationId: "org123" } },
};
mockSearchUsers.mockResolvedValue({ result: [mockUser] });
mockGetLoginSettings.mockResolvedValue({
disableLoginWithPhone: true,
allowLocalAuthentication: true,
});
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
const result = await sendLoginname({
loginName: "Jackson@Example.com",
});
expect(result).not.toEqual({ error: "errors.userNotFound" });
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
});
test("should block login with phone number when disableLoginWithPhone is true", async () => {
const mockUser = {
userId: "user123",
+10 -3
View File
@@ -1,5 +1,6 @@
"use server";
import { equalsIgnoreCase } from "@/lib/auth-utils";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { createLogger } from "@/lib/logger";
import { create } from "@zitadel/client";
@@ -258,15 +259,21 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// recheck login settings after user discovery, as the search might have been done without org scope
if (userLoginSettings?.disableLoginWithEmail && userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== concatLoginname) {
if (!equalsIgnoreCase(user.preferredLoginName, concatLoginname)) {
return preventUserEnumeration(command.organization);
}
} else if (userLoginSettings?.disableLoginWithEmail) {
if (user.preferredLoginName !== concatLoginname && humanUser?.phone?.phone !== command.loginName) {
if (
!equalsIgnoreCase(user.preferredLoginName, concatLoginname) &&
!equalsIgnoreCase(humanUser?.phone?.phone, command.loginName)
) {
return preventUserEnumeration(command.organization);
}
} else if (userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== concatLoginname && humanUser?.email?.email !== command.loginName) {
if (
!equalsIgnoreCase(user.preferredLoginName, concatLoginname) &&
!equalsIgnoreCase(humanUser?.email?.email, command.loginName)
) {
return preventUserEnumeration(command.organization);
}
}
+19 -6
View File
@@ -1,5 +1,6 @@
"use server";
import { equalsIgnoreCase } from "@/lib/auth-utils";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { createLogger } from "@/lib/logger";
import { recordAuthAttempt, recordAuthFailure, recordAuthSuccess } from "@/lib/metrics";
@@ -91,7 +92,7 @@ export async function resetPassword(command: ResetPasswordCommand) {
const userLoginSettings = await getLoginSettings({ serviceConfig, organization: user.details?.resourceOwner });
if (userLoginSettings?.disableLoginWithEmail && userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== command.loginName) {
if (!equalsIgnoreCase(user.preferredLoginName, command.loginName)) {
if (userLoginSettings?.ignoreUnknownUsernames) {
await new Promise((resolve) => setTimeout(resolve, 2000));
return {};
@@ -99,7 +100,10 @@ export async function resetPassword(command: ResetPasswordCommand) {
return { error: t("errors.couldNotSendResetLink") };
}
} else if (userLoginSettings?.disableLoginWithEmail) {
if (user.preferredLoginName !== command.loginName && humanUser?.phone?.phone !== command.loginName) {
if (
!equalsIgnoreCase(user.preferredLoginName, command.loginName) &&
!equalsIgnoreCase(humanUser?.phone?.phone, command.loginName)
) {
if (userLoginSettings?.ignoreUnknownUsernames) {
await new Promise((resolve) => setTimeout(resolve, 2000));
return {};
@@ -107,7 +111,10 @@ export async function resetPassword(command: ResetPasswordCommand) {
return { error: t("errors.couldNotSendResetLink") };
}
} else if (userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== command.loginName && humanUser?.email?.email !== command.loginName) {
if (
!equalsIgnoreCase(user.preferredLoginName, command.loginName) &&
!equalsIgnoreCase(humanUser?.email?.email, command.loginName)
) {
if (userLoginSettings?.ignoreUnknownUsernames) {
await new Promise((resolve) => setTimeout(resolve, 2000));
return {};
@@ -239,7 +246,7 @@ export async function sendPassword(
// recheck login settings after user discovery, as the search might have been done without org scope
if (userLoginSettings?.disableLoginWithEmail && userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== command.loginName) {
if (!equalsIgnoreCase(user.preferredLoginName, command.loginName)) {
// emulate user not found to prevent enumeration (use context settings not user settings)
recordAuthFailure("password", "login_name_mismatch", command.organization);
if (loginSettingsByContext?.ignoreUnknownUsernames) {
@@ -248,7 +255,10 @@ export async function sendPassword(
return { error: t("errors.couldNotVerifyPassword") };
}
} else if (userLoginSettings?.disableLoginWithEmail) {
if (user.preferredLoginName !== command.loginName && humanUser?.phone?.phone !== command.loginName) {
if (
!equalsIgnoreCase(user.preferredLoginName, command.loginName) &&
!equalsIgnoreCase(humanUser?.phone?.phone, command.loginName)
) {
recordAuthFailure("password", "login_name_mismatch", command.organization);
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
@@ -256,7 +266,10 @@ export async function sendPassword(
return { error: t("errors.couldNotVerifyPassword") };
}
} else if (userLoginSettings?.disableLoginWithPhone) {
if (user.preferredLoginName !== command.loginName && humanUser?.email?.email !== command.loginName) {
if (
!equalsIgnoreCase(user.preferredLoginName, command.loginName) &&
!equalsIgnoreCase(humanUser?.email?.email, command.loginName)
) {
recordAuthFailure("password", "login_name_mismatch", command.organization);
if (loginSettingsByContext?.ignoreUnknownUsernames) {
return { error: t("errors.failedToAuthenticateNoLimit") };
+76
View File
@@ -0,0 +1,76 @@
import { TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("./service", () => ({
createServiceForHost: vi.fn(),
}));
vi.mock("next-intl/server", () => ({
getTranslations: vi.fn(() => (key: string) => key),
}));
const serviceConfig = { serviceUrl: "https://api.example.com" } as any;
describe("zitadel queries are case insensitive", () => {
let mockCreateServiceForHost: any;
beforeEach(async () => {
vi.clearAllMocks();
const { createServiceForHost } = await import("./service");
mockCreateServiceForHost = vi.mocked(createServiceForHost);
});
describe("getOrgsByDomain", () => {
test("should look up the organization domain ignoring case", async () => {
// Regression test for: https://github.com/zitadel/zitadel/issues/12025
// Domain discovery for "jackson@Example.com" must find the org owning "example.com".
const listOrganizations = vi.fn().mockResolvedValue({ result: [] });
mockCreateServiceForHost.mockResolvedValue({ listOrganizations });
const { getOrgsByDomain } = await import("./zitadel");
await getOrgsByDomain({ serviceConfig, domain: "Example.com" });
expect(listOrganizations).toHaveBeenCalledWith(
{
queries: [
{
query: {
case: "domainQuery",
value: { domain: "Example.com", method: TextQueryMethod.EQUALS_IGNORE_CASE },
},
},
],
},
{},
);
});
});
describe("listUsers", () => {
test("should search login names ignoring case", async () => {
const listUsersMock = vi.fn().mockResolvedValue({ result: [], details: {} });
mockCreateServiceForHost.mockResolvedValue({ listUsers: listUsersMock });
const { listUsers } = await import("./zitadel");
await listUsers({ serviceConfig, loginName: "Jackson@Example.com" });
const { queries } = listUsersMock.mock.calls[0][0];
expect(queries[0].query.case).toBe("loginNameQuery");
expect(queries[0].query.value.method).toBe(TextQueryMethod.EQUALS_IGNORE_CASE);
});
test("should search user names and emails ignoring case", async () => {
const listUsersMock = vi.fn().mockResolvedValue({ result: [], details: {} });
mockCreateServiceForHost.mockResolvedValue({ listUsers: listUsersMock });
const { listUsers } = await import("./zitadel");
await listUsers({ serviceConfig, userName: "Jackson", email: "Jackson@Example.com" });
const { queries } = listUsersMock.mock.calls[0][0];
const orQueries = queries[0].query.value.queries;
const methods = orQueries.map((q: any) => q.query.value.method);
expect(orQueries.map((q: any) => q.query.case)).toEqual(["userNameQuery", "emailQuery"]);
expect(methods).toEqual([TextQueryMethod.EQUALS_IGNORE_CASE, TextQueryMethod.EQUALS_IGNORE_CASE]);
});
});
});
+8 -4
View File
@@ -610,6 +610,8 @@ const userLookupQuery = { limit: 2 };
export async function listUsers({ serviceConfig, loginName, userName, phone, email, organizationId }: ListUsersCommand) {
const queries: SearchQuery[] = [];
// loginnames, usernames and emails are matched case insensitively, mirroring how
// uniqueness is enforced in the backend and how the loginname page searches users
// either use loginName or userName, email, phone
if (loginName) {
queries.push(
@@ -618,7 +620,7 @@ export async function listUsers({ serviceConfig, loginName, userName, phone, ema
case: "loginNameQuery",
value: {
loginName,
method: TextQueryMethod.EQUALS,
method: TextQueryMethod.EQUALS_IGNORE_CASE,
},
},
}),
@@ -632,7 +634,7 @@ export async function listUsers({ serviceConfig, loginName, userName, phone, ema
case: "userNameQuery",
value: {
userName,
method: TextQueryMethod.EQUALS,
method: TextQueryMethod.EQUALS_IGNORE_CASE,
},
},
});
@@ -645,7 +647,7 @@ export async function listUsers({ serviceConfig, loginName, userName, phone, ema
case: "emailQuery",
value: {
emailAddress: email,
method: TextQueryMethod.EQUALS,
method: TextQueryMethod.EQUALS_IGNORE_CASE,
},
},
});
@@ -896,7 +898,9 @@ export async function getOrgsByDomain({ serviceConfig, domain }: WithServiceConf
{
query: {
case: "domainQuery",
value: { domain, method: TextQueryMethod.EQUALS },
// domains are case insensitive, so a loginname like "jackson@Example.com"
// must still discover the organization owning "example.com"
value: { domain, method: TextQueryMethod.EQUALS_IGNORE_CASE },
},
},
],