diff --git a/apps/login/src/lib/server/loginname.test.ts b/apps/login/src/lib/server/loginname.test.ts index bd3e81441..5fcab5122 100644 --- a/apps/login/src/lib/server/loginname.test.ts +++ b/apps/login/src/lib/server/loginname.test.ts @@ -62,6 +62,7 @@ describe("sendLoginname", () => { let mockGetActiveIdentityProviders: any; let mockGetIDPByID: any; let mockIdpTypeToSlug: any; + let mockGetOrgsByDomain: any; beforeEach(async () => { vi.clearAllMocks(); @@ -77,6 +78,7 @@ describe("sendLoginname", () => { listIDPLinks, startIdentityProviderFlow, getActiveIdentityProviders, + getOrgsByDomain, } = await import("../zitadel"); const { createSessionAndUpdateCookie } = await import("./cookie"); const { getOriginalHost } = await import("./host"); @@ -96,6 +98,7 @@ describe("sendLoginname", () => { mockGetActiveIdentityProviders = vi.mocked(getActiveIdentityProviders); mockGetIDPByID = vi.mocked(getIDPByID); mockIdpTypeToSlug = vi.mocked(idpTypeToSlug); + mockGetOrgsByDomain = vi.mocked(getOrgsByDomain); // Default mock implementations mockHeaders.mockResolvedValue({} as any); @@ -107,6 +110,8 @@ describe("sendLoginname", () => { name: "Google", type: "GOOGLE", }); + // Default: org discovery returns empty result + mockGetOrgsByDomain.mockResolvedValue({ result: [] }); }); afterEach(() => { @@ -482,6 +487,161 @@ describe("sendLoginname", () => { expect(result).toEqual({ error: "errors.userNotFound" }); }); + + test("should discover organization from domain suffix when user not found without org context", async () => { + // Mock login settings for instance level (no org context) + mockGetLoginSettings + .mockResolvedValueOnce({ + allowRegister: true, + allowUsernamePassword: true, + ignoreUnknownUsernames: false, + }) + // Mock login settings for discovered org - must include all necessary flags + .mockResolvedValueOnce({ + allowDomainDiscovery: true, + allowRegister: true, + allowUsernamePassword: true, + ignoreUnknownUsernames: false, + }); + + // Mock org discovery to return one org with matching domain + mockGetOrgsByDomain.mockResolvedValue({ + result: [{ id: "discovered-org-123", name: "Example Org" }], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + requestId: "req123", + // No organization parameter - this is the key test scenario + }); + + expect(result).toBeDefined(); + expect(result?.redirect).toMatch(/^\/register\?/); + expect(result?.redirect).toContain("organization=discovered-org-123"); + expect(result?.redirect).toContain("requestId=req123"); + expect(result?.redirect).toContain("email=user%40example.com"); + + // Verify org discovery was called with correct domain + expect(mockGetOrgsByDomain).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + domain: "example.com", + }); + }); + + test("should redirect to IDP with discovered org when user not found and only IDP allowed", async () => { + // Mock login settings for instance level (no org context) + mockGetLoginSettings + .mockResolvedValueOnce({ + allowRegister: true, + allowUsernamePassword: false, + }) + // Mock login settings for discovered org - must include all necessary flags + .mockResolvedValueOnce({ + allowDomainDiscovery: true, + allowRegister: true, + allowUsernamePassword: false, + }); + + // Mock org discovery to return one org with matching domain + mockGetOrgsByDomain.mockResolvedValue({ + result: [{ id: "discovered-org-456", name: "Example Org" }], + }); + + mockGetActiveIdentityProviders.mockResolvedValue({ + identityProviders: [{ id: "idp123", type: "OIDC" }], + }); + mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth?org=discovered-org-456"); + + const result = await sendLoginname({ + loginName: "user@company.com", + requestId: "req123", + // No organization parameter + }); + + expect(result).toEqual({ redirect: "https://idp.example.com/auth?org=discovered-org-456" }); + + // Verify org discovery was called + expect(mockGetOrgsByDomain).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + domain: "company.com", + }); + + // Verify IDP redirect was called with discovered org + expect(mockGetActiveIdentityProviders).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + orgId: "discovered-org-456", + }); + }); + + test("should not discover org if domain discovery is disabled", async () => { + mockGetLoginSettings + .mockResolvedValueOnce({ + allowRegister: true, + allowUsernamePassword: true, + ignoreUnknownUsernames: false, + }) + // Mock login settings for org with domain discovery disabled + .mockResolvedValueOnce({ + allowDomainDiscovery: false, + }); + + mockGetOrgsByDomain.mockResolvedValue({ + result: [{ id: "10987654321", name: "Example Org" }], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + // No organization parameter + }); + + // Should return error since discovery is disabled and no org context + expect(result).toEqual({ error: "errors.userNotFound" }); + }); + + test("should not discover org if multiple orgs match the domain", async () => { + mockGetLoginSettings.mockResolvedValue({ + allowRegister: true, + allowUsernamePassword: true, + ignoreUnknownUsernames: false, + }); + + // Mock org discovery to return multiple orgs + mockGetOrgsByDomain.mockResolvedValue({ + result: [ + { id: "12345678910", name: "Example Org 1" }, + { id: "10987654321", name: "Example Org 2" }, + ], + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + // No organization parameter + }); + + // Should return error since multiple orgs match + expect(result).toEqual({ error: "errors.userNotFound" }); + }); + + test("should use provided organization instead of discovering when org context exists", async () => { + mockGetLoginSettings.mockResolvedValue({ + allowRegister: true, + allowUsernamePassword: true, + ignoreUnknownUsernames: false, + }); + + const result = await sendLoginname({ + loginName: "user@example.com", + organization: "123456", + requestId: "req123", + }); + + expect(result).toBeDefined(); + expect(result?.redirect).toMatch(/^\/register\?/); + expect(result?.redirect).toContain("organization=123456"); + + // Verify org discovery was NOT called since org was provided + expect(mockGetOrgsByDomain).not.toHaveBeenCalled(); + }); }); describe("Edge cases", () => { diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index 562d4cc07..e09dd34b1 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -59,16 +59,31 @@ export async function sendLoginname(command: SendLoginnameCommand) { const searchResult = await searchUsers(searchUsersRequest); + // Safety check: ensure searchResult is defined + if (!searchResult) { + console.error("searchUsers returned undefined or null"); + return { error: t("errors.couldNotSearchUsers") }; + } + if ("error" in searchResult && searchResult.error) { + console.log("searchUsers returned error, returning early:", searchResult.error); return searchResult; } if (!("result" in searchResult)) { + console.log("searchUsers has no result field"); return { error: t("errors.couldNotSearchUsers") }; } const { result: potentialUsers } = searchResult; + // Additional safety check: treat undefined result as empty array + const users = potentialUsers ?? []; + + if (users.length === 0) { + console.log("No users found, will proceed with org discovery"); + } + const redirectUserToIDP = async (userId?: string, organization?: string) => { // If userId is provided, check for user-specific IDP links first let identityProviders: IDPLink[] = []; @@ -194,11 +209,12 @@ export async function sendLoginname(command: SendLoginnameCommand) { } }; - if (potentialUsers.length > 1) { + if (users.length > 1) { + console.log("multiple users found, returning error"); return { error: t("errors.moreThanOneUserFound") }; - } else if (potentialUsers.length == 1 && potentialUsers[0].userId) { - const user = potentialUsers[0]; - const userId = potentialUsers[0].userId; + } else if (users.length == 1 && users[0].userId) { + const user = users[0]; + const userId = users[0].userId; const userLoginSettings = await getLoginSettings({ serviceUrl, @@ -208,7 +224,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { // compare with the concatenated suffix when set const concatLoginname = command.suffix ? `${command.loginName}@${command.suffix}` : command.loginName; - const humanUser = potentialUsers[0].type.case === "human" ? potentialUsers[0].type.value : undefined; + const humanUser = users[0].type.case === "human" ? users[0].type.value : undefined; // recheck login settings after user discovery, as the search might have been done without org scope if (userLoginSettings?.disableLoginWithEmail && userLoginSettings?.disableLoginWithPhone) { @@ -391,44 +407,58 @@ export async function sendLoginname(command: SendLoginnameCommand) { } } - // user not found, check if register is enabled on instance / organization context - if (loginSettingsByContext?.allowRegister && !loginSettingsByContext?.allowUsernamePassword) { - const resp = await redirectUserToIDP(undefined, command.organization); - if (resp) { - return resp; - } - return { error: t("errors.userNotFound") }; - } else if (loginSettingsByContext?.allowRegister && loginSettingsByContext?.allowUsernamePassword) { - let orgToRegisterOn: string | undefined = command.organization; + console.log("user not found (0 potential users), checking registration options"); - if ( - !loginSettingsByContext?.ignoreUnknownUsernames && - !orgToRegisterOn && - command.loginName && - ORG_SUFFIX_REGEX.test(command.loginName) - ) { - const matched = ORG_SUFFIX_REGEX.exec(command.loginName); - const suffix = matched?.[1] ?? ""; + // user not found, perform organization discovery if no org context provided + let discoveredOrganization = command.organization; + let effectiveLoginSettings = loginSettingsByContext; - // this just returns orgs where the suffix is set as primary domain - const orgs = await getOrgsByDomain({ - serviceUrl, - domain: suffix, - }); - const orgToCheckForDiscovery = orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; + if (!discoveredOrganization && command.loginName && ORG_SUFFIX_REGEX.test(command.loginName)) { + const matched = ORG_SUFFIX_REGEX.exec(command.loginName); + const suffix = matched?.[1] ?? ""; + // this just returns orgs where the suffix is set as primary domain + const orgs = await getOrgsByDomain({ + serviceUrl, + domain: suffix, + }); + + const orgToCheckForDiscovery = orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; + + if (orgToCheckForDiscovery) { const orgLoginSettings = await getLoginSettings({ serviceUrl, organization: orgToCheckForDiscovery, }); - if (orgLoginSettings?.allowDomainDiscovery) { - orgToRegisterOn = orgToCheckForDiscovery; - } - } + if (orgLoginSettings?.allowDomainDiscovery) { + console.log("org discovery successful, using org:", orgToCheckForDiscovery); + discoveredOrganization = orgToCheckForDiscovery; + // Use the discovered organization's login settings for subsequent checks + effectiveLoginSettings = orgLoginSettings; + } else { + console.log("org does not allow domain discovery"); + } + } else { + console.log("no single org found for discovery"); + } + } + + // user not found, check if register is enabled on instance / organization context + if (effectiveLoginSettings?.allowRegister && !effectiveLoginSettings?.allowUsernamePassword) { + console.log("redirecting to IDP (register allowed, password not allowed)"); + const resp = await redirectUserToIDP(undefined, discoveredOrganization); + if (resp) { + return resp; + } + console.log("IDP redirect failed, returning user not found"); + return { error: t("errors.userNotFound") }; + } else if (effectiveLoginSettings?.allowRegister && effectiveLoginSettings?.allowUsernamePassword) { + console.log("register and password both allowed"); // do not register user if ignoreUnknownUsernames is set - if (orgToRegisterOn && !loginSettingsByContext?.ignoreUnknownUsernames) { - const params = new URLSearchParams({ organization: orgToRegisterOn }); + if (discoveredOrganization && !effectiveLoginSettings?.ignoreUnknownUsernames) { + console.log("redirecting to registration page with org:", discoveredOrganization); + const params = new URLSearchParams({ organization: discoveredOrganization }); if (command.requestId) { params.set("requestId", command.requestId); @@ -439,10 +469,16 @@ export async function sendLoginname(command: SendLoginnameCommand) { } return { redirect: "/register?" + params }; + } else { + console.log("not redirecting to register:", { + hasDiscoveredOrg: !!discoveredOrganization, + ignoreUnknownUsernames: effectiveLoginSettings?.ignoreUnknownUsernames, + }); } } - if (loginSettingsByContext?.ignoreUnknownUsernames) { + if (effectiveLoginSettings?.ignoreUnknownUsernames) { + console.log("ignoreUnknownUsernames is true, redirecting to password"); const paramsPasswordDefault = new URLSearchParams({ loginName: command.loginName, }); @@ -451,14 +487,13 @@ export async function sendLoginname(command: SendLoginnameCommand) { paramsPasswordDefault.append("requestId", command.requestId); } - if (command.organization) { - paramsPasswordDefault.append("organization", command.organization); + if (discoveredOrganization) { + paramsPasswordDefault.append("organization", discoveredOrganization); } return { redirect: "/password?" + paramsPasswordDefault }; } - // fallbackToPassword - + console.log("no valid registration option found, returning user not found"); return { error: t("errors.userNotFound") }; } diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 929968d34..8185f49c9 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -658,7 +658,8 @@ export async function searchUsers({ serviceUrl, searchValue, loginSettings, orga const emailAndPhoneQueries: SearchQuery[] = []; if (loginSettings.disableLoginWithEmail && loginSettings.disableLoginWithPhone) { - return { error: t("errors.userNotFound") }; + // Both email and phone login are disabled, return empty result + return { result: [] }; } else if (loginSettings.disableLoginWithEmail && searchValue.length <= 20) { const phoneQuery = PhoneQuery(searchValue); emailAndPhoneQueries.push(phoneQuery); @@ -718,7 +719,8 @@ export async function searchUsers({ serviceUrl, searchValue, loginSettings, orga return emailOrPhoneResult; } - return { error: t("errors.userNotFound") }; + // No users found - return empty result, not an error + return { result: [] }; } export async function getDefaultOrg({ serviceUrl }: { serviceUrl: string }): Promise {