fix(login): resolve unknownContext error on password page caused by inconsistent ignoreUnknownUsernames handling (#12512)

# 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 <[email protected]>
This commit is contained in:
Max Peintner
2026-08-04 12:49:28 +00:00
committed by GitHub
co-authored by Livio Spring
parent 9070b537d2
commit 464d1cb655
9 changed files with 63 additions and 68 deletions
@@ -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<Record<string
organization: sessionFactors?.factors?.user?.organizationId,
});
const loginSettings = await getLoginSettings({
serviceConfig,
organization: sessionFactors?.factors?.user?.organizationId,
});
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
@@ -54,8 +49,10 @@ export default async function Page(props: { searchParams: Promise<Record<string
<Translated i18nKey="change.description" namespace="password" />
</p>
{/* 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 && (
<div className="py-4">
<Alert>
<Translated i18nKey="unknownContext" namespace="error" />
+5 -2
View File
@@ -73,8 +73,11 @@ export default async function Page(props: { searchParams: Promise<Record<string
</div>
<div className="w-full">
{/* 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 && (
<div className="py-4">
<Alert>
<Translated i18nKey="unknownContext" namespace="error" />
@@ -108,8 +108,10 @@ export default async function Page(props: { searchParams: Promise<Record<string
<Translated i18nKey="set.description" namespace="password" />
</p>
{/* 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 && (
<div className="py-4">
<Alert>
<Translated i18nKey="unknownContext" namespace="error" />
+1 -2
View File
@@ -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(() => {
@@ -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: "[email protected]",
ignoreUnknownUsernames: true,
}),
);
expect(mockSendLoginname).not.toHaveBeenCalledWith(
expect.objectContaining({
ignoreUnknownUsernames: expect.anything(),
}),
);
+4 -13
View File
@@ -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,
+2 -13
View File
@@ -537,7 +537,6 @@ describe("sendLoginname", () => {
loginName: "[email protected]",
requestId: "req123",
organization: "org123",
ignoreUnknownUsernames: true,
});
expect(result).toBeDefined();
@@ -874,7 +873,6 @@ describe("sendLoginname", () => {
const result = await sendLoginname({
loginName: "[email protected]",
ignoreUnknownUsernames: true,
});
expect(result).not.toEqual({ error: "errors.userNotFound" });
@@ -899,7 +897,6 @@ describe("sendLoginname", () => {
const result = await sendLoginname({
loginName: "[email protected]",
ignoreUnknownUsernames: true,
});
expect(result).not.toEqual({ error: "errors.userNotFound" });
@@ -989,7 +986,6 @@ describe("sendLoginname", () => {
const result = await sendLoginname({
loginName: "[email protected]",
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: "[email protected]",
ignoreUnknownUsernames: true,
});
expect(result).not.toEqual({ error: "errors.localAuthenticationNotAllowed" });
@@ -1126,7 +1121,6 @@ describe("sendLoginname", () => {
const result = await sendLoginname({
loginName: "[email protected]",
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");
+32 -22
View File
@@ -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 });
@@ -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. */