mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-17 16:35:14 -05:00
613 lines
22 KiB
TypeScript
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);
|
|
}
|