diff --git a/apps/login/src/lib/server/loginname.test.ts b/apps/login/src/lib/server/loginname.test.ts new file mode 100644 index 000000000..a394e4805 --- /dev/null +++ b/apps/login/src/lib/server/loginname.test.ts @@ -0,0 +1,548 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; +import { sendLoginname } from "./loginname"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getIDPByID } from "../zitadel"; + +// Mock all the dependencies +vi.mock("next/headers", () => ({ + headers: vi.fn(), +})); + +vi.mock("@zitadel/client", () => ({ + create: vi.fn(), +})); + +vi.mock("../service-url", () => ({ + getServiceUrlFromHeaders: vi.fn(), +})); + +vi.mock("../idp", () => ({ + idpTypeToIdentityProviderType: vi.fn(), + idpTypeToSlug: vi.fn(), +})); + +vi.mock("../zitadel", () => ({ + getActiveIdentityProviders: vi.fn(), + getIDPByID: vi.fn(), + getLoginSettings: vi.fn(), + getOrgsByDomain: vi.fn(), + listAuthenticationMethodTypes: vi.fn(), + listIDPLinks: vi.fn(), + searchUsers: vi.fn(), + startIdentityProviderFlow: vi.fn(), +})); + +vi.mock("./cookie", () => ({ + createSessionAndUpdateCookie: vi.fn(), +})); + +vi.mock("./host", () => ({ + getOriginalHost: vi.fn(), +})); + +describe("sendLoginname", () => { + // Mock modules + let mockHeaders: any; + let mockCreate: any; + let mockGetServiceUrlFromHeaders: any; + let mockGetLoginSettings: any; + let mockSearchUsers: any; + let mockCreateSessionAndUpdateCookie: any; + let mockListAuthenticationMethodTypes: any; + let mockListIDPLinks: any; + let mockGetOriginalHost: any; + let mockStartIdentityProviderFlow: any; + let mockGetActiveIdentityProviders: any; + let mockGetIDPByID: any; + let mockIdpTypeToSlug: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Import mocked modules + const { headers } = await import("next/headers"); + const { create } = await import("@zitadel/client"); + const { getServiceUrlFromHeaders } = await import("../service-url"); + const { + getLoginSettings, + searchUsers, + listAuthenticationMethodTypes, + listIDPLinks, + startIdentityProviderFlow, + getActiveIdentityProviders, + } = await import("../zitadel"); + const { createSessionAndUpdateCookie } = await import("./cookie"); + const { getOriginalHost } = await import("./host"); + const { idpTypeToSlug } = await import("../idp"); + + // Setup mocks + mockHeaders = vi.mocked(headers); + mockCreate = vi.mocked(create); + mockGetServiceUrlFromHeaders = vi.mocked(getServiceUrlFromHeaders); + mockGetLoginSettings = vi.mocked(getLoginSettings); + mockSearchUsers = vi.mocked(searchUsers); + mockCreateSessionAndUpdateCookie = vi.mocked(createSessionAndUpdateCookie); + mockListAuthenticationMethodTypes = vi.mocked(listAuthenticationMethodTypes); + mockListIDPLinks = vi.mocked(listIDPLinks); + mockGetOriginalHost = vi.mocked(getOriginalHost); + mockStartIdentityProviderFlow = vi.mocked(startIdentityProviderFlow); + mockGetActiveIdentityProviders = vi.mocked(getActiveIdentityProviders); + mockGetIDPByID = vi.mocked(getIDPByID); + mockIdpTypeToSlug = vi.mocked(idpTypeToSlug); + + // Default mock implementations + mockHeaders.mockResolvedValue({} as any); + mockGetServiceUrlFromHeaders.mockReturnValue({ serviceUrl: "https://api.example.com" }); + mockGetOriginalHost.mockResolvedValue("example.com"); + mockIdpTypeToSlug.mockReturnValue("google"); + mockGetIDPByID.mockResolvedValue({ + id: "idp123", + name: "Google", + type: "GOOGLE", + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Error cases", () => { + test("should return error when login settings cannot be retrieved", async () => { + mockGetLoginSettings.mockResolvedValue(null); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "Could not get login settings" }); + }); + + test("should return error when user search fails", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({ error: "Search failed" }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "Search failed" }); + }); + + test("should return error when search result has no result field", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({}); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "Could not search users" }); + }); + + test("should return error when more than one user found", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({ + result: [ + { userId: "user1", preferredLoginName: "user1@example.com" }, + { userId: "user2", preferredLoginName: "user2@example.com" }, + ], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "More than one user found. Provide a unique identifier." }); + }); + }); + + describe("Single user found - authentication method handling", () => { + const mockUser = { + userId: "user123", + preferredLoginName: "user@example.com", + details: { resourceOwner: "org123" }, + type: { case: "human", value: { email: { email: "user@example.com" } } }, + state: UserState.ACTIVE, + }; + + const mockSession = { + factors: { + user: { + id: "user123", + loginName: "user@example.com", + organizationId: "org123", + }, + }, + }; + + beforeEach(() => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({ result: [mockUser] }); + mockCreate.mockReturnValue({}); + mockCreateSessionAndUpdateCookie.mockResolvedValue(mockSession); + }); + + test("should redirect to verify when user has no authentication methods", async () => { + mockListAuthenticationMethodTypes.mockResolvedValue({ authMethodTypes: [] }); + + const result = await sendLoginname({ + loginName: "user@example.com", + requestId: "req123", + }); + + expect(result).toHaveProperty("redirect"); + expect((result as any).redirect).toMatch(/^\/verify\?/); + expect((result as any).redirect).toContain("loginName=user%40example.com"); + expect((result as any).redirect).toContain("send=true"); + expect((result as any).redirect).toContain("invite=true"); + expect((result as any).redirect).toContain("requestId=req123"); + }); + + describe("Single authentication method", () => { + test("should redirect to password when user has only password method and it's allowed", async () => { + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + requestId: "req123", + }); + + expect(result).toHaveProperty("redirect"); + expect((result as any).redirect).toMatch(/^\/password\?/); + expect((result as any).redirect).toContain("loginName=user%40example.com"); + expect((result as any).redirect).toContain("requestId=req123"); + }); + + test("should attempt IDP redirect when password is not allowed but user has IDP links", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: false }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + mockListIDPLinks.mockResolvedValue({ + result: [{ idpId: "idp123" }], + }); + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ redirect: "https://idp.example.com/auth" }); + expect(mockListIDPLinks).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + userId: "user123", + }); + }); + + test("should return error when password not allowed and no IDP links available", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: false }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + mockListIDPLinks.mockResolvedValue({ result: [] }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ + error: "Username Password not allowed! Contact your administrator for more information.", + }); + }); + + test("should redirect to passkey when user has only passkey method and it's allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ passkeysType: PasskeysType.ALLOWED }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSKEY], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + requestId: "req123", + }); + + expect(result).toHaveProperty("redirect"); + expect((result as any).redirect).toMatch(/^\/passkey\?/); + expect((result as any).redirect).toContain("loginName=user%40example.com"); + expect((result as any).redirect).toContain("requestId=req123"); + }); + + test("should return error when passkeys are not allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ passkeysType: PasskeysType.NOT_ALLOWED }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSKEY], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ + error: "Passkeys not allowed! Contact your administrator for more information.", + }); + }); + + test("should redirect to IDP when user has only IDP method", async () => { + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.IDP], + }); + mockListIDPLinks.mockResolvedValue({ + result: [{ idpId: "idp123" }], + }); + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ redirect: "https://idp.example.com/auth" }); + }); + }); + + describe("Multiple authentication methods", () => { + test("should prefer passkey when multiple methods available", async () => { + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.PASSKEY], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toHaveProperty("redirect"); + expect((result as any).redirect).toMatch(/^\/passkey\?/); + expect((result as any).redirect).toContain("altPassword=true"); // password is allowed + }); + + test("should not show password alternative when password is not allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: false }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.PASSKEY], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toBeDefined(); + expect(result?.redirect).toMatch(/^\/passkey\?/); + expect(result?.redirect).toContain("altPassword=false"); // password is not allowed + }); + + test("should redirect to IDP when no passkey but IDP available", async () => { + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD, AuthenticationMethodType.IDP], + }); + mockListIDPLinks.mockResolvedValue({ + result: [{ idpId: "idp123" }], + }); + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ redirect: "https://idp.example.com/auth" }); + }); + + test("should redirect to password when no passkey or IDP, only password available and allowed", async () => { + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toBeDefined(); + expect(result?.redirect).toMatch(/^\/password\?/); + }); + + test("should return error when password is only method in multi-method scenario but not allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: false }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + mockListIDPLinks.mockResolvedValue({ result: [] }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ + error: "Username Password not allowed! Contact your administrator for more information.", + }); + }); + }); + }); + + describe("User not found scenarios", () => { + beforeEach(() => { + mockSearchUsers.mockResolvedValue({ result: [] }); + }); + + test("should redirect to single IDP when register allowed but password not allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ + allowRegister: true, + allowUsernamePassword: false, + }); + mockGetActiveIdentityProviders.mockResolvedValue({ + identityProviders: [{ id: "idp123", type: "OIDC" }], + }); + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth"); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ redirect: "https://idp.example.com/auth" }); + }); + + test("should redirect to register when both register and password allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ + allowRegister: true, + allowUsernamePassword: true, + ignoreUnknownUsernames: false, + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + organization: "org123", + requestId: "req123", + }); + + expect(result).toBeDefined(); + expect(result?.redirect).toMatch(/^\/register\?/); + expect(result?.redirect).toContain("organization=org123"); + expect(result?.redirect).toContain("requestId=req123"); + expect(result?.redirect).toContain("email=user%40example.com"); + }); + + test("should redirect to password when ignoreUnknownUsernames is true", async () => { + mockGetLoginSettings.mockResolvedValue({ + ignoreUnknownUsernames: true, + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + requestId: "req123", + organization: "org123", + }); + + expect(result).toBeDefined(); + expect(result?.redirect).toMatch(/^\/password\?/); + expect(result?.redirect).toContain("loginName=user%40example.com"); + expect(result?.redirect).toContain("requestId=req123"); + expect(result?.redirect).toContain("organization=org123"); + }); + + test("should return error when user not found and no registration allowed", async () => { + mockGetLoginSettings.mockResolvedValue({ + allowRegister: false, + allowUsernamePassword: true, + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "User not found in the system" }); + }); + }); + + describe("Edge cases", () => { + test("should handle session creation failure", async () => { + const mockUser = { + userId: "user123", + preferredLoginName: "user@example.com", + details: { resourceOwner: "org123" }, + type: { case: "human", value: { email: { email: "user@example.com" } } }, + state: UserState.ACTIVE, + }; + + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({ result: [mockUser] }); + mockCreate.mockReturnValue({}); + mockCreateSessionAndUpdateCookie.mockResolvedValue({ factors: {} }); // No user in session + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "Could not create session for user" }); + }); + + test("should handle initial user state", async () => { + const mockUser = { + userId: "user123", + preferredLoginName: "user@example.com", + details: { resourceOwner: "org123" }, + type: { case: "human", value: { email: { email: "user@example.com" } } }, + state: UserState.INITIAL, + }; + + const mockSession = { + factors: { + user: { + id: "user123", + loginName: "user@example.com", + organizationId: "org123", + }, + }, + }; + + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({ result: [mockUser] }); + mockCreate.mockReturnValue({}); + mockCreateSessionAndUpdateCookie.mockResolvedValue(mockSession); + + const result = await sendLoginname({ + loginName: "user@example.com", + }); + + expect(result).toEqual({ error: "Initial User not supported" }); + }); + + test("should handle organization parameter in all redirects", async () => { + const mockUser = { + userId: "user123", + preferredLoginName: "user@example.com", + details: { resourceOwner: "org123" }, + type: { case: "human", value: { email: { email: "user@example.com" } } }, + state: UserState.ACTIVE, + }; + + const mockSession = { + factors: { + user: { + id: "user123", + loginName: "user@example.com", + organizationId: "org123", + }, + }, + }; + + mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: true }); + mockSearchUsers.mockResolvedValue({ result: [mockUser] }); + mockCreate.mockReturnValue({}); + mockCreateSessionAndUpdateCookie.mockResolvedValue(mockSession); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [AuthenticationMethodType.PASSWORD], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + organization: "custom-org", + requestId: "req123", + }); + + expect(result).toBeDefined(); + expect(result?.redirect).toContain("organization=custom-org"); + expect(result?.redirect).toContain("requestId=req123"); + }); + }); +}); diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index c69589212..9f243a86d 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -255,6 +255,12 @@ export async function sendLoginname(command: SendLoginnameCommand) { switch (method) { case AuthenticationMethodType.PASSWORD: // user has only password as auth method if (!userLoginSettings?.allowUsernamePassword) { + // Check if user has IDPs available as alternative, that could eventually be used to register/link. + const idpResp = await redirectUserToIDP(userId); + if (idpResp?.redirect) { + return idpResp; + } + return { error: "Username Password not allowed! Contact your administrator for more information.", }; @@ -312,7 +318,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSKEY)) { const passkeyParams = new URLSearchParams({ loginName: session.factors?.user?.loginName, - altPassword: `${methods.authMethodTypes.includes(1)}`, // show alternative password option + altPassword: `${methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD) && userLoginSettings?.allowUsernamePassword}`, // show alternative password option only if allowed }); if (command.requestId) { @@ -327,7 +333,14 @@ export async function sendLoginname(command: SendLoginnameCommand) { } else if (methods.authMethodTypes.includes(AuthenticationMethodType.IDP)) { return redirectUserToIDP(userId); } else if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)) { - // user has no passkey setup and login settings allow passkeys + // Check if password authentication is allowed + if (!userLoginSettings?.allowUsernamePassword) { + return { + error: "Username Password not allowed! Contact your administrator for more information.", + }; + } + + // user has no passkey setup and login settings allow passwords const paramsPasswordDefault = new URLSearchParams({ loginName: session.factors?.user?.loginName, });