fix(login): fallback for idp login (#10876)

Closes #10671

# Which Problems Are Solved

Users with password authentication disabled in their organization were
seeing "Username Password not allowed!" error instead of being
redirected to their organization's configured Identity Provider. This
affected domain discovery and multi-tenancy use cases in Login V2.

# How the Problems Are Solved

- Updated `redirectUserToIDP` to accept optional `userId` and
`organization` parameters
- Added fallback logic to check organization-level IDPs via
`getActiveIdentityProviders`
- Updated all call sites to pass appropriate organization context
- Added test coverage for the fallback behavior

# Additional Changes

- Consolidated duplicate logic by removing
`redirectUserToSingleIDPIfAvailable` function, which is now handled by
the unified `redirectUserToIDP` function
- improved error handling on verification page

---------

Co-authored-by: Ramon <mail@conblem.me>
This commit is contained in:
Max Peintner
2025-10-21 11:04:33 +02:00
committed by GitHub
parent 2ad5cf141f
commit ff869482b1
13 changed files with 175 additions and 112 deletions

View File

@@ -386,7 +386,9 @@
"couldNotCreateSession": "Sitzung konnte nicht erstellt werden",
"noHostFound": "Kein Host gefunden",
"userAlreadyVerified": "Benutzer ist bereits verifiziert!",
"couldNotResendInvite": "Einladung konnte nicht erneut gesendet werden"
"couldNotResendInvite": "Einladung konnte nicht erneut gesendet werden",
"inviteSendFailed": "Einladungs-E-Mail konnte nicht gesendet werden",
"emailSendFailed": "Verifizierungs-E-Mail konnte nicht gesendet werden"
}
},
"authenticator": {

View File

@@ -386,7 +386,9 @@
"couldNotCreateSession": "Could not create session",
"noHostFound": "No host found",
"userAlreadyVerified": "User is already verified!",
"couldNotResendInvite": "Could not resend invite"
"couldNotResendInvite": "Could not resend invite",
"inviteSendFailed": "Failed to send invitation email",
"emailSendFailed": "Failed to send verification email"
}
},
"authenticator": {

View File

@@ -386,7 +386,9 @@
"couldNotCreateSession": "No se pudo crear la sesión",
"noHostFound": "No se encontró el host",
"userAlreadyVerified": "¡El usuario ya está verificado!",
"couldNotResendInvite": "No se pudo reenviar la invitación"
"couldNotResendInvite": "No se pudo reenviar la invitación",
"inviteSendFailed": "No se pudo enviar el correo de invitación",
"emailSendFailed": "No se pudo enviar el correo de verificación"
}
},
"authenticator": {

View File

@@ -386,7 +386,9 @@
"couldNotCreateSession": "Impossibile creare la sessione",
"noHostFound": "Nessun host trovato",
"userAlreadyVerified": "L'utente è già verificato!",
"couldNotResendInvite": "Impossibile reinviare l'invito"
"couldNotResendInvite": "Impossibile reinviare l'invito",
"inviteSendFailed": "Impossibile inviare l'email di invito",
"emailSendFailed": "Impossibile inviare l'email di verifica"
}
},
"authenticator": {

View File

@@ -287,6 +287,21 @@
"required": {
"code": "必須項目です"
}
},
"errors": {
"couldNotResendEmail": "メールを再送信できませんでした",
"couldNotVerifyUser": "ユーザーを確認できませんでした",
"couldNotVerifyInvite": "招待を確認できませんでした",
"couldNotVerifyEmail": "メールアドレスを確認できませんでした",
"couldNotVerify": "確認できませんでした",
"couldNotLoadUser": "ユーザーを読み込めませんでした",
"couldNotLoadAuthenticators": "認証方法を読み込めませんでした",
"couldNotCreateSession": "セッションを作成できませんでした",
"noHostFound": "ホストが見つかりません",
"userAlreadyVerified": "ユーザーは既に確認済みです!",
"couldNotResendInvite": "招待を再送信できませんでした",
"inviteSendFailed": "招待メールの送信に失敗しました",
"emailSendFailed": "確認メールの送信に失敗しました"
}
},
"authenticator": {

View File

@@ -386,7 +386,9 @@
"couldNotCreateSession": "Nie udało się utworzyć sesji",
"noHostFound": "Nie znaleziono hosta",
"userAlreadyVerified": "Użytkownik jest już zweryfikowany!",
"couldNotResendInvite": "Nie udało się ponownie wysłać zaproszenia"
"couldNotResendInvite": "Nie udało się ponownie wysłać zaproszenia",
"inviteSendFailed": "Nie udało się wysłać wiadomości e-mail z zaproszeniem",
"emailSendFailed": "Nie udało się wysłać wiadomości e-mail weryfikacyjnej"
}
},
"authenticator": {

View File

@@ -386,7 +386,9 @@
"couldNotCreateSession": "Не удалось создать сеанс",
"noHostFound": "Хост не найден",
"userAlreadyVerified": "Пользователь уже подтверждён!",
"couldNotResendInvite": "Не удалось повторно отправить приглашение"
"couldNotResendInvite": "Не удалось повторно отправить приглашение",
"inviteSendFailed": "Не удалось отправить письмо с приглашением",
"emailSendFailed": "Не удалось отправить письмо с подтверждением"
}
},
"authenticator": {

View File

@@ -386,7 +386,9 @@
"couldNotCreateSession": "无法创建会话",
"noHostFound": "未找到主机",
"userAlreadyVerified": "用户已验证!",
"couldNotResendInvite": "无法重新发送邀请"
"couldNotResendInvite": "无法重新发送邀请",
"inviteSendFailed": "发送邀请邮件失败",
"emailSendFailed": "发送验证邮件失败"
}
},
"authenticator": {

View File

@@ -193,6 +193,7 @@ export default async function Page(props: { searchParams: Promise<Record<string
</div>
<SignInWithIdp
showLabel={false}
identityProviders={identityProviders}
requestId={requestId}
organization={sessionWithData.factors?.user?.organizationId}

View File

@@ -36,6 +36,8 @@ export default async function Page(props: { searchParams: Promise<any> }) {
let human: HumanUser | undefined;
let id: string | undefined;
let error: string | undefined;
const doSend = send === "true";
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
@@ -49,9 +51,9 @@ export default async function Page(props: { searchParams: Promise<any> }) {
urlTemplate:
`${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` +
(requestId ? `&requestId=${requestId}` : ""),
}).catch((error) => {
console.error("Could not send invitation email", error);
throw Error("Failed to send invitation email");
}).catch((apiError) => {
console.error("Could not send invitation email", apiError);
error = "inviteSendFailed";
});
} else {
await sendEmailCode({
@@ -59,9 +61,9 @@ export default async function Page(props: { searchParams: Promise<any> }) {
urlTemplate:
`${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
(requestId ? `&requestId=${requestId}` : ""),
}).catch((error) => {
console.error("Could not send verification email", error);
throw Error("Failed to send verification email");
}).catch((apiError) => {
console.error("Could not send verification email", apiError);
error = "emailSendFailed";
});
}
}
@@ -143,6 +145,14 @@ export default async function Page(props: { searchParams: Promise<any> }) {
</div>
<div className="w-full">
{error && (
<div className="py-4">
<Alert>
<Translated i18nKey={`errors.${error}`} namespace="verify" />
</Alert>
</div>
)}
{!id && (
<div className="py-4">
<Alert>

View File

@@ -1,7 +1,4 @@
import {
LoginSettings,
PasskeysType,
} from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { LoginSettings, PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { Alert, AlertType } from "./alert";
import { PASSKEYS, PASSWORD } from "./auth-methods";
@@ -13,11 +10,7 @@ type Props = {
loginSettings: LoginSettings;
};
export function ChooseAuthenticatorToSetup({
authMethods,
params,
loginSettings,
}: Props) {
export function ChooseAuthenticatorToSetup({ authMethods, params, loginSettings }: Props) {
if (authMethods.length !== 0) {
return (
<Alert type={AlertType.ALERT}>
@@ -26,17 +19,6 @@ export function ChooseAuthenticatorToSetup({
);
} else {
return (
<>
{loginSettings.passkeysType == PasskeysType.NOT_ALLOWED &&
!loginSettings.allowUsernamePassword && (
<Alert type={AlertType.ALERT}>
<Translated
i18nKey="noMethodsAvailable"
namespace="authenticator"
/>
</Alert>
)}
<div className="grid w-full grid-cols-1 gap-5 pt-4">
{!authMethods.includes(AuthenticationMethodType.PASSWORD) &&
loginSettings.allowUsernamePassword &&
@@ -45,7 +27,6 @@ export function ChooseAuthenticatorToSetup({
loginSettings.passkeysType == PasskeysType.ALLOWED &&
PASSKEYS(false, "/passkey/set?" + params)}
</div>
</>
);
}
}

View File

@@ -249,6 +249,7 @@ describe("sendLoginname", () => {
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
mockListIDPLinks.mockResolvedValue({ result: [] });
mockGetActiveIdentityProviders.mockResolvedValue({ identityProviders: [] });
const result = await sendLoginname({
loginName: "user@example.com",
@@ -259,6 +260,29 @@ describe("sendLoginname", () => {
});
});
test("should redirect to organization IDP when password not allowed, no user IDP links, but organization has active IDP", async () => {
mockGetLoginSettings.mockResolvedValue({ allowUsernamePassword: false });
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
mockListIDPLinks.mockResolvedValue({ result: [] });
mockGetActiveIdentityProviders.mockResolvedValue({
identityProviders: [{ id: "org-idp-123", type: 0 }],
});
mockIdpTypeToSlug.mockReturnValue("google");
mockStartIdentityProviderFlow.mockResolvedValue("https://org-idp.example.com/auth");
const result = await sendLoginname({
loginName: "user@example.com",
});
expect(result).toEqual({ redirect: "https://org-idp.example.com/auth" });
expect(mockGetActiveIdentityProviders).toHaveBeenCalledWith({
serviceUrl: "https://api.example.com",
orgId: "org123", // User's organization from resourceOwner
});
});
test("should redirect to passkey when user has only passkey method and it's allowed", async () => {
mockGetLoginSettings.mockResolvedValue({ passkeysType: PasskeysType.ALLOWED });
mockListAuthenticationMethodTypes.mockResolvedValue({
@@ -373,6 +397,7 @@ describe("sendLoginname", () => {
authMethodTypes: [AuthenticationMethodType.PASSWORD],
});
mockListIDPLinks.mockResolvedValue({ result: [] });
mockGetActiveIdentityProviders.mockResolvedValue({ identityProviders: [] });
const result = await sendLoginname({
loginName: "user@example.com",

View File

@@ -23,6 +23,7 @@ import {
} from "../zitadel";
import { createSessionAndUpdateCookie } from "./cookie";
import { getOriginalHost } from "./host";
import { IDPLink } from "@zitadel/proto/zitadel/user/v2/idp_pb";
export type SendLoginnameCommand = {
loginName: string;
@@ -68,38 +69,55 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const { result: potentialUsers } = searchResult;
const redirectUserToSingleIDPIfAvailable = async () => {
const identityProviders = await getActiveIdentityProviders({
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({
serviceUrl,
orgId: command.organization,
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({
serviceUrl,
orgId: organization,
}).then((resp) => {
return resp.identityProviders;
});
if (identityProviders.length === 1) {
// If exactly one active IDP exists in the organization, redirect to it
if (activeIdps.length === 1) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const host = await getOriginalHost();
const identityProviderType = identityProviders[0].type;
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 (command.organization) {
params.set("organization", command.organization);
if (organization) {
params.set("organization", organization);
}
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const url = await startIdentityProviderFlow({
serviceUrl,
idpId: identityProviders[0].id,
idpId: activeIdps[0].id,
urls: {
successUrl:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/success?` +
@@ -116,15 +134,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
return { redirect: url };
}
};
const redirectUserToIDP = async (userId: string) => {
const identityProviders = await listIDPLinks({
serviceUrl,
userId,
}).then((resp) => {
return resp.result;
});
}
if (identityProviders.length === 1) {
const _headers = await headers();
@@ -147,14 +157,18 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const identityProviderType = idpTypeToIdentityProviderType(idpType);
const provider = idpTypeToSlug(identityProviderType);
const params = new URLSearchParams({ userId });
const params = new URLSearchParams();
if (userId) {
params.set("userId", userId);
}
if (command.requestId) {
params.set("requestId", command.requestId);
}
if (command.organization) {
params.set("organization", command.organization);
if (organization) {
params.set("organization", organization);
}
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
@@ -240,6 +254,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
return { error: t("errors.initialUserNotSupported") };
}
// Resolve organization from command or session
const organization = command.organization ?? session.factors?.user?.organizationId;
const methods = await listAuthenticationMethodTypes({
serviceUrl,
userId: session.factors?.user?.id,
@@ -250,15 +267,15 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
send: "true", // set this to true to request a new code immediately
invite: "true",
invite: "true", // humanUser?.email?.isVerified ? "false" : "true", // sendInviteEmailCode results in an error if user is already initialized
});
if (command.requestId) {
params.append("requestId", command.requestId);
}
if (command.organization || session.factors?.user?.organizationId) {
params.append("organization", command.organization ?? (session.factors?.user?.organizationId as string));
if (organization) {
params.append("organization", organization);
}
return { redirect: `/verify?` + params };
@@ -270,7 +287,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
case AuthenticationMethodType.PASSWORD: // user has only password as auth method
if (!userLoginSettings?.allowUsernamePassword) {
// Check if user has IDPs available as alternative, that could eventually be used to register/link.
const idpResp = await redirectUserToIDP(userId);
const idpResp = await redirectUserToIDP(userId, organization);
if (idpResp?.redirect) {
return idpResp;
}
@@ -286,8 +303,8 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// TODO: does this have to be checked in loginSettings.allowDomainDiscovery
if (command.organization || session.factors?.user?.organizationId) {
paramsPassword.append("organization", command.organization ?? session.factors?.user?.organizationId);
if (organization) {
paramsPassword.append("organization", organization);
}
if (command.requestId) {
@@ -312,14 +329,14 @@ export async function sendLoginname(command: SendLoginnameCommand) {
paramsPasskey.append("requestId", command.requestId);
}
if (command.organization || session.factors?.user?.organizationId) {
paramsPasskey.append("organization", command.organization ?? session.factors?.user?.organizationId);
if (organization) {
paramsPasskey.append("organization", organization);
}
return { redirect: "/passkey?" + paramsPasskey };
case AuthenticationMethodType.IDP:
const resp = await redirectUserToIDP(userId);
const resp = await redirectUserToIDP(userId, organization);
if (resp?.error) {
return { error: resp.error };
@@ -339,13 +356,13 @@ export async function sendLoginname(command: SendLoginnameCommand) {
passkeyParams.append("requestId", command.requestId);
}
if (command.organization || session.factors?.user?.organizationId) {
passkeyParams.append("organization", command.organization ?? session.factors?.user?.organizationId);
if (organization) {
passkeyParams.append("organization", organization);
}
return { redirect: "/passkey?" + passkeyParams };
} else if (methods.authMethodTypes.includes(AuthenticationMethodType.IDP)) {
return redirectUserToIDP(userId);
return redirectUserToIDP(userId, organization);
} else if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD)) {
// Check if password authentication is allowed
if (!userLoginSettings?.allowUsernamePassword) {
@@ -363,8 +380,8 @@ export async function sendLoginname(command: SendLoginnameCommand) {
paramsPasswordDefault.append("requestId", command.requestId);
}
if (command.organization || session.factors?.user?.organizationId) {
paramsPasswordDefault.append("organization", command.organization ?? session.factors?.user?.organizationId);
if (organization) {
paramsPasswordDefault.append("organization", organization);
}
return {
@@ -376,7 +393,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// user not found, check if register is enabled on instance / organization context
if (loginSettingsByContext?.allowRegister && !loginSettingsByContext?.allowUsernamePassword) {
const resp = await redirectUserToSingleIDPIfAvailable();
const resp = await redirectUserToIDP(undefined, command.organization);
if (resp) {
return resp;
}