Files
zitadel/apps/login/src/lib/server/loginname.ts
T

613 lines
22 KiB
TypeScript

"use server";
import { equalsIgnoreCase } from "@/lib/auth-utils";
import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification";
import { createLogger } from "@/lib/logger";
import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp";
import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { IDPLink } from "@zitadel/proto/zitadel/user/v2/idp_pb";
import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { getServiceConfig } from "../service-url";
import {
getActiveIdentityProviders,
getIDPByID,
getLoginSettings,
getOrgsByDomain,
listAuthenticationMethodTypes,
listIDPLinks,
searchUsers,
SearchUsersCommand,
startIdentityProviderFlow,
} from "../zitadel";
import { createSessionAndUpdateCookie } from "./cookie";
import { getPublicHost } from "./host";
import { trySendVerification } from "./verify";
const logger = createLogger("loginname");
export type SendLoginnameCommand = {
loginName: string;
requestId?: string;
organization?: string;
defaultOrganization?: string;
suffix?: string;
};
const ORG_SUFFIX_REGEX = /(?<=@)(.+)/;
export async function sendLoginname(command: SendLoginnameCommand) {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const t = await getTranslations("loginname");
const loginSettingsByContext = await getLoginSettings({ serviceConfig, organization: command.organization });
if (!loginSettingsByContext) {
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,
organizationId: command.organization,
loginSettings: loginSettingsByContext,
suffix: command.suffix,
};
const searchResult = await searchUsers(searchUsersRequest);
// Safety check: ensure searchResult is defined
if (!searchResult) {
logger.error("searchUsers returned undefined or null");
return { error: t("errors.couldNotSearchUsers") };
}
if ("error" in searchResult && searchResult.error) {
logger.debug("searchUsers returned error, returning early", { error: searchResult.error });
return searchResult;
}
if (!("result" in searchResult)) {
logger.debug("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) {
logger.debug("No users found, will proceed with org discovery");
}
const preventUserEnumeration = (organization: string | undefined) => {
if (ignoreUnknownUsernames) {
logger.debug("ignoreUnknownUsernames is true, redirecting to password");
const paramsPasswordDefault = new URLSearchParams({
loginName: command.loginName,
});
if (command.requestId) {
paramsPasswordDefault.append("requestId", command.requestId);
}
if (organization) {
paramsPasswordDefault.append("organization", organization);
}
return { redirect: "/password?" + paramsPasswordDefault };
}
return { error: t("errors.userNotFound") };
};
const redirectUserToIDP = async (userId?: string, organization?: string) => {
// If userId is provided, check for user-specific IDP links first
let identityProviders: IDPLink[] = [];
if (userId) {
identityProviders = await listIDPLinks({ serviceConfig, userId }).then((resp) => {
return resp.result;
});
}
// If no IDP links exist for the user (or no userId provided), try to get active IDPs from the organization
if (identityProviders.length === 0) {
const activeIdps = await getActiveIdentityProviders({ serviceConfig, orgId: organization }).then((resp) => {
return resp.identityProviders.filter((idp) => idp.options?.isAutoCreation || idp.options?.isCreationAllowed);
});
// If exactly one active IDP exists in the organization, redirect to it
if (activeIdps.length === 1) {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const host = getPublicHost(_headers);
const identityProviderType = activeIdps[0].type;
const provider = idpTypeToSlug(identityProviderType);
const params = new URLSearchParams();
if (userId) {
params.set("userId", userId);
}
if (command.requestId) {
params.set("requestId", command.requestId);
}
if (organization) {
params.set("organization", organization);
}
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const response = await startIdentityProviderFlow({
serviceConfig,
idpId: activeIdps[0].id,
urls: {
successUrl:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/process?` +
new URLSearchParams(params),
failureUrl:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` +
new URLSearchParams(params),
},
});
if (!response || !response.url) {
return { error: t("errors.couldNotStartIDPFlow") };
}
if (response.fields) {
return { samlData: { url: response.url, fields: response.fields } };
}
return { redirect: response.url };
}
}
if (identityProviders.length === 1) {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const host = getPublicHost(_headers);
const identityProviderId = identityProviders[0].idpId;
const idp = await getIDPByID({ serviceConfig, id: identityProviderId });
const idpType = idp?.type;
if (!idp || !idpType) {
throw new Error(t("errors.couldNotFindIdentityProvider"));
}
const identityProviderType = idpTypeToIdentityProviderType(idpType);
const provider = idpTypeToSlug(identityProviderType);
const params = new URLSearchParams();
if (userId) {
params.set("userId", userId);
}
if (command.requestId) {
params.set("requestId", command.requestId);
}
if (organization) {
params.set("organization", organization);
}
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const response = await startIdentityProviderFlow({
serviceConfig,
idpId: idp.id,
urls: {
successUrl:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/process?` +
new URLSearchParams(params),
failureUrl:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` +
new URLSearchParams(params),
},
});
if (!response || !response.url) {
return { error: t("errors.couldNotStartIDPFlow") };
}
if (response.fields) {
return { samlData: { url: response.url, fields: response.fields } };
}
return { redirect: response.url };
}
};
if (users.length > 1) {
logger.debug("multiple users found, returning error");
if (ignoreUnknownUsernames) {
return preventUserEnumeration(command.organization);
}
return { error: t("errors.moreThanOneUserFound") };
} else if (users.length == 1 && users[0].userId) {
const user = users[0];
const userId = users[0].userId;
const userLoginSettings = await getLoginSettings({ serviceConfig, organization: user.details?.resourceOwner });
// compare with the concatenated suffix when set
const concatLoginname = command.suffix ? `${command.loginName}@${command.suffix}` : command.loginName;
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) {
if (!equalsIgnoreCase(user.preferredLoginName, concatLoginname)) {
return preventUserEnumeration(command.organization);
}
} else if (userLoginSettings?.disableLoginWithEmail) {
if (
!equalsIgnoreCase(user.preferredLoginName, concatLoginname) &&
!equalsIgnoreCase(humanUser?.phone?.phone, command.loginName)
) {
return preventUserEnumeration(command.organization);
}
} else if (userLoginSettings?.disableLoginWithPhone) {
if (
!equalsIgnoreCase(user.preferredLoginName, concatLoginname) &&
!equalsIgnoreCase(humanUser?.email?.email, command.loginName)
) {
return preventUserEnumeration(command.organization);
}
}
// 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 (!ignoreUnknownUsernames) {
const checks = create(ChecksSchema, {
user: { search: { case: "userId", value: userId } },
});
const sessionOrError = await createSessionAndUpdateCookie({
checks,
requestId: command.requestId,
}).catch((error) => {
if (isClassifiedError(error) && error.message?.includes("Errors.User.NotActive")) {
return { error: t("errors.userNotActive") };
}
throw error;
});
if ("error" in sessionOrError) {
return sessionOrError;
}
session = sessionOrError.session;
}
if (session && !session.factors?.user?.id) {
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 (ignoreUnknownUsernames) {
return preventUserEnumeration(command.organization);
}
return { error: t("errors.initialUserNotSupported") };
}
// Resolve organization from command or session
let organization = command.organization ?? session?.factors?.user?.organizationId ?? user.details?.resourceOwner;
if (ignoreUnknownUsernames) {
organization = command.organization;
if (!organization && ORG_SUFFIX_REGEX.test(command.loginName)) {
const matched = ORG_SUFFIX_REGEX.exec(command.loginName);
const suffix = matched?.[1] ?? "";
const orgs = await getOrgsByDomain({ serviceConfig, domain: suffix });
if (orgs.result && orgs.result.length === 1) {
const orgToCheckForDiscovery = orgs.result[0].id;
const orgLoginSettings = await getLoginSettings({ serviceConfig, organization: orgToCheckForDiscovery });
if (orgLoginSettings?.allowDomainDiscovery) {
organization = orgToCheckForDiscovery;
}
}
}
}
const methods = await listAuthenticationMethodTypes({
serviceConfig,
userId: session?.factors?.user?.id ?? userId,
});
const hasPrimaryMethod =
methods.authMethodTypes?.some(
(m: AuthenticationMethodType) =>
m === AuthenticationMethodType.PASSWORD ||
m === AuthenticationMethodType.PASSKEY ||
m === AuthenticationMethodType.IDP,
) ?? false;
// always resend invite or setup email if user has no primary auth method set
if (!hasPrimaryMethod) {
logger.debug("humanUser.email?.isVerified", {
isVerified: humanUser?.email?.isVerified,
});
// If the user's email is not verified, they likely already have a code from the
// initial verification email. Auto-sending a new one here invalidates their existing code
// and causes confusion. Only auto-send if the email is already verified.
const shouldSend = humanUser?.email?.isVerified === true;
const codeSent = shouldSend
? await trySendVerification({
userId: session?.factors?.user?.id ?? user.userId,
isInvite: true,
requestId: command.requestId,
})
: false;
const params = new URLSearchParams({
loginName: (session?.factors?.user?.loginName ?? user.preferredLoginName) as string,
invite: "true", // always send invite code if user has no primary auth method
});
if (codeSent) {
params.append("codeSent", "true");
}
if (command.requestId) {
params.append("requestId", command.requestId);
}
if (organization) {
params.append("organization", organization);
}
return { redirect: `/verify?` + params };
}
if (methods.authMethodTypes.length == 1) {
const method = methods.authMethodTypes[0];
switch (method) {
case AuthenticationMethodType.PASSWORD: // user has only password as auth method
if (!userLoginSettings?.allowLocalAuthentication) {
// Check if user has IDPs available as alternative, that could eventually be used to register/link.
const idpResp = await redirectUserToIDP(userId, organization);
if (idpResp?.redirect) {
return idpResp;
}
if (ignoreUnknownUsernames) {
return preventUserEnumeration(command.organization);
}
return {
error: t("errors.localAuthenticationNotAllowed"),
};
}
{
const paramsPassword = new URLSearchParams({
loginName: redirectLoginName,
});
if (organization) {
paramsPassword.append("organization", organization);
}
if (command.requestId) {
paramsPassword.append("requestId", command.requestId);
}
return {
redirect: "/password?" + paramsPassword,
};
}
case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY
if (userLoginSettings?.passkeysType === PasskeysType.NOT_ALLOWED || !userLoginSettings?.allowLocalAuthentication) {
if (ignoreUnknownUsernames) {
return preventUserEnumeration(command.organization);
}
return {
error: t("errors.passkeysNotAllowed"),
};
}
{
const paramsPasskey = new URLSearchParams({
loginName: redirectLoginName,
});
if (command.requestId) {
paramsPasskey.append("requestId", command.requestId);
}
if (organization) {
paramsPasskey.append("organization", organization);
}
return { redirect: "/passkey?" + paramsPasskey };
}
case AuthenticationMethodType.IDP: {
const resp = await redirectUserToIDP(userId, organization);
if (resp?.error) {
return { error: resp.error };
}
return resp;
}
}
} else {
// prefer passkey in favor of other methods
if (
methods.authMethodTypes.includes(AuthenticationMethodType.PASSKEY) &&
userLoginSettings?.passkeysType !== PasskeysType.NOT_ALLOWED &&
userLoginSettings?.allowLocalAuthentication
) {
const passkeyParams = new URLSearchParams({
loginName: redirectLoginName,
altPassword: `${methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD) && userLoginSettings?.allowLocalAuthentication}`, // show alternative password option only if allowed
});
if (command.requestId) {
passkeyParams.append("requestId", command.requestId);
}
if (organization) {
passkeyParams.append("organization", organization);
}
return { redirect: "/passkey?" + passkeyParams };
} else if (methods.authMethodTypes.includes(AuthenticationMethodType.IDP)) {
return redirectUserToIDP(userId, organization);
} else if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)) {
// Check if password authentication is allowed
if (!userLoginSettings?.allowLocalAuthentication) {
if (ignoreUnknownUsernames) {
return preventUserEnumeration(command.organization);
}
return {
error: t("errors.localAuthenticationNotAllowed"),
};
}
// user has no passkey setup and login settings allow passwords
const paramsPasswordDefault = new URLSearchParams({
loginName: redirectLoginName,
});
if (command.requestId) {
paramsPasswordDefault.append("requestId", command.requestId);
}
if (organization) {
paramsPasswordDefault.append("organization", organization);
}
return {
redirect: "/password?" + paramsPasswordDefault,
};
}
}
}
logger.debug("User not found (0 potential users), checking registration options");
// user not found, perform organization discovery if no org context provided
let discoveredOrganization = command.organization;
let effectiveLoginSettings = loginSettingsByContext;
if (!command.organization && command.defaultOrganization) {
const defaultLoginSettings = await getLoginSettings({ serviceConfig, organization: command.defaultOrganization });
if (defaultLoginSettings) {
effectiveLoginSettings = defaultLoginSettings;
}
}
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 the Organization Domain
const orgs = await getOrgsByDomain({ serviceConfig, domain: suffix });
const orgToCheckForDiscovery = orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined;
if (orgToCheckForDiscovery) {
const orgLoginSettings = await getLoginSettings({ serviceConfig, organization: orgToCheckForDiscovery });
if (orgLoginSettings?.allowDomainDiscovery) {
logger.debug("Org discovery successful", { organization: orgToCheckForDiscovery });
discoveredOrganization = orgToCheckForDiscovery;
// Use the discovered organization's login settings for subsequent checks
effectiveLoginSettings = orgLoginSettings;
} else {
logger.debug("Org does not allow domain discovery");
}
} else {
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)
// Registration policy (allowRegister) controls local account creation only
// 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
if ((!effectiveLoginSettings?.allowLocalAuthentication || discoveredOrganization) && !ignoreUnknownUsernames) {
const resp = await redirectUserToIDP(undefined, discoveredOrganization);
if (resp) {
logger.debug("Redirecting to IDP", { organization: discoveredOrganization });
return resp;
}
// If local auth is disabled, there is no fallback — return error
if (!effectiveLoginSettings?.allowLocalAuthentication) {
logger.debug("IDP redirect failed and local auth not allowed, returning user not found");
return preventUserEnumeration(discoveredOrganization);
}
}
if (effectiveLoginSettings?.allowRegister && effectiveLoginSettings?.allowLocalAuthentication) {
logger.debug("register and password both allowed");
// do not register user if ignoreUnknownUsernames is set
if (discoveredOrganization && !effectiveLoginSettings?.ignoreUnknownUsernames) {
logger.debug("Redirecting to registration page", { organization: discoveredOrganization });
const params = new URLSearchParams({ organization: discoveredOrganization });
if (command.requestId) {
params.set("requestId", command.requestId);
}
if (command.loginName) {
params.set("email", command.loginName);
}
return { redirect: "/register?" + params };
} else {
logger.debug("Not redirecting to register", {
hasDiscoveredOrg: !!discoveredOrganization,
ignoreUnknownUsernames: effectiveLoginSettings?.ignoreUnknownUsernames,
});
}
}
return preventUserEnumeration(discoveredOrganization);
}