Files
zitadel/apps/login/src/lib/session.ts
T
26f8238499 fix(login): correctly redirect to /password when ignoreUnknownUsername is set (#11130)
Closes #11006 

# Which Problems Are Solved

This PR addresses an issue where the ignoreUnknownUsernames setting was
not being respected in certain scenarios during the login flow.
Specifically:

- When a user was found but rejected due to login settings (e.g.,
disableLoginWithEmail), the system would return a "User not found" error
instead of redirecting to the password page as dictated by
ignoreUnknownUsernames.
- When a user was not found, and the flow fell through the registration
checks (e.g., IDP redirect failed), it would also return "User not
found" without checking ignoreUnknownUsernames.

# How the Problems Are Solved

- Introduced a helper function `handleUserNotFound` in 
`apps/login/src/lib/server/loginname.ts` to centralize the logic for
checking ignoreUnknownUsernames and redirecting to the password page.
- Updated the user validation logic to call handleUserNotFound when a
user is found but rejected by policy.
- Updated the registration fallback logic to call handleUserNotFound
instead of directly returning an error.
- Added new test cases in `apps/login/src/lib/server/loginname.test.ts`
to verify the fix and ensure no regressions.

# Additional Changes

- Fixed inconsistency in translation files

---------

Co-authored-by: Livio Spring <[email protected]>
2026-01-14 08:24:48 +00:00

186 lines
6.5 KiB
TypeScript

import { timestampDate } from "@zitadel/client";
import { AuthRequest } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb";
import { SAMLRequest } from "@zitadel/proto/zitadel/saml/v2/authorization_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { GetSessionResponse } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { getMostRecentCookieWithLoginname } from "./cookies";
import { shouldEnforceMFA } from "./verify-helper";
import { getLoginSettings, getSession, getUserByID, listAuthenticationMethodTypes, ServiceConfig } from "./zitadel";
type LoadMostRecentSessionParams = {
serviceConfig: ServiceConfig;
sessionParams: {
loginName?: string;
organization?: string;
};
};
export async function loadMostRecentSession({
serviceConfig,
sessionParams,
}: LoadMostRecentSessionParams): Promise<Session | undefined> {
const recent = await getMostRecentCookieWithLoginname({
loginName: sessionParams.loginName,
organization: sessionParams.organization,
});
if (!recent) {
return undefined;
}
return getSession({ serviceConfig, sessionId: recent.id, sessionToken: recent.token }).then(
(resp: GetSessionResponse) => resp.session,
);
}
/**
* mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.)
* to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId);
**/
export async function isSessionValid({
serviceConfig,
session,
}: {
serviceConfig: ServiceConfig;
session: Session;
}): Promise<boolean> {
// session can't be checked without user
if (!session.factors?.user) {
return false;
}
let mfaValid = true;
// Check if user authenticated via different methods
const validIDP = session?.factors?.intent?.verifiedAt;
const validPassword = session?.factors?.password?.verifiedAt;
const validPasskey = session?.factors?.webAuthN?.verifiedAt;
// Get login settings to determine if MFA is actually required by policy
const loginSettings = await getLoginSettings({ serviceConfig, organization: session.factors?.user?.organizationId });
// Use the existing shouldEnforceMFA function to determine if MFA is required
const isMfaRequired = shouldEnforceMFA(session, loginSettings);
// Only enforce MFA validation if MFA is required by policy
if (isMfaRequired) {
const authMethodTypes = await listAuthenticationMethodTypes({ serviceConfig, userId: session.factors.user.id });
const authMethods = authMethodTypes.authMethodTypes;
// Filter to only MFA methods (exclude PASSWORD and PASSKEY)
const mfaMethods = authMethods?.filter(
(method) =>
method === AuthenticationMethodType.TOTP ||
method === AuthenticationMethodType.OTP_EMAIL ||
method === AuthenticationMethodType.OTP_SMS ||
method === AuthenticationMethodType.U2F,
);
if (mfaMethods && mfaMethods.length > 0) {
// Check if any of the configured MFA methods have been verified
const totpValid = mfaMethods.includes(AuthenticationMethodType.TOTP) && !!session.factors.totp?.verifiedAt;
const otpEmailValid =
mfaMethods.includes(AuthenticationMethodType.OTP_EMAIL) && !!session.factors.otpEmail?.verifiedAt;
const otpSmsValid = mfaMethods.includes(AuthenticationMethodType.OTP_SMS) && !!session.factors.otpSms?.verifiedAt;
const u2fValid = mfaMethods.includes(AuthenticationMethodType.U2F) && !!session.factors.webAuthN?.verifiedAt;
mfaValid = totpValid || otpEmailValid || otpSmsValid || u2fValid;
} else {
// No specific MFA methods configured, but MFA is forced - check for any verified MFA factors
// (excluding IDP which should be handled separately)
const otpEmail = session.factors.otpEmail?.verifiedAt;
const otpSms = session.factors.otpSms?.verifiedAt;
const totp = session.factors.totp?.verifiedAt;
const webAuthN = session.factors.webAuthN?.verifiedAt;
// Note: Removed IDP (session.factors.intent?.verifiedAt) as requested
mfaValid = !!(otpEmail || otpSms || totp || webAuthN);
}
}
// If MFA is not required by policy, mfaValid remains true
const stillValid = session.expirationDate ? timestampDate(session.expirationDate).getTime() > new Date().getTime() : true;
if (!stillValid) {
console.warn(
"[Session] Session is expired",
session.expirationDate ? timestampDate(session.expirationDate).toDateString() : "no expiration date",
);
return false;
}
const validChecks = !!(validPassword || validPasskey || validIDP);
if (!validChecks) {
return false;
}
if (!mfaValid) {
console.warn("[Session] MFA is required but not valid");
return false;
}
// Check email verification if EMAIL_VERIFICATION environment variable is enabled
if (process.env.EMAIL_VERIFICATION === "true") {
const userResponse = await getUserByID({ serviceConfig, userId: session.factors.user.id });
const humanUser = userResponse?.user?.type.case === "human" ? userResponse?.user.type.value : undefined;
if (humanUser && !humanUser.email?.isVerified) {
console.warn("[Session] Email is not verified");
return false;
}
}
return true;
}
export async function findValidSession({
serviceConfig,
sessions,
authRequest,
samlRequest,
}: {
serviceConfig: ServiceConfig;
sessions: Session[];
authRequest?: AuthRequest;
samlRequest?: SAMLRequest;
}): Promise<Session | undefined> {
const sessionsWithHint = sessions.filter((s) => {
if (authRequest && authRequest.hintUserId) {
return s.factors?.user?.id === authRequest.hintUserId;
}
if (authRequest && authRequest.loginHint) {
return s.factors?.user?.loginName === authRequest.loginHint;
}
if (samlRequest) {
// SAML requests don't contain user hints like OIDC (hintUserId/loginHint)
// so we return all sessions for further processing
return true;
}
return true;
});
if (sessionsWithHint.length === 0) {
return undefined;
}
// sort by change date descending
sessionsWithHint.sort((a, b) => {
const dateA = a.changeDate ? timestampDate(a.changeDate).getTime() : 0;
const dateB = b.changeDate ? timestampDate(b.changeDate).getTime() : 0;
return dateB - dateA;
});
// return the first valid session according to settings
for (const session of sessionsWithHint) {
if (await isSessionValid({ serviceConfig, session })) {
return session;
}
}
return undefined;
}