diff --git a/apps/login/src/lib/server/loginname.test.ts b/apps/login/src/lib/server/loginname.test.ts index 9411ffa2c..b401cc713 100644 --- a/apps/login/src/lib/server/loginname.test.ts +++ b/apps/login/src/lib/server/loginname.test.ts @@ -731,6 +731,75 @@ describe("sendLoginname", () => { expect((result as any).redirect).toMatch(/^\/password\?/); }); + test("should allow login with email when disableLoginWithPhone is true and preferred login name differs from email", async () => { + // Regression test for: https://github.com/zitadel/zitadel/issues/11518 + // When preferredLoginName (e.g. username@org-domain) differs from the user's email, logging + // in with the email while disableLoginWithPhone=true must NOT be blocked. + const mockUser = { + userId: "user123", + preferredLoginName: "user@orgdomain.com", // org-domain scoped login name + details: { resourceOwner: "org123" }, + type: { + case: "human", + value: { email: { email: "user@test.com" }, phone: { phone: "+1234567890" } }, + }, + state: UserState.ACTIVE, + }; + + const mockSession = { + factors: { + user: { + id: "user123", + loginName: "user@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: "user@test.com", // logging in with email, not phone + }); + + // Must NOT return "User not found" — email-based login must be allowed when only phone is disabled + 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", + preferredLoginName: "user@orgdomain.com", + details: { resourceOwner: "org123" }, + type: { + case: "human", + value: { email: { email: "user@example.com" }, phone: { phone: "+1234567890" } }, + }, + state: UserState.ACTIVE, + }; + + mockSearchUsers.mockResolvedValue({ result: [mockUser] }); + mockGetLoginSettings.mockResolvedValue({ + disableLoginWithPhone: true, + }); + + const result = await sendLoginname({ + loginName: "+1234567890", // logging in with phone — must be blocked + }); + + expect(result).toEqual({ error: "errors.userNotFound" }); + expect(mockCreateSessionAndUpdateCookie).not.toHaveBeenCalled(); + }); + test("should redirect to password when ignoreUnknownUsernames is true and more than one user found", async () => { mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true, diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index d29a2ce72..803226ae1 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -250,11 +250,11 @@ export async function sendLoginname(command: SendLoginnameCommand) { return preventUserEnumeration(command.organization); } } else if (userLoginSettings?.disableLoginWithEmail) { - if (user.preferredLoginName !== concatLoginname || humanUser?.phone?.phone !== command.loginName) { + if (user.preferredLoginName !== concatLoginname && humanUser?.phone?.phone !== command.loginName) { return preventUserEnumeration(command.organization); } } else if (userLoginSettings?.disableLoginWithPhone) { - if (user.preferredLoginName !== concatLoginname || humanUser?.email?.email !== command.loginName) { + if (user.preferredLoginName !== concatLoginname && humanUser?.email?.email !== command.loginName) { return preventUserEnumeration(command.organization); } } diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index ed8e11027..8fc4fee23 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -90,7 +90,7 @@ 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 (user.preferredLoginName !== command.loginName && humanUser?.phone?.phone !== command.loginName) { if (userLoginSettings?.ignoreUnknownUsernames) { await new Promise((resolve) => setTimeout(resolve, 2000)); return {}; @@ -98,7 +98,7 @@ 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 (user.preferredLoginName !== command.loginName && humanUser?.email?.email !== command.loginName) { if (userLoginSettings?.ignoreUnknownUsernames) { await new Promise((resolve) => setTimeout(resolve, 2000)); return {}; @@ -221,14 +221,14 @@ export async function sendPassword( return { error: t("errors.couldNotVerifyPassword") }; } } else if (userLoginSettings?.disableLoginWithEmail) { - if (user.preferredLoginName !== command.loginName || humanUser?.phone?.phone !== command.loginName) { + if (user.preferredLoginName !== command.loginName && humanUser?.phone?.phone !== command.loginName) { if (loginSettingsByContext?.ignoreUnknownUsernames) { return { error: t("errors.failedToAuthenticateNoLimit") }; } return { error: t("errors.couldNotVerifyPassword") }; } } else if (userLoginSettings?.disableLoginWithPhone) { - if (user.preferredLoginName !== command.loginName || humanUser?.email?.email !== command.loginName) { + if (user.preferredLoginName !== command.loginName && humanUser?.email?.email !== command.loginName) { if (loginSettingsByContext?.ignoreUnknownUsernames) { return { error: t("errors.failedToAuthenticateNoLimit") }; }