From ff869482b102b659f46d8798241c611c0bbadcf6 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 21 Oct 2025 11:04:33 +0200 Subject: [PATCH] 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 --- apps/login/locales/de.json | 4 +- apps/login/locales/en.json | 4 +- apps/login/locales/es.json | 4 +- apps/login/locales/it.json | 4 +- apps/login/locales/ja.json | 15 ++ apps/login/locales/pl.json | 4 +- apps/login/locales/ru.json | 4 +- apps/login/locales/zh.json | 4 +- .../app/(login)/authenticator/set/page.tsx | 1 + apps/login/src/app/(login)/verify/page.tsx | 22 ++- .../choose-authenticator-to-setup.tsx | 39 ++--- apps/login/src/lib/server/loginname.test.ts | 25 +++ apps/login/src/lib/server/loginname.ts | 157 ++++++++++-------- 13 files changed, 175 insertions(+), 112 deletions(-) diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 25c93cbd4..ad68f8e13 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -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": { diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index dd7a2685d..0a77a48f1 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -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": { diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 8929a9d0b..1a2b9714c 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -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": { diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 51ab723fa..44e2fdbf6 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -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": { diff --git a/apps/login/locales/ja.json b/apps/login/locales/ja.json index 5e5abeb21..94b2fba72 100644 --- a/apps/login/locales/ja.json +++ b/apps/login/locales/ja.json @@ -287,6 +287,21 @@ "required": { "code": "必須項目です" } + }, + "errors": { + "couldNotResendEmail": "メールを再送信できませんでした", + "couldNotVerifyUser": "ユーザーを確認できませんでした", + "couldNotVerifyInvite": "招待を確認できませんでした", + "couldNotVerifyEmail": "メールアドレスを確認できませんでした", + "couldNotVerify": "確認できませんでした", + "couldNotLoadUser": "ユーザーを読み込めませんでした", + "couldNotLoadAuthenticators": "認証方法を読み込めませんでした", + "couldNotCreateSession": "セッションを作成できませんでした", + "noHostFound": "ホストが見つかりません", + "userAlreadyVerified": "ユーザーは既に確認済みです!", + "couldNotResendInvite": "招待を再送信できませんでした", + "inviteSendFailed": "招待メールの送信に失敗しました", + "emailSendFailed": "確認メールの送信に失敗しました" } }, "authenticator": { diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 1f62bca4d..dee81bc09 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -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": { diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index 5eb2eb17c..0b4b8855d 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -386,7 +386,9 @@ "couldNotCreateSession": "Не удалось создать сеанс", "noHostFound": "Хост не найден", "userAlreadyVerified": "Пользователь уже подтверждён!", - "couldNotResendInvite": "Не удалось повторно отправить приглашение" + "couldNotResendInvite": "Не удалось повторно отправить приглашение", + "inviteSendFailed": "Не удалось отправить письмо с приглашением", + "emailSendFailed": "Не удалось отправить письмо с подтверждением" } }, "authenticator": { diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 429da9bfb..c642f870b 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -386,7 +386,9 @@ "couldNotCreateSession": "无法创建会话", "noHostFound": "未找到主机", "userAlreadyVerified": "用户已验证!", - "couldNotResendInvite": "无法重新发送邀请" + "couldNotResendInvite": "无法重新发送邀请", + "inviteSendFailed": "发送邀请邮件失败", + "emailSendFailed": "发送验证邮件失败" } }, "authenticator": { diff --git a/apps/login/src/app/(login)/authenticator/set/page.tsx b/apps/login/src/app/(login)/authenticator/set/page.tsx index c52d408eb..a78239bff 100644 --- a/apps/login/src/app/(login)/authenticator/set/page.tsx +++ b/apps/login/src/app/(login)/authenticator/set/page.tsx @@ -193,6 +193,7 @@ export default async function Page(props: { searchParams: Promise }) { 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 }) { 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 }) { 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 }) {
+ {error && ( +
+ + + +
+ )} + {!id && (
diff --git a/apps/login/src/components/choose-authenticator-to-setup.tsx b/apps/login/src/components/choose-authenticator-to-setup.tsx index 9eb744f3a..780fb3ab7 100644 --- a/apps/login/src/components/choose-authenticator-to-setup.tsx +++ b/apps/login/src/components/choose-authenticator-to-setup.tsx @@ -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 ( @@ -26,26 +19,14 @@ export function ChooseAuthenticatorToSetup({ ); } else { return ( - <> - {loginSettings.passkeysType == PasskeysType.NOT_ALLOWED && - !loginSettings.allowUsernamePassword && ( - - - - )} - -
- {!authMethods.includes(AuthenticationMethodType.PASSWORD) && - loginSettings.allowUsernamePassword && - PASSWORD(false, "/password/set?" + params)} - {!authMethods.includes(AuthenticationMethodType.PASSKEY) && - loginSettings.passkeysType == PasskeysType.ALLOWED && - PASSKEYS(false, "/passkey/set?" + params)} -
- +
+ {!authMethods.includes(AuthenticationMethodType.PASSWORD) && + loginSettings.allowUsernamePassword && + PASSWORD(false, "/password/set?" + params)} + {!authMethods.includes(AuthenticationMethodType.PASSKEY) && + loginSettings.passkeysType == PasskeysType.ALLOWED && + PASSKEYS(false, "/passkey/set?" + params)} +
); } } diff --git a/apps/login/src/lib/server/loginname.test.ts b/apps/login/src/lib/server/loginname.test.ts index b483aa0d7..bd3e81441 100644 --- a/apps/login/src/lib/server/loginname.test.ts +++ b/apps/login/src/lib/server/loginname.test.ts @@ -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", diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index b56bedc77..4bd0ef6df 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -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,63 +69,72 @@ export async function sendLoginname(command: SendLoginnameCommand) { const { result: potentialUsers } = searchResult; - const redirectUserToSingleIDPIfAvailable = async () => { - const identityProviders = await getActiveIdentityProviders({ - serviceUrl, - orgId: command.organization, - }).then((resp) => { - return resp.identityProviders; - }); - - if (identityProviders.length === 1) { - const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const host = await getOriginalHost(); - - const identityProviderType = identityProviders[0].type; - - const provider = idpTypeToSlug(identityProviderType); - - const params = new URLSearchParams(); - - if (command.requestId) { - params.set("requestId", command.requestId); - } - - if (command.organization) { - params.set("organization", command.organization); - } - - const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; - - const url = await startIdentityProviderFlow({ + 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, - idpId: identityProviders[0].id, - urls: { - successUrl: - `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/success?` + - new URLSearchParams(params), - failureUrl: - `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` + - new URLSearchParams(params), - }, + 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 (!url) { - return { error: t("errors.couldNotStartIDPFlow") }; + // 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 = 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 url = await startIdentityProviderFlow({ + serviceUrl, + idpId: activeIdps[0].id, + urls: { + successUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/success?` + + new URLSearchParams(params), + failureUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` + + new URLSearchParams(params), + }, + }); + + if (!url) { + return { error: t("errors.couldNotStartIDPFlow") }; + } + + return { redirect: url }; } - - 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; }