diff --git a/apps/login/src/lib/server/loginname.test.ts b/apps/login/src/lib/server/loginname.test.ts index cfd4dd1ee..a4281847c 100644 --- a/apps/login/src/lib/server/loginname.test.ts +++ b/apps/login/src/lib/server/loginname.test.ts @@ -546,6 +546,81 @@ describe("sendLoginname", () => { expect(result?.redirect).toContain("organization=org123"); }); + test("should redirect to IDP via domain discovery for IdP-only org even when ignoreUnknownUsernames is true", async () => { + // Regression test: enumeration protection must not divert unknown users of + // an IdP-only org (allowLocalAuthentication: false) to the decoy password + // screen — known users of that org are auto-redirected to the IdP, so the + // redirect is the indistinguishable (and functional) behavior. + mockGetLoginSettings + .mockResolvedValueOnce({ + // context (instance default) settings + ignoreUnknownUsernames: true, + allowLocalAuthentication: true, + }) + .mockResolvedValueOnce({ + // discovered org settings: IdP-only + allowDomainDiscovery: true, + allowRegister: false, + allowLocalAuthentication: false, + ignoreUnknownUsernames: true, + }); + + mockGetOrgsByDomain.mockResolvedValue({ + result: [{ id: "discovered-org-sso", name: "Enterprise Org" }], + }); + + mockGetActiveIdentityProviders.mockResolvedValue({ + identityProviders: [{ id: "idp-entra", type: "OIDC", options: { isCreationAllowed: true } }], + }); + mockStartIdentityProviderFlow.mockResolvedValue({ url: "https://entra.example.com/auth" }); + + const result = await sendLoginname({ + loginName: "newuser@enterprise.com", + requestId: "req123", + }); + + expect(result).toEqual({ redirect: "https://entra.example.com/auth" }); + + expect(mockGetActiveIdentityProviders).toHaveBeenCalledWith({ + serviceConfig: { baseUrl: "https://api.example.com" }, + orgId: "discovered-org-sso", + }); + }); + + test("should keep decoy password screen when ignoreUnknownUsernames is true and discovered org allows local authentication", async () => { + // With local authentication enabled, known (password) users see the + // password screen, so the decoy is what keeps unknown users + // indistinguishable — the discovery-based IdP redirect stays gated. + mockGetLoginSettings + .mockResolvedValueOnce({ + // context (instance default) settings + ignoreUnknownUsernames: true, + allowLocalAuthentication: true, + }) + .mockResolvedValueOnce({ + // discovered org settings: local auth enabled + allowDomainDiscovery: true, + allowRegister: false, + allowLocalAuthentication: true, + ignoreUnknownUsernames: true, + }); + + mockGetOrgsByDomain.mockResolvedValue({ + result: [{ id: "discovered-org-local", name: "Password Org" }], + }); + + const result = await sendLoginname({ + loginName: "newuser@company.com", + requestId: "req123", + }); + + expect(result).toBeDefined(); + expect(result?.redirect).toMatch(/^\/password\?/); + expect(result?.redirect).toContain("loginName=newuser%40company.com"); + expect(result?.redirect).toContain("organization=discovered-org-local"); + expect(mockStartIdentityProviderFlow).not.toHaveBeenCalled(); + }); + test("should return error when user not found and no registration allowed", async () => { mockGetLoginSettings.mockResolvedValue({ allowRegister: false, diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index 0968cc363..1ece81a02 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -562,8 +562,19 @@ export async function sendLoginname(command: SendLoginnameCommand) { // and must not prevent authentication via an external IdP. // Fixes: https://github.com/zitadel/zitadel/issues/12021 // Fixes: https://github.com/zitadel/zitadel/issues/12023 + // + // Enumeration protection (ignoreUnknownUsernames) must NOT gate the redirect + // when local authentication is disabled: in an IdP-only org, known users are + // auto-redirected to the IdP, so sending unknown usernames to the very same + // IdP is what keeps them indistinguishable. Diverting unknown users to the + // decoy password screen instead would both break silent SSO for first-time + // users (the IdP is meant to create the account on first login) and act as an + // enumeration oracle (redirect = user exists, password screen = unknown). + // Conversely, when local authentication is enabled, the decoy password screen + // is what known (password) users see, so enumeration protection keeps gating + // the discovery-based redirect there. - if ((!effectiveLoginSettings?.allowLocalAuthentication || discoveredOrganization) && !ignoreUnknownUsernames) { + if (!effectiveLoginSettings?.allowLocalAuthentication || (discoveredOrganization && !ignoreUnknownUsernames)) { const resp = await redirectUserToIDP(undefined, discoveredOrganization); if (resp) { logger.debug("Redirecting to IDP", { organization: discoveredOrganization });