diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index ad68f8e13cd..edef348d0a9 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -159,18 +159,6 @@ "signInWithAzureAD": "Mit AzureAD anmelden", "signInWithGithub": "Mit GitHub anmelden", "signInWithGitlab": "Mit GitLab anmelden", - "loginSuccess": { - "title": "Anmeldung erfolgreich", - "description": "Sie haben sich erfolgreich angemeldet!" - }, - "linkingSuccess": { - "title": "Konto verknüpft", - "description": "Sie haben Ihr Konto erfolgreich verknüpft!" - }, - "registerSuccess": { - "title": "Registrierung erfolgreich", - "description": "Sie haben sich erfolgreich registriert!" - }, "loginError": { "title": "Anmeldung fehlgeschlagen", "description": "Beim Anmelden ist ein Fehler aufgetreten." @@ -194,6 +182,23 @@ "description": "Wir konnten den Registrierungsprozess nicht abschließen.", "info": "Die Organisation für die Registrierung konnte nicht ermittelt werden. Bitte wenden Sie sich an Ihren Administrator.", "backToLogin": "Zurück zur Anmeldung" + }, + "processing": { + "message": "Authentifizierung wird verarbeitet...", + "noRedirect": "Keine Weiterleitung oder Fehler vom Server erhalten", + "unexpectedError": "Ein unerwarteter Fehler ist aufgetreten" + }, + "errors": { + "missingParameters": "Erforderliche Parameter fehlen", + "missingIdpInfo": "IDP-Informationen fehlen", + "idpNotFound": "Identitätsanbieter nicht gefunden", + "linkingNotAllowed": "Verknüpfung ist für diesen Identitätsanbieter nicht erlaubt", + "linkingFailed": "Verknüpfung des Identitätsanbieters zum Konto fehlgeschlagen", + "autoLinkingFailed": "Automatische Verknüpfung des Kontos fehlgeschlagen", + "userCreationFailed": "Erstellen des Benutzerkontos fehlgeschlagen", + "orgResolutionFailed": "Organisation für Registrierung konnte nicht ermittelt werden", + "sessionCreationFailed": "Sitzung konnte nicht erstellt oder Weiterleitung konnte nicht ermittelt werden", + "unknownError": "Ein unbekannter Fehler ist aufgetreten" } }, "ldap": { diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 0a77a48f1a6..88a32244ba5 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -159,18 +159,6 @@ "signInWithAzureAD": "Sign in with AzureAD", "signInWithGithub": "Sign in with GitHub", "signInWithGitlab": "Sign in with GitLab", - "loginSuccess": { - "title": "Login successful", - "description": "You have successfully been loggedIn!" - }, - "linkingSuccess": { - "title": "Account linked", - "description": "You have successfully linked your account!" - }, - "registerSuccess": { - "title": "Registration successful", - "description": "You have successfully registered!" - }, "loginError": { "title": "Login failed", "description": "An error occurred while trying to login." @@ -194,6 +182,23 @@ "description": "We couldn't complete the registration process.", "info": "Unable to determine the organization for registration. Please contact your administrator for assistance.", "backToLogin": "Back to Login" + }, + "processing": { + "message": "Processing authentication...", + "noRedirect": "No redirect or error returned from server", + "unexpectedError": "An unexpected error occurred" + }, + "errors": { + "missingParameters": "Missing required parameters", + "missingIdpInfo": "IDP information missing", + "idpNotFound": "Identity provider not found", + "linkingNotAllowed": "Linking is not allowed for this identity provider", + "linkingFailed": "Failed to link identity provider to account", + "autoLinkingFailed": "Failed to automatically link account", + "userCreationFailed": "Failed to create user account", + "orgResolutionFailed": "Could not determine organization for registration", + "sessionCreationFailed": "Could not create session or determine redirect", + "unknownError": "An unknown error occurred" } }, "ldap": { diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 1a2b9714ccb..b1593d1b068 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -159,18 +159,6 @@ "signInWithAzureAD": "Iniciar sesión con AzureAD", "signInWithGithub": "Iniciar sesión con GitHub", "signInWithGitlab": "Iniciar sesión con GitLab", - "loginSuccess": { - "title": "Inicio de sesión exitoso", - "description": "¡Has iniciado sesión con éxito!" - }, - "linkingSuccess": { - "title": "Cuenta vinculada", - "description": "¡Has vinculado tu cuenta con éxito!" - }, - "registerSuccess": { - "title": "Registro exitoso", - "description": "¡Te has registrado con éxito!" - }, "loginError": { "title": "Error de inicio de sesión", "description": "Ocurrió un error al intentar iniciar sesión." @@ -194,6 +182,23 @@ "description": "No pudimos completar el proceso de registro.", "info": "No se pudo determinar la organización para el registro. Por favor, contacta a tu administrador para obtener asistencia.", "backToLogin": "Volver al inicio de sesión" + }, + "processing": { + "message": "Procesando autenticación...", + "noRedirect": "No se recibió redirección ni error del servidor", + "unexpectedError": "Ocurrió un error inesperado" + }, + "errors": { + "missingParameters": "Faltan parámetros requeridos", + "missingIdpInfo": "Falta información del IDP", + "idpNotFound": "Proveedor de identidad no encontrado", + "linkingNotAllowed": "La vinculación no está permitida para este proveedor de identidad", + "linkingFailed": "Error al vincular el proveedor de identidad con la cuenta", + "autoLinkingFailed": "Error al vincular automáticamente la cuenta", + "userCreationFailed": "Error al crear la cuenta de usuario", + "orgResolutionFailed": "No se pudo determinar la organización para el registro", + "sessionCreationFailed": "No se pudo crear la sesión o determinar la redirección", + "unknownError": "Ocurrió un error desconocido" } }, "ldap": { diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 44e2fdbf660..3e0ab4608c3 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -159,18 +159,6 @@ "signInWithAzureAD": "Accedi con AzureAD", "signInWithGithub": "Accedi con GitHub", "signInWithGitlab": "Accedi con GitLab", - "loginSuccess": { - "title": "Accesso riuscito", - "description": "Accesso effettuato con successo!" - }, - "linkingSuccess": { - "title": "Account collegato", - "description": "Hai collegato con successo il tuo account!" - }, - "registerSuccess": { - "title": "Registrazione riuscita", - "description": "Registrazione effettuata con successo!" - }, "loginError": { "title": "Accesso fallito", "description": "Si è verificato un errore durante il tentativo di accesso." @@ -194,6 +182,23 @@ "description": "Non siamo riusciti a completare il processo di registrazione.", "info": "Impossibile determinare l'organizzazione per la registrazione. Contatta il tuo amministratore per assistenza.", "backToLogin": "Torna al login" + }, + "processing": { + "message": "Elaborazione autenticazione in corso...", + "noRedirect": "Nessun reindirizzamento o errore ricevuto dal server", + "unexpectedError": "Si è verificato un errore imprevisto" + }, + "errors": { + "missingParameters": "Parametri richiesti mancanti", + "missingIdpInfo": "Informazioni IDP mancanti", + "idpNotFound": "Provider di identità non trovato", + "linkingNotAllowed": "Il collegamento non è consentito per questo provider di identità", + "linkingFailed": "Collegamento del provider di identità all'account non riuscito", + "autoLinkingFailed": "Collegamento automatico dell'account non riuscito", + "userCreationFailed": "Creazione dell'account utente non riuscita", + "orgResolutionFailed": "Impossibile determinare l'organizzazione per la registrazione", + "sessionCreationFailed": "Impossibile creare la sessione o determinare il reindirizzamento", + "unknownError": "Si è verificato un errore sconosciuto" } }, "ldap": { diff --git a/apps/login/locales/ja.json b/apps/login/locales/ja.json index 94b2fba722b..4a97aade72b 100644 --- a/apps/login/locales/ja.json +++ b/apps/login/locales/ja.json @@ -91,18 +91,6 @@ "signInWithAzureAD": "AzureADでサインイン", "signInWithGithub": "GitHubでサインイン", "signInWithGitlab": "GitLabでサインイン", - "loginSuccess": { - "title": "ログイン成功", - "description": "正常にログインしました!" - }, - "linkingSuccess": { - "title": "アカウント連携完了", - "description": "アカウントの連携が完了しました!" - }, - "registerSuccess": { - "title": "登録完了", - "description": "正常に登録されました!" - }, "loginError": { "title": "ログイン失敗", "description": "ログインの際にエラーが発生しました。" @@ -114,6 +102,35 @@ "completeRegister": { "title": "データを完成させる", "description": "メールアドレスと名前を入力して登録を完了してください。" + }, + "accountNotFound": { + "title": "アカウントが見つかりません", + "description": "アイデンティティプロバイダの認証情報に関連付けられたアカウントが見つかりませんでした。", + "info": "既存のアカウントが見つかりませんでした。既存のアカウントでサインインするか、管理者にお問い合わせください。", + "backToLogin": "ログインに戻る" + }, + "registrationFailed": { + "title": "登録が利用できません", + "description": "登録プロセスを完了できませんでした。", + "info": "登録する組織を特定できませんでした。管理者にお問い合わせください。", + "backToLogin": "ログインに戻る" + }, + "processing": { + "message": "認証を処理しています...", + "noRedirect": "サーバーからリダイレクトまたはエラーが返されませんでした", + "unexpectedError": "予期しないエラーが発生しました" + }, + "errors": { + "missingParameters": "必要なパラメータが不足しています", + "missingIdpInfo": "IDP情報が不足しています", + "idpNotFound": "アイデンティティプロバイダが見つかりません", + "linkingNotAllowed": "このアイデンティティプロバイダとの連携は許可されていません", + "linkingFailed": "アイデンティティプロバイダのアカウントへの連携に失敗しました", + "autoLinkingFailed": "アカウントの自動連携に失敗しました", + "userCreationFailed": "ユーザーアカウントの作成に失敗しました", + "orgResolutionFailed": "登録する組織を特定できませんでした", + "sessionCreationFailed": "セッションの作成またはリダイレクトの決定に失敗しました", + "unknownError": "不明なエラーが発生しました" } }, "ldap": { diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index dee81bc0902..2f33f309f2e 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -159,18 +159,6 @@ "signInWithAzureAD": "Zaloguj się przez AzureAD", "signInWithGithub": "Zaloguj się przez GitHub", "signInWithGitlab": "Zaloguj się przez GitLab", - "loginSuccess": { - "title": "Logowanie udane", - "description": "Zostałeś pomyślnie zalogowany!" - }, - "linkingSuccess": { - "title": "Konto powiązane", - "description": "Pomyślnie powiązałeś swoje konto!" - }, - "registerSuccess": { - "title": "Rejestracja udana", - "description": "Pomyślnie się zarejestrowałeś!" - }, "loginError": { "title": "Logowanie nieudane", "description": "Wystąpił błąd podczas próby logowania." @@ -194,6 +182,23 @@ "description": "Nie mogliśmy ukończyć procesu rejestracji.", "info": "Nie można określić organizacji do rejestracji. Skontaktuj się z administratorem w celu uzyskania pomocy.", "backToLogin": "Powrót do logowania" + }, + "processing": { + "message": "Przetwarzanie uwierzytelniania...", + "noRedirect": "Nie otrzymano przekierowania ani błędu z serwera", + "unexpectedError": "Wystąpił nieoczekiwany błąd" + }, + "errors": { + "missingParameters": "Brakuje wymaganych parametrów", + "missingIdpInfo": "Brak informacji IDP", + "idpNotFound": "Dostawca tożsamości nie znaleziony", + "linkingNotAllowed": "Łączenie nie jest dozwolone dla tego dostawcy tożsamości", + "linkingFailed": "Nie udało się połączyć dostawcy tożsamości z kontem", + "autoLinkingFailed": "Nie udało się automatycznie połączyć konta", + "userCreationFailed": "Nie udało się utworzyć konta użytkownika", + "orgResolutionFailed": "Nie można określić organizacji do rejestracji", + "sessionCreationFailed": "Nie można utworzyć sesji lub określić przekierowania", + "unknownError": "Wystąpił nieznany błąd" } }, "ldap": { diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index 0b4b8855d82..377ed02047a 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -159,18 +159,6 @@ "signInWithAzureAD": "Войти через AzureAD", "signInWithGithub": "Войти через GitHub", "signInWithGitlab": "Войти через GitLab", - "loginSuccess": { - "title": "Вход выполнен успешно", - "description": "Вы успешно вошли в систему!" - }, - "linkingSuccess": { - "title": "Аккаунт привязан", - "description": "Аккаунт успешно привязан!" - }, - "registerSuccess": { - "title": "Регистрация завершена", - "description": "Вы успешно зарегистрировались!" - }, "loginError": { "title": "Ошибка входа", "description": "Произошла ошибка при попытке входа." @@ -194,6 +182,23 @@ "description": "Мы не смогли завершить процесс регистрации.", "info": "Не удалось определить организацию для регистрации. Пожалуйста, обратитесь к администратору за помощью.", "backToLogin": "Вернуться к входу" + }, + "processing": { + "message": "Обработка аутентификации...", + "noRedirect": "Не получено перенаправления или ошибки от сервера", + "unexpectedError": "Произошла неожиданная ошибка" + }, + "errors": { + "missingParameters": "Отсутствуют обязательные параметры", + "missingIdpInfo": "Отсутствует информация IDP", + "idpNotFound": "Провайдер идентификации не найден", + "linkingNotAllowed": "Связывание не разрешено для этого провайдера идентификации", + "linkingFailed": "Не удалось связать провайдера идентификации с учетной записью", + "autoLinkingFailed": "Не удалось автоматически связать учетную запись", + "userCreationFailed": "Не удалось создать учетную запись пользователя", + "orgResolutionFailed": "Не удалось определить организацию для регистрации", + "sessionCreationFailed": "Не удалось создать сеанс или определить перенаправление", + "unknownError": "Произошла неизвестная ошибка" } }, "ldap": { diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index c642f870b46..842b38e84fe 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -159,18 +159,6 @@ "signInWithAzureAD": "用 AzureAD 登录", "signInWithGithub": "用 GitHub 登录", "signInWithGitlab": "用 GitLab 登录", - "loginSuccess": { - "title": "登录成功", - "description": "您已成功登录!" - }, - "linkingSuccess": { - "title": "账户已链接", - "description": "您已成功链接您的账户!" - }, - "registerSuccess": { - "title": "注册成功", - "description": "您已成功注册!" - }, "loginError": { "title": "登录失败", "description": "登录时发生错误。" @@ -194,6 +182,23 @@ "description": "我们无法完成注册过程。", "info": "无法确定注册的组织。请联系您的管理员寻求帮助。", "backToLogin": "返回登录" + }, + "processing": { + "message": "正在处理身份验证...", + "noRedirect": "未从服务器收到重定向或错误", + "unexpectedError": "发生意外错误" + }, + "errors": { + "missingParameters": "缺少必需参数", + "missingIdpInfo": "缺少身份提供商信息", + "idpNotFound": "未找到身份提供商", + "linkingNotAllowed": "此身份提供商不允许关联", + "linkingFailed": "无法将身份提供商关联到账户", + "autoLinkingFailed": "无法自动关联账户", + "userCreationFailed": "无法创建用户账户", + "orgResolutionFailed": "无法确定注册的组织", + "sessionCreationFailed": "无法创建会话或确定重定向", + "unknownError": "发生未知错误" } }, "ldap": { diff --git a/apps/login/src/app/(login)/idp/[provider]/complete-registration/page.tsx b/apps/login/src/app/(login)/idp/[provider]/complete-registration/page.tsx new file mode 100644 index 00000000000..e7167e158f2 --- /dev/null +++ b/apps/login/src/app/(login)/idp/[provider]/complete-registration/page.tsx @@ -0,0 +1,61 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { RegisterFormIDPIncomplete } from "@/components/register-form-idp-incomplete"; +import { Translated } from "@/components/translated"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getBrandingSettings } from "@/lib/zitadel"; +import { headers } from "next/headers"; + +/** + * Complete registration page - shown when manual user registration is required + */ +export default async function CompleteRegistrationPage(props: { + searchParams: Promise>; + params: Promise<{ provider: string }>; +}) { + const searchParams = await props.searchParams; + const { id, token, requestId, organization, idpId, idpUserId, idpUserName, givenName, familyName, email } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + if (!id || !token || !idpId || !organization || !idpUserId || !idpUserName) { + throw new Error("Missing required parameters"); + } + + return ( + +
+

+ +

+

+ +

+
+ +
+ +
+
+ ); +} diff --git a/apps/login/src/app/(login)/idp/[provider]/linking-failed/page.tsx b/apps/login/src/app/(login)/idp/[provider]/linking-failed/page.tsx new file mode 100644 index 00000000000..2b5e9227203 --- /dev/null +++ b/apps/login/src/app/(login)/idp/[provider]/linking-failed/page.tsx @@ -0,0 +1,38 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getBrandingSettings } from "@/lib/zitadel"; +import { headers } from "next/headers"; + +/** + * Linking failed page - shown when IDP linking fails + */ +export default async function LinkingFailedPage(props: { + searchParams: Promise>; + params: Promise<{ provider: string }>; +}) { + const searchParams = await props.searchParams; + const { organization, error } = searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + return ( + +
+

+ +

+

+ +

+ {error &&

{error}

} +
+
+ ); +} diff --git a/apps/login/src/app/(login)/idp/[provider]/process/page.tsx b/apps/login/src/app/(login)/idp/[provider]/process/page.tsx new file mode 100644 index 00000000000..469db78e994 --- /dev/null +++ b/apps/login/src/app/(login)/idp/[provider]/process/page.tsx @@ -0,0 +1,34 @@ +import { IdpProcessHandler } from "@/components/idp-process-handler"; + +/** + * This page handles the initial IDP callback with the single-use token. + * It delegates to a client component which calls the server action. + * The client component is needed so that cookies can be set properly. + */ +export default async function ProcessPage(props: { + searchParams: Promise>; + params: Promise<{ provider: string }>; +}) { + const params = await props.params; + const searchParams = await props.searchParams; + + const { provider } = params; + const { id, token, requestId, organization, link, postErrorRedirectUrl } = searchParams; + + // Validate required parameters before passing to client component + if (!id || !token) { + throw new Error("Missing required IDP callback parameters"); + } + + return ( + + ); +} diff --git a/apps/login/src/app/(login)/idp/[provider]/success/page.tsx b/apps/login/src/app/(login)/idp/[provider]/success/page.tsx deleted file mode 100644 index 8c0e9280562..00000000000 --- a/apps/login/src/app/(login)/idp/[provider]/success/page.tsx +++ /dev/null @@ -1,324 +0,0 @@ -import { DynamicTheme } from "@/components/dynamic-theme"; -import { IdpSignin } from "@/components/idp-signin"; -import { completeIDP } from "@/components/idps/pages/complete-idp"; -import { linkingFailed } from "@/components/idps/pages/linking-failed"; -import { linkingSuccess } from "@/components/idps/pages/linking-success"; -import { loginFailed } from "@/components/idps/pages/login-failed"; -import { loginSuccess } from "@/components/idps/pages/login-success"; -import { Translated } from "@/components/translated"; -import { getServiceUrlFromHeaders } from "@/lib/service-url"; -import { - addHuman, - addIDPLink, - getBrandingSettings, - getDefaultOrg, - getIDPByID, - getLoginSettings, - getOrgsByDomain, - listUsers, - retrieveIDPIntent, - updateHuman, -} from "@/lib/zitadel"; -import { ConnectError, create } from "@zitadel/client"; -import { redirect } from "next/navigation"; -import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb"; -import { OrganizationSchema } from "@zitadel/proto/zitadel/object/v2/object_pb"; -import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; -import { - AddHumanUserRequest, - AddHumanUserRequestSchema, - UpdateHumanUserRequestSchema, -} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; -import { headers } from "next/headers"; - -const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; - -async function resolveOrganizationForUser({ - organization, - addHumanUser, - serviceUrl, -}: { - organization?: string; - addHumanUser?: { username?: string }; - serviceUrl: string; -}): Promise { - if (organization) return organization; - - if (addHumanUser?.username && ORG_SUFFIX_REGEX.test(addHumanUser.username)) { - const matched = ORG_SUFFIX_REGEX.exec(addHumanUser.username); - const suffix = matched?.[1] ?? ""; - - const orgs = await getOrgsByDomain({ - serviceUrl, - domain: suffix, - }); - const orgToCheckForDiscovery = orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; - - if (orgToCheckForDiscovery) { - const orgLoginSettings = await getLoginSettings({ - serviceUrl, - organization: orgToCheckForDiscovery, - }); - if (orgLoginSettings?.allowDomainDiscovery) { - return orgToCheckForDiscovery; - } - } - } - return undefined; -} - -export default async function Page(props: { - searchParams: Promise>; - params: Promise<{ provider: string }>; -}) { - const params = await props.params; - const searchParams = await props.searchParams; - let { id, token, requestId, organization, link, postErrorRedirectUrl } = searchParams; - const { provider } = params; - - const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - - let branding = await getBrandingSettings({ - serviceUrl, - organization, - }); - - if (!organization) { - const org: Organization | null = await getDefaultOrg({ - serviceUrl, - }); - if (org) { - organization = org.id; - } - } - - if (!provider || !id || !token) { - return loginFailed(branding, "IDP context missing"); - } - - const intent = await retrieveIDPIntent({ - serviceUrl, - id, - token, - }); - - const { idpInformation, userId } = intent; - let { addHumanUser } = intent; - - if (!idpInformation) { - return loginFailed(branding, "IDP information missing"); - } - - const idp = await getIDPByID({ - serviceUrl, - id: idpInformation.idpId, - }); - - const options = idp?.config?.options; - - if (!idp) { - throw new Error("IDP not found"); - } - - // sign in user. If user should be linked continue - if (userId && !link) { - // if auto update is enabled, we will update the user with the new information - if (options?.isAutoUpdate && addHumanUser) { - try { - await updateHuman({ - serviceUrl, - request: create(UpdateHumanUserRequestSchema, { - userId: userId, - profile: addHumanUser.profile, - email: addHumanUser.email, - phone: addHumanUser.phone, - }), - }); - } catch (error: unknown) { - // Log the error and continue with the login process - console.warn("An error occurred while updating the user:", error); - } - } - - return loginSuccess(userId, { idpIntentId: id, idpIntentToken: token }, requestId, branding); - } - - if (link) { - if (!options?.isLinkingAllowed) { - // linking was probably disallowed since the invitation was created - return linkingFailed(branding, "Linking is no longer allowed"); - } - - let idpLink; - try { - idpLink = await addIDPLink({ - serviceUrl, - idp: { - id: idpInformation.idpId, - userId: idpInformation.userId, - userName: idpInformation.userName, - }, - userId, - }); - } catch (error) { - console.error(error); - return linkingFailed(branding); - } - - if (!idpLink) { - return linkingFailed(branding); - } else { - return linkingSuccess(userId, { idpIntentId: id, idpIntentToken: token }, requestId, branding); - } - } - - // search for potential user via username, then link - if (options?.autoLinking) { - let foundUser; - const email = addHumanUser?.email?.email; - - if (options.autoLinking === AutoLinkingOption.EMAIL && email) { - foundUser = await listUsers({ serviceUrl, email, organizationId: organization }).then((response) => { - return response.result ? response.result[0] : null; - }); - } else if (options.autoLinking === AutoLinkingOption.USERNAME) { - foundUser = await listUsers({ serviceUrl, userName: idpInformation.userName, organizationId: organization }).then( - (response) => { - return response.result ? response.result[0] : null; - }, - ); - } else { - foundUser = await listUsers({ - serviceUrl, - userName: idpInformation.userName, - email, - organizationId: organization, - }).then((response) => { - return response.result ? response.result[0] : null; - }); - } - - if (foundUser) { - let idpLink; - try { - idpLink = await addIDPLink({ - serviceUrl, - idp: { - id: idpInformation.idpId, - userId: idpInformation.userId, - userName: idpInformation.userName, - }, - userId: foundUser.userId, - }); - } catch (error) { - console.error(error); - return linkingFailed(branding); - } - - if (!idpLink) { - return linkingFailed(branding); - } else { - return linkingSuccess(foundUser.userId, { idpIntentId: id, idpIntentToken: token }, requestId, branding); - } - } - } - - let newUser; - // automatic creation of a user is allowed and data is complete - if (options?.isAutoCreation && addHumanUser) { - const orgToRegisterOn = await resolveOrganizationForUser({ - organization, - addHumanUser, - serviceUrl, - }); - - let addHumanUserWithOrganization: AddHumanUserRequest; - if (orgToRegisterOn) { - const organizationSchema = create(OrganizationSchema, { - org: { case: "orgId", value: orgToRegisterOn }, - }); - - addHumanUserWithOrganization = create(AddHumanUserRequestSchema, { - ...addHumanUser, - organization: organizationSchema, - }); - } else { - addHumanUserWithOrganization = create(AddHumanUserRequestSchema, addHumanUser); - } - - try { - newUser = await addHuman({ - serviceUrl, - request: addHumanUserWithOrganization, - }); - } catch (error: unknown) { - console.error("An error occurred while creating the user:", error, addHumanUser); - return loginFailed( - branding, - (error as ConnectError).message ? (error as ConnectError).message : "Could not create user", - ); - } - } else if (options?.isCreationAllowed) { - // if no user was found, we will create a new user manually / redirect to the registration page - const orgToRegisterOn = await resolveOrganizationForUser({ - organization, - addHumanUser, - serviceUrl, - }); - - if (orgToRegisterOn) { - branding = await getBrandingSettings({ - serviceUrl, - organization: orgToRegisterOn, - }); - } - - if (!orgToRegisterOn) { - // Redirect to registration-failed page - couldn't determine organization for registration - const queryParams = new URLSearchParams(); - if (requestId) queryParams.set("requestId", requestId); - if (organization) queryParams.set("organization", organization); - if (postErrorRedirectUrl) queryParams.set("postErrorRedirectUrl", postErrorRedirectUrl); - redirect(`/idp/${provider}/registration-failed?${queryParams.toString()}`); - } - - return completeIDP({ - branding, - idpIntent: { idpIntentId: id, idpIntentToken: token }, - addHumanUser, - organization: orgToRegisterOn, - requestId, - idpUserId: idpInformation?.userId, - idpId: idpInformation?.idpId, - idpUserName: idpInformation?.userName, - }); - } - - if (newUser) { - return ( - -
-

- -

-

- -

-
- -
- -
-
- ); - } - - // Redirect to account-not-found page with postErrorRedirectUrl - // This provides a graceful fallback when no user was found and creation/linking is not allowed - const queryParams = new URLSearchParams(); - if (requestId) queryParams.set("requestId", requestId); - if (organization) queryParams.set("organization", organization); - if (postErrorRedirectUrl) queryParams.set("postErrorRedirectUrl", postErrorRedirectUrl); - redirect(`/idp/${provider}/account-not-found?${queryParams.toString()}`); -} diff --git a/apps/login/src/components/idp-process-handler.tsx b/apps/login/src/components/idp-process-handler.tsx new file mode 100644 index 00000000000..76f70f807c2 --- /dev/null +++ b/apps/login/src/components/idp-process-handler.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { processIDPCallback } from "@/lib/server/idp-intent"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { Alert } from "./alert"; +import { Spinner } from "./spinner"; + +type Props = { + provider: string; + id: string; + token: string; + requestId?: string; + organization?: string; + link?: string; + postErrorRedirectUrl?: string; +}; + +/** + * Client component that handles IDP callback processing. + * Must be client-side to allow cookie modifications via server actions. + */ +export function IdpProcessHandler({ provider, id, token, requestId, organization, link, postErrorRedirectUrl }: Props) { + const t = useTranslations("idp"); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const executedRef = useRef(false); + const router = useRouter(); + + useEffect(() => { + // Prevent double execution in React Strict Mode + if (executedRef.current) { + return; + } + + executedRef.current = true; + + console.log("[IDP Process Handler] Starting IDP callback processing from client"); + + processIDPCallback({ + provider, + id, + token, + requestId, + organization, + link, + postErrorRedirectUrl, + }) + .then((result) => { + if (result.error) { + console.error("[IDP Process Handler] Error:", result.error); + setError(result.error); + setLoading(false); + return; + } + + if (result.redirect) { + console.log("[IDP Process Handler] Redirecting to:", result.redirect); + router.push(result.redirect); + return; + } + + setError(t("processing.noRedirect")); + setLoading(false); + }) + .catch((err) => { + console.error("[IDP Process Handler] Unexpected error:", err); + setError(err instanceof Error ? err.message : t("processing.unexpectedError")); + setLoading(false); + }); + }, [provider, id, token, requestId, organization, link, postErrorRedirectUrl, router]); + + return ( +
+ {loading && ( +
+ +

{t("processing.message")}

+
+ )} + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/apps/login/src/components/idp-signin.tsx b/apps/login/src/components/idp-signin.tsx deleted file mode 100644 index ec7da3fc8b8..00000000000 --- a/apps/login/src/components/idp-signin.tsx +++ /dev/null @@ -1,75 +0,0 @@ -"use client"; - -import { CreateNewSessionCommand, createNewSessionFromIdpIntent } from "@/lib/server/idp"; -import { useRouter } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; -import { Alert } from "./alert"; -import { Spinner } from "./spinner"; - -type Props = { - userId: string; - // organization: string; - idpIntent: { - idpIntentId: string; - idpIntentToken: string; - }; - requestId?: string; -}; - -export function IdpSignin({ userId, idpIntent: { idpIntentId, idpIntentToken }, requestId }: Props) { - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const executedRef = useRef(false); - - const router = useRouter(); - - useEffect(() => { - // Prevent double execution in React Strict Mode - if (executedRef.current) { - return; - } - - executedRef.current = true; - let request: CreateNewSessionCommand = { - userId, - idpIntent: { - idpIntentId, - idpIntentToken, - }, - }; - - if (requestId) { - request = { ...request, requestId: requestId }; - } - - createNewSessionFromIdpIntent(request) - .then((response) => { - if (response && "error" in response && response?.error) { - setError(response?.error); - return; - } - - if (response && "redirect" in response && response?.redirect) { - return router.push(response.redirect); - } - }) - .catch(() => { - setError("An internal error occurred"); - return; - }) - .finally(() => { - setLoading(false); - }); - }, []); - - return ( -
- {loading && } - {error && ( -
- {error} -
- )} -
- ); -} diff --git a/apps/login/src/components/idps/pages/complete-idp.tsx b/apps/login/src/components/idps/pages/complete-idp.tsx deleted file mode 100644 index d6781b96576..00000000000 --- a/apps/login/src/components/idps/pages/complete-idp.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { RegisterFormIDPIncomplete } from "@/components/register-form-idp-incomplete"; -import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; -import { AddHumanUserRequest } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; -import { DynamicTheme } from "../../dynamic-theme"; -import { Translated } from "../../translated"; - -export async function completeIDP({ - idpUserId, - idpId, - idpUserName, - addHumanUser, - requestId, - organization, - branding, - idpIntent, -}: { - idpUserId: string; - idpId: string; - idpUserName: string; - addHumanUser?: AddHumanUserRequest; - requestId?: string; - organization: string; - branding?: BrandingSettings; - idpIntent: { - idpIntentId: string; - idpIntentToken: string; - }; -}) { - return ( - -
-

- -

-

- -

-
- -
- -
-
- ); -} diff --git a/apps/login/src/components/idps/pages/linking-failed.tsx b/apps/login/src/components/idps/pages/linking-failed.tsx deleted file mode 100644 index e6fbe3f114a..00000000000 --- a/apps/login/src/components/idps/pages/linking-failed.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; -import { Alert, AlertType } from "../../alert"; -import { DynamicTheme } from "../../dynamic-theme"; -import { Translated } from "../../translated"; - -export async function linkingFailed(branding?: BrandingSettings, error?: string) { - return ( - -
-

- -

-

- -

- {error &&
{{error}}
} -
-
-
- ); -} diff --git a/apps/login/src/components/idps/pages/linking-success.tsx b/apps/login/src/components/idps/pages/linking-success.tsx deleted file mode 100644 index 4b7be47b13f..00000000000 --- a/apps/login/src/components/idps/pages/linking-success.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; -import { DynamicTheme } from "../../dynamic-theme"; -import { IdpSignin } from "../../idp-signin"; -import { Translated } from "../../translated"; - -export async function linkingSuccess( - userId: string, - idpIntent: { idpIntentId: string; idpIntentToken: string }, - requestId?: string, - branding?: BrandingSettings, -) { - return ( - -
-

- -

-

- -

-
- -
- -
-
- ); -} diff --git a/apps/login/src/components/idps/pages/login-failed.tsx b/apps/login/src/components/idps/pages/login-failed.tsx deleted file mode 100644 index 9788ca46117..00000000000 --- a/apps/login/src/components/idps/pages/login-failed.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; -import { Alert, AlertType } from "../../alert"; -import { DynamicTheme } from "../../dynamic-theme"; -import { Translated } from "../../translated"; - -export async function loginFailed(branding?: BrandingSettings, error?: string) { - return ( - -
-

- -

-

- -

- {error &&
{{error}}
} -
-
-
- ); -} diff --git a/apps/login/src/components/idps/pages/login-success.tsx b/apps/login/src/components/idps/pages/login-success.tsx deleted file mode 100644 index ba85547ec74..00000000000 --- a/apps/login/src/components/idps/pages/login-success.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; -import { DynamicTheme } from "../../dynamic-theme"; -import { IdpSignin } from "../../idp-signin"; -import { Translated } from "../../translated"; - -export async function loginSuccess( - userId: string, - idpIntent: { idpIntentId: string; idpIntentToken: string }, - requestId?: string, - branding?: BrandingSettings, -) { - return ( - -
-

- -

-

- -

-
- -
- -
-
- ); -} diff --git a/apps/login/src/lib/server/flow-initiation.ts b/apps/login/src/lib/server/flow-initiation.ts index a553bb80045..ab747d1cfd1 100644 --- a/apps/login/src/lib/server/flow-initiation.ts +++ b/apps/login/src/lib/server/flow-initiation.ts @@ -141,7 +141,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr serviceUrl, idpId, urls: { - successUrl: `${origin}/idp/${provider}/success?` + new URLSearchParams(params), + successUrl: `${origin}/idp/${provider}/process?` + new URLSearchParams(params), failureUrl: `${origin}/idp/${provider}/failure?` + new URLSearchParams(params), }, }); diff --git a/apps/login/src/lib/server/idp-intent.test.ts b/apps/login/src/lib/server/idp-intent.test.ts new file mode 100644 index 00000000000..c04c054a0fa --- /dev/null +++ b/apps/login/src/lib/server/idp-intent.test.ts @@ -0,0 +1,779 @@ +import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; +import { processIDPCallback } from "./idp-intent"; +import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb"; + +// Mock all the dependencies +vi.mock("next/headers", () => ({ + headers: vi.fn(), +})); + +vi.mock("@zitadel/client", () => ({ + create: vi.fn((schema: any, data: any) => data), +})); + +vi.mock("../service-url", () => ({ + getServiceUrlFromHeaders: vi.fn(), +})); + +vi.mock("../zitadel", () => ({ + retrieveIDPIntent: vi.fn(), + getIDPByID: vi.fn(), + updateHuman: vi.fn(), + addIDPLink: vi.fn(), + listUsers: vi.fn(), + addHuman: vi.fn(), + getLoginSettings: vi.fn(), + getOrgsByDomain: vi.fn(), +})); + +vi.mock("./idp", () => ({ + createNewSessionFromIdpIntent: vi.fn(), +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: vi.fn(() => (key: string) => key), +})); + +describe("processIDPCallback", () => { + // Mock modules + let mockHeaders: any; + let mockGetServiceUrlFromHeaders: any; + let mockRetrieveIDPIntent: any; + let mockGetIDPByID: any; + let mockUpdateHuman: any; + let mockAddIDPLink: any; + let mockListUsers: any; + let mockAddHuman: any; + let mockGetLoginSettings: any; + let mockGetOrgsByDomain: any; + let mockCreateNewSessionFromIdpIntent: any; + + const defaultParams = { + provider: "google", + id: "intent123", + token: "token123", + requestId: "req123", + organization: "org123", + }; + + const defaultIntent = { + idpInformation: { + idpId: "idp123", + userId: "user123", + userName: "testuser", + }, + userId: "user123", + addHumanUser: { + username: "testuser", + profile: { + givenName: "Test", + familyName: "User", + displayName: "Test User", + }, + email: { + email: "test@example.com", + }, + }, + }; + + const defaultIdp = { + id: "idp123", + config: { + options: { + isAutoUpdate: false, + isLinkingAllowed: false, + isCreationAllowed: false, + isAutoCreation: false, + autoLinking: undefined, + }, + }, + }; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Import mocked modules + const { headers } = await import("next/headers"); + const { getServiceUrlFromHeaders } = await import("../service-url"); + const { + retrieveIDPIntent, + getIDPByID, + updateHuman, + addIDPLink, + listUsers, + addHuman, + getLoginSettings, + getOrgsByDomain, + } = await import("../zitadel"); + const { createNewSessionFromIdpIntent } = await import("./idp"); + + // Setup mocks + mockHeaders = vi.mocked(headers); + mockGetServiceUrlFromHeaders = vi.mocked(getServiceUrlFromHeaders); + mockRetrieveIDPIntent = vi.mocked(retrieveIDPIntent); + mockGetIDPByID = vi.mocked(getIDPByID); + mockUpdateHuman = vi.mocked(updateHuman); + mockAddIDPLink = vi.mocked(addIDPLink); + mockListUsers = vi.mocked(listUsers); + mockAddHuman = vi.mocked(addHuman); + mockGetLoginSettings = vi.mocked(getLoginSettings); + mockGetOrgsByDomain = vi.mocked(getOrgsByDomain); + mockCreateNewSessionFromIdpIntent = vi.mocked(createNewSessionFromIdpIntent); + + // Default mock implementations + mockHeaders.mockResolvedValue({} as any); + mockGetServiceUrlFromHeaders.mockReturnValue({ + serviceUrl: "https://api.example.com", + }); + mockRetrieveIDPIntent.mockResolvedValue(defaultIntent); + mockGetIDPByID.mockResolvedValue(defaultIdp); + mockCreateNewSessionFromIdpIntent.mockResolvedValue({ + redirect: "https://app.example.com/success", + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("Parameter validation", () => { + test("should return error redirect when provider is missing", async () => { + const result = await processIDPCallback({ + provider: "", + id: "intent123", + token: "token123", + }); + + expect(result.redirect).toContain("/idp//failure"); + expect(mockRetrieveIDPIntent).not.toHaveBeenCalled(); + }); + + test("should return error redirect when id is missing", async () => { + const result = await processIDPCallback({ + provider: "google", + id: "", + token: "token123", + }); + + expect(result.redirect).toContain("/idp/google/failure"); + expect(mockRetrieveIDPIntent).not.toHaveBeenCalled(); + }); + + test("should return error redirect when token is missing", async () => { + const result = await processIDPCallback({ + provider: "google", + id: "intent123", + token: "", + }); + + expect(result.redirect).toContain("/idp/google/failure"); + expect(mockRetrieveIDPIntent).not.toHaveBeenCalled(); + }); + + test("should preserve requestId and organization in error redirect", async () => { + const result = await processIDPCallback({ + provider: "google", + id: "", + token: "token123", + requestId: "req123", + organization: "org123", + }); + + expect(result.redirect).toContain("requestId=req123"); + expect(result.redirect).toContain("organization=org123"); + }); + }); + + describe("Intent retrieval errors", () => { + test("should return error redirect when IDP information is missing", async () => { + mockRetrieveIDPIntent.mockResolvedValue({ + idpInformation: undefined, + userId: "user123", + }); + + const result = await processIDPCallback(defaultParams); + + expect(result.redirect).toContain("/idp/google/failure"); + expect(result.redirect).toContain("error=missing_idp_info"); + }); + + test("should return error when IDP is not found", async () => { + mockGetIDPByID.mockResolvedValue(null); + + const result = await processIDPCallback(defaultParams); + + expect(result.error).toBe("errors.idpNotFound"); + }); + + test("should handle retrieval errors gracefully", async () => { + mockRetrieveIDPIntent.mockRejectedValue(new Error("Network error")); + + const result = await processIDPCallback(defaultParams); + + expect(result.redirect).toContain("/idp/google/failure"); + expect(result.redirect).toContain("error=Network+error"); + }); + }); + + describe("CASE 1: User exists and should sign in", () => { + test("should create session for existing user without auto-update", async () => { + const result = await processIDPCallback(defaultParams); + + expect(mockRetrieveIDPIntent).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + id: "intent123", + token: "token123", + }); + expect(mockCreateNewSessionFromIdpIntent).toHaveBeenCalledWith({ + userId: "user123", + idpIntent: { + idpIntentId: "intent123", + idpIntentToken: "token123", + }, + requestId: "req123", + organization: "org123", + }); + expect(result.redirect).toBe("https://app.example.com/success"); + }); + + test("should auto-update user profile when enabled", async () => { + mockGetIDPByID.mockResolvedValue({ + ...defaultIdp, + config: { + options: { + ...defaultIdp.config.options, + isAutoUpdate: true, + }, + }, + }); + + await processIDPCallback(defaultParams); + + expect(mockUpdateHuman).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + request: expect.objectContaining({ + userId: "user123", + profile: defaultIntent.addHumanUser.profile, + email: defaultIntent.addHumanUser.email, + }), + }); + }); + + test("should continue session creation even if auto-update fails", async () => { + mockGetIDPByID.mockResolvedValue({ + ...defaultIdp, + config: { + options: { + ...defaultIdp.config.options, + isAutoUpdate: true, + }, + }, + }); + mockUpdateHuman.mockRejectedValue(new Error("Update failed")); + + const result = await processIDPCallback(defaultParams); + + expect(mockCreateNewSessionFromIdpIntent).toHaveBeenCalled(); + expect(result.redirect).toBe("https://app.example.com/success"); + }); + + test("should return error when session creation fails", async () => { + mockCreateNewSessionFromIdpIntent.mockResolvedValue({ + error: "Session creation error", + }); + + const result = await processIDPCallback(defaultParams); + + expect(result.error).toBe("Session creation error"); + }); + + test("should return error when session creation returns neither redirect nor error", async () => { + mockCreateNewSessionFromIdpIntent.mockResolvedValue({}); + + const result = await processIDPCallback(defaultParams); + + expect(result.error).toBe("errors.sessionCreationFailed"); + }); + }); + + describe("CASE 2: Link IDP to existing user", () => { + const linkParams = { + ...defaultParams, + link: "true", + }; + + test("should link IDP and create session when linking is allowed", async () => { + mockGetIDPByID.mockResolvedValue({ + ...defaultIdp, + config: { + options: { + ...defaultIdp.config.options, + isLinkingAllowed: true, + }, + }, + }); + + const result = await processIDPCallback(linkParams); + + expect(mockAddIDPLink).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + idp: { + id: "idp123", + userId: "user123", + userName: "testuser", + }, + userId: "user123", + }); + expect(mockCreateNewSessionFromIdpIntent).toHaveBeenCalled(); + expect(result.redirect).toBe("https://app.example.com/success"); + }); + + test("should return error redirect when linking is not allowed", async () => { + const result = await processIDPCallback(linkParams); + + expect(result.redirect).toContain("/idp/google/linking-failed"); + expect(result.redirect).toContain("error=linking_not_allowed"); + expect(mockAddIDPLink).not.toHaveBeenCalled(); + }); + + test("should return error redirect when linking fails", async () => { + mockGetIDPByID.mockResolvedValue({ + ...defaultIdp, + config: { + options: { + ...defaultIdp.config.options, + isLinkingAllowed: true, + }, + }, + }); + mockAddIDPLink.mockRejectedValue(new Error("Linking failed")); + + const result = await processIDPCallback(linkParams); + + expect(result.redirect).toContain("/idp/google/linking-failed"); + }); + + test("should return error when session creation fails after linking", async () => { + mockGetIDPByID.mockResolvedValue({ + ...defaultIdp, + config: { + options: { + ...defaultIdp.config.options, + isLinkingAllowed: true, + }, + }, + }); + mockCreateNewSessionFromIdpIntent.mockResolvedValue({ + error: "Session error", + }); + + const result = await processIDPCallback(linkParams); + + expect(result.error).toBe("Session error"); + }); + }); + + describe("CASE 3: Auto-linking by email", () => { + beforeEach(() => { + mockRetrieveIDPIntent.mockResolvedValue({ + ...defaultIntent, + userId: undefined, // No existing userId + }); + mockGetIDPByID.mockResolvedValue({ + ...defaultIdp, + config: { + options: { + ...defaultIdp.config.options, + autoLinking: AutoLinkingOption.EMAIL, + }, + }, + }); + }); + + test("should auto-link user by email and create session", async () => { + const foundUser = { userId: "found123" }; + mockListUsers.mockResolvedValue({ + result: [foundUser], + }); + + const result = await processIDPCallback(defaultParams); + + expect(mockListUsers).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + email: "test@example.com", + organizationId: "org123", + }); + expect(mockAddIDPLink).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + idp: { + id: "idp123", + userId: "user123", + userName: "testuser", + }, + userId: "found123", + }); + expect(mockCreateNewSessionFromIdpIntent).toHaveBeenCalledWith({ + userId: "found123", + idpIntent: { + idpIntentId: "intent123", + idpIntentToken: "token123", + }, + requestId: "req123", + organization: "org123", + }); + expect(result.redirect).toBe("https://app.example.com/success"); + }); + + test("should continue to next case when no user found by email", async () => { + mockListUsers.mockResolvedValue({ + result: [], + }); + + const result = await processIDPCallback(defaultParams); + + expect(mockAddIDPLink).not.toHaveBeenCalled(); + expect(result.redirect).toContain("/idp/google/account-not-found"); + }); + + test("should return error redirect when auto-linking fails", async () => { + mockListUsers.mockResolvedValue({ + result: [{ userId: "found123" }], + }); + mockAddIDPLink.mockRejectedValue(new Error("Linking failed")); + + const result = await processIDPCallback(defaultParams); + + expect(result.redirect).toContain("/idp/google/linking-failed"); + }); + }); + + describe("CASE 3: Auto-linking by username", () => { + beforeEach(() => { + mockRetrieveIDPIntent.mockResolvedValue({ + ...defaultIntent, + userId: undefined, + }); + mockGetIDPByID.mockResolvedValue({ + ...defaultIdp, + config: { + options: { + ...defaultIdp.config.options, + autoLinking: AutoLinkingOption.USERNAME, + }, + }, + }); + }); + + test("should auto-link user by username", async () => { + mockListUsers.mockResolvedValue({ + result: [{ userId: "found123" }], + }); + + const result = await processIDPCallback(defaultParams); + + expect(mockListUsers).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + userName: "testuser", + organizationId: "org123", + }); + expect(mockAddIDPLink).toHaveBeenCalled(); + expect(result.redirect).toBe("https://app.example.com/success"); + }); + }); + + describe("CASE 4: Auto-creation of user", () => { + beforeEach(() => { + mockRetrieveIDPIntent.mockResolvedValue({ + ...defaultIntent, + userId: undefined, + }); + mockGetIDPByID.mockResolvedValue({ + ...defaultIdp, + config: { + options: { + ...defaultIdp.config.options, + isAutoCreation: true, + }, + }, + }); + }); + + test("should auto-create user and create session", async () => { + mockAddHuman.mockResolvedValue({ + userId: "newuser123", + }); + + const result = await processIDPCallback(defaultParams); + + expect(mockAddHuman).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + request: expect.objectContaining({ + username: "testuser", + profile: defaultIntent.addHumanUser.profile, + email: defaultIntent.addHumanUser.email, + organization: expect.objectContaining({ + org: { case: "orgId", value: "org123" }, + }), + }), + }); + expect(mockCreateNewSessionFromIdpIntent).toHaveBeenCalledWith({ + userId: "newuser123", + idpIntent: { + idpIntentId: "intent123", + idpIntentToken: "token123", + }, + requestId: "req123", + organization: "org123", + }); + expect(result.redirect).toBe("https://app.example.com/success"); + }); + + test("should resolve organization from username domain", async () => { + mockRetrieveIDPIntent.mockResolvedValue({ + ...defaultIntent, + userId: undefined, + addHumanUser: { + ...defaultIntent.addHumanUser, + username: "user@example.com", + }, + }); + mockGetOrgsByDomain.mockResolvedValue({ + result: [{ id: "org-from-domain" }], + }); + mockGetLoginSettings.mockResolvedValue({ + allowDomainDiscovery: true, + }); + mockAddHuman.mockResolvedValue({ userId: "newuser123" }); + + await processIDPCallback({ + ...defaultParams, + organization: undefined, + }); + + expect(mockGetOrgsByDomain).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + domain: "example.com", + }); + expect(mockAddHuman).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + request: expect.objectContaining({ + organization: expect.objectContaining({ + org: { case: "orgId", value: "org-from-domain" }, + }), + }), + }); + }); + + test("should create user without organization when not resolved", async () => { + mockAddHuman.mockResolvedValue({ userId: "newuser123" }); + + await processIDPCallback({ + ...defaultParams, + organization: undefined, + }); + + expect(mockAddHuman).toHaveBeenCalledWith({ + serviceUrl: "https://api.example.com", + request: expect.not.objectContaining({ + organization: expect.anything(), + }), + }); + }); + + test("should return error redirect when user creation fails", async () => { + mockAddHuman.mockRejectedValue(new Error("Creation failed")); + + const result = await processIDPCallback(defaultParams); + + expect(result.redirect).toContain("/idp/google/failure"); + expect(result.redirect).toContain("error=user_creation_failed"); + }); + + test("should return error when session creation fails after user creation", async () => { + mockAddHuman.mockResolvedValue({ userId: "newuser123" }); + mockCreateNewSessionFromIdpIntent.mockResolvedValue({ + error: "Session error", + }); + + const result = await processIDPCallback(defaultParams); + + expect(result.error).toBe("Session error"); + }); + }); + + describe("CASE 5: Manual user creation allowed", () => { + beforeEach(() => { + mockRetrieveIDPIntent.mockResolvedValue({ + ...defaultIntent, + userId: undefined, + }); + mockGetIDPByID.mockResolvedValue({ + ...defaultIdp, + config: { + options: { + ...defaultIdp.config.options, + isCreationAllowed: true, + }, + }, + }); + }); + + test("should redirect to complete registration page with user data", async () => { + const result = await processIDPCallback(defaultParams); + + expect(result.redirect).toContain("/idp/google/complete-registration"); + expect(result.redirect).toContain("id=intent123"); + expect(result.redirect).toContain("token=token123"); + expect(result.redirect).toContain("requestId=req123"); + expect(result.redirect).toContain("organization=org123"); + expect(result.redirect).toContain("idpId=idp123"); + expect(result.redirect).toContain("idpUserId=user123"); + expect(result.redirect).toContain("idpUserName=testuser"); + expect(result.redirect).toContain("givenName=Test"); + expect(result.redirect).toContain("familyName=User"); + expect(result.redirect).toContain("email=test%40example.com"); + }); + + test("should redirect to registration failed when organization cannot be resolved", async () => { + const result = await processIDPCallback({ + ...defaultParams, + organization: undefined, + }); + + expect(result.redirect).toContain("/idp/google/registration-failed"); + expect(result.redirect).toContain("id=intent123"); + }); + + test("should resolve organization from domain for registration", async () => { + mockRetrieveIDPIntent.mockResolvedValue({ + ...defaultIntent, + userId: undefined, + addHumanUser: { + ...defaultIntent.addHumanUser, + username: "user@example.com", + }, + }); + mockGetOrgsByDomain.mockResolvedValue({ + result: [{ id: "org-from-domain" }], + }); + mockGetLoginSettings.mockResolvedValue({ + allowDomainDiscovery: true, + }); + + const result = await processIDPCallback({ + ...defaultParams, + organization: undefined, + }); + + expect(result.redirect).toContain("organization=org-from-domain"); + }); + }); + + describe("CASE 6: No user found and creation not allowed", () => { + beforeEach(() => { + mockRetrieveIDPIntent.mockResolvedValue({ + ...defaultIntent, + userId: undefined, + }); + }); + + test("should redirect to account not found page", async () => { + const result = await processIDPCallback(defaultParams); + + expect(result.redirect).toContain("/idp/google/account-not-found"); + expect(result.redirect).toContain("id=intent123"); + expect(result.redirect).toContain("requestId=req123"); + expect(result.redirect).toContain("organization=org123"); + }); + }); + + describe("Priority of cases", () => { + test("should prioritize existing user sign-in over auto-linking", async () => { + mockGetIDPByID.mockResolvedValue({ + ...defaultIdp, + config: { + options: { + ...defaultIdp.config.options, + autoLinking: AutoLinkingOption.EMAIL, + }, + }, + }); + + await processIDPCallback(defaultParams); + + // Should not search for users when userId already exists + expect(mockListUsers).not.toHaveBeenCalled(); + expect(mockCreateNewSessionFromIdpIntent).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user123", + }), + ); + }); + + test("should prioritize auto-linking over auto-creation", async () => { + mockRetrieveIDPIntent.mockResolvedValue({ + ...defaultIntent, + userId: undefined, + }); + mockGetIDPByID.mockResolvedValue({ + ...defaultIdp, + config: { + options: { + ...defaultIdp.config.options, + autoLinking: AutoLinkingOption.EMAIL, + isAutoCreation: true, + }, + }, + }); + mockListUsers.mockResolvedValue({ + result: [{ userId: "found123" }], + }); + + await processIDPCallback(defaultParams); + + // Should link, not create + expect(mockAddIDPLink).toHaveBeenCalled(); + expect(mockAddHuman).not.toHaveBeenCalled(); + }); + + test("should prioritize auto-creation over manual creation", async () => { + mockRetrieveIDPIntent.mockResolvedValue({ + ...defaultIntent, + userId: undefined, + }); + mockGetIDPByID.mockResolvedValue({ + ...defaultIdp, + config: { + options: { + ...defaultIdp.config.options, + isAutoCreation: true, + isCreationAllowed: true, + }, + }, + }); + mockAddHuman.mockResolvedValue({ userId: "newuser123" }); + + const result = await processIDPCallback(defaultParams); + + // Should auto-create, not redirect to manual form + expect(mockAddHuman).toHaveBeenCalled(); + expect(result.redirect).toBe("https://app.example.com/success"); + expect(result.redirect).not.toContain("complete-registration"); + }); + }); + + describe("postErrorRedirectUrl handling", () => { + test("should preserve postErrorRedirectUrl in all redirects", async () => { + const paramsWithError = { + ...defaultParams, + postErrorRedirectUrl: "https://app.example.com/error", + }; + + mockRetrieveIDPIntent.mockRejectedValue(new Error("Test error")); + + const result = await processIDPCallback(paramsWithError); + + expect(result.redirect).toContain("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror"); + }); + }); +}); diff --git a/apps/login/src/lib/server/idp-intent.ts b/apps/login/src/lib/server/idp-intent.ts new file mode 100644 index 00000000000..93bf7116cc8 --- /dev/null +++ b/apps/login/src/lib/server/idp-intent.ts @@ -0,0 +1,438 @@ +"use server"; + +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + retrieveIDPIntent, + getIDPByID, + updateHuman, + addIDPLink, + listUsers, + addHuman, + getLoginSettings, + getOrgsByDomain, +} from "@/lib/zitadel"; +import { headers } from "next/headers"; +import { create } from "@zitadel/client"; +import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb"; +import { OrganizationSchema } from "@zitadel/proto/zitadel/object/v2/object_pb"; +import { + AddHumanUserRequest, + AddHumanUserRequestSchema, + UpdateHumanUserRequestSchema, +} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { createNewSessionFromIdpIntent } from "./idp"; +import { getTranslations } from "next-intl/server"; + +const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; + +async function resolveOrganizationForUser({ + organization, + addHumanUser, + serviceUrl, +}: { + organization?: string; + addHumanUser?: AddHumanUserRequest; + serviceUrl: string; +}): Promise { + if (organization) return organization; + + if (addHumanUser?.username && ORG_SUFFIX_REGEX.test(addHumanUser.username)) { + const matched = ORG_SUFFIX_REGEX.exec(addHumanUser.username); + const suffix = matched?.[1] ?? ""; + + const orgs = await getOrgsByDomain({ + serviceUrl, + domain: suffix, + }); + const orgToCheckForDiscovery = orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; + + if (orgToCheckForDiscovery) { + const orgLoginSettings = await getLoginSettings({ + serviceUrl, + organization: orgToCheckForDiscovery, + }); + if (orgLoginSettings?.allowDomainDiscovery) { + return orgToCheckForDiscovery; + } + } + } + return undefined; +} + +/** + * Server action to process IDP callback and handle ALL business logic. + * This action: + * 1. Consumes the single-use token once + * 2. Performs all IDP-related operations (auto-update, auto-linking, auto-creation) + * 3. Returns redirect URL or error for client-side navigation + */ +export async function processIDPCallback({ + provider, + id, + token, + requestId, + organization, + link, + postErrorRedirectUrl, +}: { + provider: string; + id: string; + token: string; + requestId?: string; + organization?: string; + link?: string; + postErrorRedirectUrl?: string; +}): Promise<{ redirect?: string; error?: string }> { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const t = await getTranslations("idp"); + + // Validate required parameters + if (!provider || !id || !token) { + console.error("[IDP Process] Missing required parameters:", { provider, id, hasToken: !!token }); + const errorParams = new URLSearchParams(); + if (requestId) errorParams.set("requestId", requestId); + if (organization) errorParams.set("organization", organization); + if (postErrorRedirectUrl) errorParams.set("postErrorRedirectUrl", postErrorRedirectUrl); + + return { redirect: `/idp/${provider}/failure?${errorParams.toString()}` }; + } + + try { + console.log("[IDP Process] Retrieving IDP intent (single call):", { + id, + tokenPreview: token.substring(0, 10) + "...", + timestamp: new Date().toISOString(), + }); + + // Consume the single-use token ONCE + const intent = await retrieveIDPIntent({ + serviceUrl, + id, + token, + }); + + console.log("[IDP Process] Intent retrieved successfully, processing business logic"); + + const { idpInformation, userId, addHumanUser } = intent; + + if (!idpInformation) { + console.error("[IDP Process] IDP information missing"); + return { redirect: `/idp/${provider}/failure?error=missing_idp_info` }; + } + + // Get IDP configuration + const idp = await getIDPByID({ + serviceUrl, + id: idpInformation.idpId, + }); + + if (!idp) { + return { error: t("errors.idpNotFound") }; + } + + const options = idp?.config?.options; + + // Build base redirect params + const buildRedirectParams = (additionalParams?: Record, includeToken: boolean = false) => { + const params = new URLSearchParams(); + params.set("id", id); + if (includeToken) params.set("token", token); + if (requestId) params.set("requestId", requestId); + if (organization) params.set("organization", organization); + if (postErrorRedirectUrl) params.set("postErrorRedirectUrl", postErrorRedirectUrl); + + if (additionalParams) { + Object.entries(additionalParams).forEach(([key, value]) => { + if (value) params.set(key, value); + }); + } + + return params.toString(); + }; + + // ============================================ + // CASE 1: User exists and should sign in + // ============================================ + if (userId && !link) { + // Auto-update user if enabled + if (options?.isAutoUpdate && addHumanUser) { + try { + await updateHuman({ + serviceUrl, + request: create(UpdateHumanUserRequestSchema, { + userId: userId, + profile: addHumanUser.profile, + email: addHumanUser.email, + phone: addHumanUser.phone, + }), + }); + console.log("[IDP Process] User auto-updated successfully"); + } catch (error: unknown) { + console.warn("[IDP Process] Error auto-updating user:", error); + } + } + + // Create session and handle redirect + console.log("[IDP Process] Creating session for existing user"); + const sessionResult = await createNewSessionFromIdpIntent({ + userId, + idpIntent: { + idpIntentId: id, + idpIntentToken: token, + }, + requestId, + organization, + }); + + if ("error" in sessionResult && sessionResult.error) { + console.error("[IDP Process] Error creating session:", sessionResult.error); + return { error: sessionResult.error }; + } + + if ("redirect" in sessionResult && sessionResult.redirect) { + console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect); + return { redirect: sessionResult.redirect }; + } + + return { error: t("errors.sessionCreationFailed") }; + } + + // ============================================ + // CASE 2: Link IDP to existing user + // ============================================ + if (link && userId) { + if (!options?.isLinkingAllowed) { + console.error("[IDP Process] Linking not allowed"); + const params = buildRedirectParams(); + return { redirect: `/idp/${provider}/linking-failed?${params}&error=linking_not_allowed` }; + } + + try { + await addIDPLink({ + serviceUrl, + idp: { + id: idpInformation.idpId, + userId: idpInformation.userId, + userName: idpInformation.userName, + }, + userId, + }); + console.log("[IDP Process] IDP linked successfully, creating session"); + + // Create session after linking + const sessionResult = await createNewSessionFromIdpIntent({ + userId, + idpIntent: { + idpIntentId: id, + idpIntentToken: token, + }, + requestId, + organization, + }); + + if ("error" in sessionResult && sessionResult.error) { + console.error("[IDP Process] Error creating session:", sessionResult.error); + return { error: sessionResult.error }; + } + + if ("redirect" in sessionResult && sessionResult.redirect) { + console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect); + return { redirect: sessionResult.redirect }; + } + + return { error: t("errors.sessionCreationFailed") }; + } catch (error) { + console.error("[IDP Process] Error linking IDP:", error); + const params = buildRedirectParams(); + return { redirect: `/idp/${provider}/linking-failed?${params}` }; + } + } + + // ============================================ + // CASE 3: Auto-linking (search for user and link) + // ============================================ + if (options?.autoLinking) { + let foundUser; + const email = addHumanUser?.email?.email; + + if (options.autoLinking === AutoLinkingOption.EMAIL && email) { + foundUser = await listUsers({ serviceUrl, email, organizationId: organization }).then((response) => { + return response.result ? response.result[0] : null; + }); + } else if (options.autoLinking === AutoLinkingOption.USERNAME) { + foundUser = await listUsers({ + serviceUrl, + userName: idpInformation.userName, + organizationId: organization, + }).then((response) => { + return response.result ? response.result[0] : null; + }); + } else { + foundUser = await listUsers({ + serviceUrl, + userName: idpInformation.userName, + email, + organizationId: organization, + }).then((response) => { + return response.result ? response.result[0] : null; + }); + } + + if (foundUser) { + try { + await addIDPLink({ + serviceUrl, + idp: { + id: idpInformation.idpId, + userId: idpInformation.userId, + userName: idpInformation.userName, + }, + userId: foundUser.userId, + }); + console.log("[IDP Process] User auto-linked successfully, creating session"); + + // Create session after auto-linking + const sessionResult = await createNewSessionFromIdpIntent({ + userId: foundUser.userId, + idpIntent: { + idpIntentId: id, + idpIntentToken: token, + }, + requestId, + organization, + }); + + if ("error" in sessionResult && sessionResult.error) { + console.error("[IDP Process] Error creating session:", sessionResult.error); + return { error: sessionResult.error }; + } + + if ("redirect" in sessionResult && sessionResult.redirect) { + console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect); + return { redirect: sessionResult.redirect }; + } + + return { error: t("errors.sessionCreationFailed") }; + } catch (error) { + console.error("[IDP Process] Error auto-linking user:", error); + const params = buildRedirectParams(); + return { redirect: `/idp/${provider}/linking-failed?${params}` }; + } + } + } + + // ============================================ + // CASE 4: Auto-creation of user + // ============================================ + if (options?.isAutoCreation && addHumanUser) { + const orgToRegisterOn = await resolveOrganizationForUser({ + organization, + addHumanUser, + serviceUrl, + }); + + let addHumanUserWithOrganization: AddHumanUserRequest; + if (orgToRegisterOn) { + const organizationSchema = create(OrganizationSchema, { + org: { case: "orgId", value: orgToRegisterOn }, + }); + + addHumanUserWithOrganization = create(AddHumanUserRequestSchema, { + ...addHumanUser, + organization: organizationSchema, + }); + } else { + addHumanUserWithOrganization = create(AddHumanUserRequestSchema, addHumanUser); + } + + try { + const newUser = await addHuman({ + serviceUrl, + request: addHumanUserWithOrganization, + }); + console.log("[IDP Process] User auto-created successfully, creating session"); + + // Create session for newly created user + const sessionResult = await createNewSessionFromIdpIntent({ + userId: newUser.userId, + idpIntent: { + idpIntentId: id, + idpIntentToken: token, + }, + requestId, + organization, + }); + + if ("error" in sessionResult && sessionResult.error) { + console.error("[IDP Process] Error creating session:", sessionResult.error); + return { error: sessionResult.error }; + } + + if ("redirect" in sessionResult && sessionResult.redirect) { + console.log("[IDP Process] Session created, redirecting to:", sessionResult.redirect); + return { redirect: sessionResult.redirect }; + } + + return { error: t("errors.sessionCreationFailed") }; + } catch (error: unknown) { + console.error("[IDP Process] Error auto-creating user:", error); + const params = buildRedirectParams(); + return { redirect: `/idp/${provider}/failure?${params}&error=user_creation_failed` }; + } + } + + // ============================================ + // CASE 5: Manual user creation allowed + // ============================================ + if (options?.isCreationAllowed && addHumanUser) { + const orgToRegisterOn = await resolveOrganizationForUser({ + organization, + addHumanUser, + serviceUrl, + }); + + if (!orgToRegisterOn) { + console.error("[IDP Process] Could not determine organization for registration"); + const params = buildRedirectParams(); + return { redirect: `/idp/${provider}/registration-failed?${params}` }; + } + + // Store user data for manual registration form + // Note: includeToken=true because the session hasn't been created yet + // The token will be needed when registerUserAndLinkToIDP creates the session + const params = buildRedirectParams( + { + organization: orgToRegisterOn, + idpId: idpInformation.idpId, + idpUserId: idpInformation.userId || "", + idpUserName: idpInformation.userName || "", + // User data for pre-filling form + givenName: addHumanUser.profile?.givenName || "", + familyName: addHumanUser.profile?.familyName || "", + email: addHumanUser.email?.email || "", + }, + true, + ); // includeToken=true + return { redirect: `/idp/${provider}/complete-registration?${params}` }; + } + + // ============================================ + // CASE 6: No user found and creation not allowed + // ============================================ + console.log("[IDP Process] No matching user and creation not allowed"); + const params = buildRedirectParams(); + return { redirect: `/idp/${provider}/account-not-found?${params}` }; + } catch (error: unknown) { + console.error("[IDP Process] Error processing intent:", error); + + const errorParams = new URLSearchParams(); + if (requestId) errorParams.set("requestId", requestId); + if (organization) errorParams.set("organization", organization); + if (postErrorRedirectUrl) errorParams.set("postErrorRedirectUrl", postErrorRedirectUrl); + errorParams.set("error", error instanceof Error ? error.message : t("errors.unknownError")); + + return { redirect: `/idp/${provider}/failure?${errorParams.toString()}` }; + } +} diff --git a/apps/login/src/lib/server/idp.ts b/apps/login/src/lib/server/idp.ts index d9a1935762d..f38adcb3435 100644 --- a/apps/login/src/lib/server/idp.ts +++ b/apps/login/src/lib/server/idp.ts @@ -46,7 +46,7 @@ export async function redirectToIdp(prevState: RedirectToIdpState, formData: For serviceUrl, host, idpId, - successUrl: `/idp/${provider}/success?` + params.toString(), + successUrl: `/idp/${provider}/process?` + params.toString(), failureUrl: `/idp/${provider}/failure?` + params.toString(), });