From 464d1cb655bbe313119c58be947732c32409ba8a Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 4 Aug 2026 14:49:28 +0200 Subject: [PATCH] fix(login): resolve unknownContext error on password page caused by inconsistent ignoreUnknownUsernames handling (#12512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved The `/password` page (and related password pages) can show the `unknownContext` error even though the user was resolved correctly and login completes fine. Two causes: 1. `sendLoginname` used two different `ignoreUnknownUsernames` sources: session creation was gated on the **user's org** login settings, while the redirect's `loginName` masking and enumeration guards were gated on a **caller-supplied flag** reflecting the request context (instance or org scope). Whenever the two diverge — e.g. `ignoreUnknownUsernames` enabled on the instance but not on the user's org, or the reverse — the redirect carries a `loginName` that doesn't match the session cookie (or no session is created at all), so the `/password` page can't load the session and renders `unknownContext`, while password submission still succeeds via its user-search fallback. This affects every caller of `sendLoginname` — the manual `/loginname` form as well as the server-side `login_hint` resolution; it was most reproducible with `login_hint`, since that path resolves context settings at the instance level, maximizing the chance of divergence with an org-level policy. 2. The password pages tried to re-derive "was enumeration protection applied?" from login settings to suppress the error — but they resolve settings from a different context than `sendLoginname` (falling back to the default organization instead of the instance), so any org-level custom policy made the suppression misfire. More fundamentally, the alert was keyed on a failed session lookup, which is not an error state: under enumeration protection no session exists by design, and the forms recover without one (`sendPassword` falls back to user search; `/password/set` works via code + userId). # How the Problems Are Solved `sendLoginname` no longer accepts `ignoreUnknownUsernames` from the caller. It derives a single constant server-side from the request-context login settings it already fetches and uses it for every decision in the flow: session creation, `preventUserEnumeration`, the multiple-users branch, the INITIAL-user check, the organization reset, the auth-method guards, and the IdP redirect after domain discovery. Redirect params use `session?.factors?.user?.loginName ?? command.loginName`: when a session exists, its loginName goes in the URL so the next page always finds the session cookie; when protection applies, no session (and no session cookie) is created and the raw input is echoed, keeping known and unknown users fully indistinguishable — including the previously observable `Set-Cookie` difference. `UsernameForm` and `resolveLoginHint` stop forwarding the flag, which also removes the redundant `getLoginSettings` call in `resolveLoginHint`. The password pages no longer consult login settings or the session lookup for the `unknownContext` error and instead warn only when required input is actually missing (matching the existing pattern on the passkey/mfa/u2f pages): `/password` and `/password/change` when no `loginName` searchParam is present, `/password/set` when neither `loginName` nor `userId` is present. A missing session alone no longer triggers the error. Verified against a live instance in all four settings combinations (uniform on/off, both divergence directions) including a known-vs-unknown `login_hint` comparison producing byte-identical responses under protection; full unit suite passes. --------- Co-authored-by: Livio Spring <9405495+livio-a@users.noreply.github.com> --- .../src/app/(login)/password/change/page.tsx | 13 ++--- apps/login/src/app/(login)/password/page.tsx | 7 ++- .../src/app/(login)/password/set/page.tsx | 6 ++- apps/login/src/components/username-form.tsx | 3 +- .../src/lib/server/flow-initiation.test.ts | 14 +++-- apps/login/src/lib/server/flow-initiation.ts | 17 ++---- apps/login/src/lib/server/loginname.test.ts | 15 +----- apps/login/src/lib/server/loginname.ts | 54 +++++++++++-------- .../login/src/lib/server/security-settings.ts | 2 +- 9 files changed, 63 insertions(+), 68 deletions(-) diff --git a/apps/login/src/app/(login)/password/change/page.tsx b/apps/login/src/app/(login)/password/change/page.tsx index 411e9d20d..ad1d7654b 100644 --- a/apps/login/src/app/(login)/password/change/page.tsx +++ b/apps/login/src/app/(login)/password/change/page.tsx @@ -5,7 +5,7 @@ import { Translated } from "@/components/translated"; import { UserAvatar } from "@/components/user-avatar"; import { getServiceConfig } from "@/lib/service-url"; import { loadMostRecentSession } from "@/lib/session"; -import { getBrandingSettings, getLoginSettings, getPasswordComplexitySettings } from "@/lib/zitadel"; +import { getBrandingSettings, getPasswordComplexitySettings } from "@/lib/zitadel"; import { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { headers } from "next/headers"; @@ -39,11 +39,6 @@ export default async function Page(props: { searchParams: Promise
@@ -54,8 +49,10 @@ export default async function Page(props: { searchParams: Promise

- {/* show error only if usernames should be shown to be unknown */} - {(!sessionFactors || !loginName) && !loginSettings?.ignoreUnknownUsernames && ( + {/* Only warn when there is no loginName to continue with; a failed session + lookup is reported by the form gate below (failedLoading), and under + enumeration protection no session may exist by design. */} + {!loginName && (
diff --git a/apps/login/src/app/(login)/password/page.tsx b/apps/login/src/app/(login)/password/page.tsx index 0f6db975f..580ded28b 100644 --- a/apps/login/src/app/(login)/password/page.tsx +++ b/apps/login/src/app/(login)/password/page.tsx @@ -73,8 +73,11 @@ export default async function Page(props: { searchParams: Promise
- {/* show error only if usernames should be shown to be unknown */} - {(!sessionFactors || !loginName) && !loginSettings?.ignoreUnknownUsernames && ( + {/* Only warn when there is no loginName to continue with (e.g. a direct visit + without searchParams). A failed session lookup alone is not an error: the + form still works via the user-search fallback in sendPassword, and under + enumeration protection no session exists by design. */} + {!loginName && (
diff --git a/apps/login/src/app/(login)/password/set/page.tsx b/apps/login/src/app/(login)/password/set/page.tsx index 5299485ef..1727c7c98 100644 --- a/apps/login/src/app/(login)/password/set/page.tsx +++ b/apps/login/src/app/(login)/password/set/page.tsx @@ -108,8 +108,10 @@ export default async function Page(props: { searchParams: Promise

- {/* show error only if usernames should be shown to be unknown */} - {loginName && !session && !loginSettings?.ignoreUnknownUsernames && ( + {/* Only warn when there is neither a loginName nor a userId to continue with. + A missing session is expected here: the set/reset flow works via code + + userId, and under enumeration protection no session exists by design. */} + {!loginName && !userId && (
diff --git a/apps/login/src/components/username-form.tsx b/apps/login/src/components/username-form.tsx index f73aa3342..ec37c3c78 100644 --- a/apps/login/src/components/username-form.tsx +++ b/apps/login/src/components/username-form.tsx @@ -68,7 +68,6 @@ export function UsernameForm({ defaultOrganization, requestId, suffix, - ignoreUnknownUsernames: loginSettings?.ignoreUnknownUsernames, }); handleServerActionResponse(res, router, setSamlData, setError); @@ -79,7 +78,7 @@ export function UsernameForm({ setLoading(false); } }, - [defaultOrganization, requestId, suffix, loginSettings, router, t], + [defaultOrganization, requestId, suffix, router, t], ); useEffect(() => { diff --git a/apps/login/src/lib/server/flow-initiation.test.ts b/apps/login/src/lib/server/flow-initiation.test.ts index aa32da1f2..e409f8989 100644 --- a/apps/login/src/lib/server/flow-initiation.test.ts +++ b/apps/login/src/lib/server/flow-initiation.test.ts @@ -498,7 +498,7 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { expect(location).not.toContain("submit=true"); }); - test("should forward ignoreUnknownUsernames from login settings when resolving loginHint", async () => { + test("should resolve loginHint without forwarding enumeration settings (derived server-side by sendLoginname)", async () => { mockGetAuthRequest.mockResolvedValue({ authRequest: { id: "abc123", @@ -509,9 +509,9 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { }, }); - // Org has enumeration protection enabled: sendLoginname must receive the - // flag so an unknown hint still redirects to the (fake) /password step. - mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true }); + // Enumeration protection is derived inside sendLoginname from the request-context + // login settings; resolveLoginHint only passes the hint and the org context, and + // an unknown hint still redirects to the (fake) /password step. mockSendLoginname.mockResolvedValue({ redirect: "/password?loginName=unknown%40example.com", }); @@ -521,7 +521,11 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { expect(mockSendLoginname).toHaveBeenCalledWith( expect.objectContaining({ loginName: "unknown@example.com", - ignoreUnknownUsernames: true, + }), + ); + expect(mockSendLoginname).not.toHaveBeenCalledWith( + expect.objectContaining({ + ignoreUnknownUsernames: expect.anything(), }), ); diff --git a/apps/login/src/lib/server/flow-initiation.ts b/apps/login/src/lib/server/flow-initiation.ts index 034b2f035..b5fca35c8 100644 --- a/apps/login/src/lib/server/flow-initiation.ts +++ b/apps/login/src/lib/server/flow-initiation.ts @@ -13,7 +13,6 @@ import { createResponse, getActiveIdentityProviders, getAuthRequest, - getLoginSettings, getOrgsByDomain, getSAMLRequest, getSecuritySettings, @@ -117,13 +116,11 @@ const gotoLoginname = ({ * error), in which case callers fall back to the prefilled /loginname screen. */ const resolveLoginHint = async ({ - serviceConfig, request, requestId, loginHint, organization, }: { - serviceConfig: ServiceConfig; request: NextRequest; requestId: string; loginHint?: string; @@ -134,17 +131,14 @@ const resolveLoginHint = async ({ } try { - // Mirror the client-side loginname form: forward ignoreUnknownUsernames so - // an unknown hint redirects to the (fake) /password step like a known one, - // instead of falling back to /loginname and disclosing that the user does - // not exist. getLoginSettings is TTL-cached, so this adds no extra RPC. - const loginSettings = await getLoginSettings({ serviceConfig, organization: organization || undefined }); - + // sendLoginname derives enumeration protection (ignoreUnknownUsernames) from the + // same organization context server-side, so an unknown hint redirects to the + // (fake) /password step like a known one instead of falling back to /loginname + // and disclosing that the user does not exist. const res = await sendLoginname({ loginName: loginHint, requestId, organization: organization || undefined, - ignoreUnknownUsernames: loginSettings?.ignoreUnknownUsernames, }); if (res && "redirect" in res && res.redirect) { @@ -343,7 +337,6 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr }); } else if (authRequest.prompt.includes(Prompt.LOGIN)) { const hintResponse = await resolveLoginHint({ - serviceConfig, request, requestId, loginHint: authRequest.loginHint, @@ -414,7 +407,6 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr if (!selectedSession || !selectedSession.id) { // login_hint matches no session: resolve it straight to the next step. const hintResponse = await resolveLoginHint({ - serviceConfig, request, requestId, loginHint: authRequest.loginHint, @@ -499,7 +491,6 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr } else { // No session: resolve a login_hint straight to the next step if we can. const hintResponse = await resolveLoginHint({ - serviceConfig, request, requestId, loginHint: authRequest?.loginHint, diff --git a/apps/login/src/lib/server/loginname.test.ts b/apps/login/src/lib/server/loginname.test.ts index ca46d58ba..cfd4dd1ee 100644 --- a/apps/login/src/lib/server/loginname.test.ts +++ b/apps/login/src/lib/server/loginname.test.ts @@ -537,7 +537,6 @@ describe("sendLoginname", () => { loginName: "user@example.com", requestId: "req123", organization: "org123", - ignoreUnknownUsernames: true, }); expect(result).toBeDefined(); @@ -874,7 +873,6 @@ describe("sendLoginname", () => { const result = await sendLoginname({ loginName: "user@example.com", - ignoreUnknownUsernames: true, }); expect(result).not.toEqual({ error: "errors.userNotFound" }); @@ -899,7 +897,6 @@ describe("sendLoginname", () => { const result = await sendLoginname({ loginName: "user@example.com", - ignoreUnknownUsernames: true, }); expect(result).not.toEqual({ error: "errors.userNotFound" }); @@ -989,7 +986,6 @@ describe("sendLoginname", () => { const result = await sendLoginname({ loginName: "user@example.com", - ignoreUnknownUsernames: true, }); expect(result).not.toEqual({ error: "errors.moreThanOneUserFound" }); @@ -1020,7 +1016,7 @@ describe("sendLoginname", () => { rawMessage: "Errors.User.NotActive (SESSION-Gj4ko)", }); - const result = await sendLoginname({ loginName: "user1", ignoreUnknownUsernames: true }); + const result = await sendLoginname({ loginName: "user1" }); expect(result).toEqual({ redirect: "/password?loginName=user1" }); // With ignoreUnknownUsernames: true, we skip session creation, so this mock is NOT called @@ -1046,7 +1042,7 @@ describe("sendLoginname", () => { authMethodTypes: [AuthenticationMethodType.PASSWORD], }); - const result = await sendLoginname({ loginName: "user1", ignoreUnknownUsernames: true }); + const result = await sendLoginname({ loginName: "user1" }); expect(result).toEqual({ redirect: "/password?loginName=user1" }); expect(mockCreateSessionAndUpdateCookie).not.toHaveBeenCalled(); @@ -1086,7 +1082,6 @@ describe("sendLoginname", () => { const result = await sendLoginname({ loginName: "user@example.com", - ignoreUnknownUsernames: true, }); expect(result).not.toEqual({ error: "errors.localAuthenticationNotAllowed" }); @@ -1126,7 +1121,6 @@ describe("sendLoginname", () => { const result = await sendLoginname({ loginName: "user@example.com", - ignoreUnknownUsernames: true, }); expect(result).not.toEqual({ error: "errors.passkeysNotAllowed" }); @@ -1239,7 +1233,6 @@ describe("sendLoginname", () => { // INPUT login name is just "user" const result = await sendLoginname({ loginName: "user", - ignoreUnknownUsernames: true, }); expect(result).toHaveProperty("redirect"); @@ -1281,7 +1274,6 @@ describe("sendLoginname", () => { const result = await sendLoginname({ loginName: "user", - ignoreUnknownUsernames: true, }); expect(result).toHaveProperty("redirect"); @@ -1323,7 +1315,6 @@ describe("sendLoginname", () => { const result = await sendLoginname({ loginName: "user", - ignoreUnknownUsernames: true, }); expect(result).toHaveProperty("redirect"); @@ -1371,7 +1362,6 @@ describe("sendLoginname", () => { const result = await sendLoginname({ loginName: "input-name", organization: "context-org", // Context has ignore=true - ignoreUnknownUsernames: true, }); expect(result).toHaveProperty("redirect"); @@ -1419,7 +1409,6 @@ describe("sendLoginname", () => { const result = await sendLoginname({ loginName: "input-name", organization: "context-org", // Context has ignore=false - ignoreUnknownUsernames: false, }); expect(result).toHaveProperty("redirect"); diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index 4031cc4d1..0968cc363 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -36,7 +36,6 @@ export type SendLoginnameCommand = { organization?: string; defaultOrganization?: string; suffix?: string; - ignoreUnknownUsernames?: boolean; }; const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; @@ -53,6 +52,13 @@ export async function sendLoginname(command: SendLoginnameCommand) { return { error: t("errors.couldNotGetLoginSettings") }; } + // Single source of truth for enumeration protection, derived server-side from the + // request-context login settings (sendLoginname is a public server action, so a + // client-supplied flag must not be trusted). It gates session creation and the + // loginName exposed in redirect URLs, keeping known and unknown users + // indistinguishable while protection applies. + const ignoreUnknownUsernames = !!loginSettingsByContext.ignoreUnknownUsernames; + let searchUsersRequest: SearchUsersCommand = { serviceConfig, searchValue: command.loginName, @@ -89,7 +95,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { } const preventUserEnumeration = (organization: string | undefined) => { - if (command.ignoreUnknownUsernames) { + if (ignoreUnknownUsernames) { logger.debug("ignoreUnknownUsernames is true, redirecting to password"); const paramsPasswordDefault = new URLSearchParams({ loginName: command.loginName, @@ -235,7 +241,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { if (users.length > 1) { logger.debug("multiple users found, returning error"); - if (loginSettingsByContext?.ignoreUnknownUsernames) { + if (ignoreUnknownUsernames) { return preventUserEnumeration(command.organization); } return { error: t("errors.moreThanOneUserFound") }; @@ -265,8 +271,11 @@ export async function sendLoginname(command: SendLoginnameCommand) { } } + // Only create a session (and its cookie) when enumeration protection does not + // apply: with protection on, known and unknown users must be indistinguishable, + // and the /password page must not be able to tell the difference either. let session; - if (!userLoginSettings?.ignoreUnknownUsernames) { + if (!ignoreUnknownUsernames) { const checks = create(ChecksSchema, { user: { search: { case: "userId", value: userId } }, }); @@ -292,9 +301,17 @@ export async function sendLoginname(command: SendLoginnameCommand) { return { error: t("errors.couldNotCreateSession") }; } + // LoginName to expose in redirect URLs: while enumeration protection applies, + // echo the raw input so known and unknown users stay indistinguishable; otherwise + // use the session's loginName so the next page can match the session cookie + // (falling back to the user's preferred login name). + const redirectLoginName = ignoreUnknownUsernames + ? command.loginName + : (session?.factors?.user?.loginName ?? user.preferredLoginName); + // TODO: check if handling of userstate INITIAL is needed if (user.state === UserState.INITIAL) { - if (userLoginSettings?.ignoreUnknownUsernames) { + if (ignoreUnknownUsernames) { return preventUserEnumeration(command.organization); } return { error: t("errors.initialUserNotSupported") }; @@ -303,7 +320,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { // Resolve organization from command or session let organization = command.organization ?? session?.factors?.user?.organizationId ?? user.details?.resourceOwner; - if (userLoginSettings?.ignoreUnknownUsernames) { + if (ignoreUnknownUsernames) { organization = command.organization; if (!organization && ORG_SUFFIX_REGEX.test(command.loginName)) { const matched = ORG_SUFFIX_REGEX.exec(command.loginName); @@ -384,7 +401,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { return idpResp; } - if (command.ignoreUnknownUsernames) { + if (ignoreUnknownUsernames) { return preventUserEnumeration(command.organization); } @@ -395,9 +412,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { { const paramsPassword = new URLSearchParams({ - loginName: command.ignoreUnknownUsernames - ? command.loginName - : (session?.factors?.user?.loginName ?? user.preferredLoginName), + loginName: redirectLoginName, }); if (organization) { @@ -415,7 +430,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY if (userLoginSettings?.passkeysType === PasskeysType.NOT_ALLOWED || !userLoginSettings?.allowLocalAuthentication) { - if (command.ignoreUnknownUsernames) { + if (ignoreUnknownUsernames) { return preventUserEnumeration(command.organization); } return { @@ -425,9 +440,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { { const paramsPasskey = new URLSearchParams({ - loginName: command.ignoreUnknownUsernames - ? command.loginName - : (session?.factors?.user?.loginName ?? user.preferredLoginName), + loginName: redirectLoginName, }); if (command.requestId) { paramsPasskey.append("requestId", command.requestId); @@ -458,9 +471,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { userLoginSettings?.allowLocalAuthentication ) { const passkeyParams = new URLSearchParams({ - loginName: command.ignoreUnknownUsernames - ? command.loginName - : (session?.factors?.user?.loginName ?? user.preferredLoginName), + loginName: redirectLoginName, altPassword: `${methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD) && userLoginSettings?.allowLocalAuthentication}`, // show alternative password option only if allowed }); @@ -478,7 +489,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { } else if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)) { // Check if password authentication is allowed if (!userLoginSettings?.allowLocalAuthentication) { - if (command.ignoreUnknownUsernames) { + if (ignoreUnknownUsernames) { return preventUserEnumeration(command.organization); } return { @@ -488,9 +499,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { // user has no passkey setup and login settings allow passwords const paramsPasswordDefault = new URLSearchParams({ - loginName: command.ignoreUnknownUsernames - ? command.loginName - : (session?.factors?.user?.loginName ?? user.preferredLoginName), + loginName: redirectLoginName, }); if (command.requestId) { @@ -545,6 +554,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { logger.debug("No single org found for discovery"); } } + // When a user is not found, try to redirect to an external IdP if: // - local authentication is disabled, OR // - domain discovery resolved an organization (regardless of allowRegister) @@ -553,7 +563,7 @@ export async function sendLoginname(command: SendLoginnameCommand) { // Fixes: https://github.com/zitadel/zitadel/issues/12021 // Fixes: https://github.com/zitadel/zitadel/issues/12023 - if ((!effectiveLoginSettings?.allowLocalAuthentication || discoveredOrganization) && !command.ignoreUnknownUsernames) { + if ((!effectiveLoginSettings?.allowLocalAuthentication || discoveredOrganization) && !ignoreUnknownUsernames) { const resp = await redirectUserToIDP(undefined, discoveredOrganization); if (resp) { logger.debug("Redirecting to IDP", { organization: discoveredOrganization }); diff --git a/apps/login/src/lib/server/security-settings.ts b/apps/login/src/lib/server/security-settings.ts index 1bf6d4bcc..f6a0ce0d2 100644 --- a/apps/login/src/lib/server/security-settings.ts +++ b/apps/login/src/lib/server/security-settings.ts @@ -11,7 +11,7 @@ try { cacheConfig = JSON.parse(process.env.API_CACHE_CONFIG); } } catch (e) { - console.error("Failed to parse API_CACHE_CONFIG", e); + logger.error("Failed to parse API_CACHE_CONFIG", { error: e }); } /** Cache TTL: defaults to longMinutes (1 hour). Security settings rarely change. */