fix(login): Prevent double execution of IDP callback token and improve architecture (#10948)

Closes #10828

# Which Problems Are Solved

The IDP callback flow was calling retrieveIDPIntent() twice, causing
single-use token failures with error: "Intent Token is invalid". This
occurred due to Next.js 15's dynamicIO feature triggering double renders

# How the Problems Are Solved

Completely refactored the IDP callback architecture to ensure single-use
tokens are consumed exactly once:

- Centralized Business Logic: Moved all IDP callback logic into a single
server action (processIDPCallback) that:
   - Consumes the token once
- Handles all 6 business scenarios (login, linking, auto-linking,
auto-creation, manual registration, account not found)
   - Integrates session creation in the same action
- Returns `{ redirect?: string; error?: string }` for client-side
navigation
- Client Component Invocation: Created `IdpProcessHandler` client
component that:
- Calls the server action from browser context (enables cookie
modification)
   - Prevents double execution with useRef
   - Handles loading states and error display
- Clean Architecture:
   - Removed 403-line success page with complex logic
   - Removed component files from `/components/idps/pages/` folder
   - Moved all UI directly into server pages
   - Created dedicated result pages with minimal params

# Additional Changes

- Added translations to all 8 supported languages

---------

Co-authored-by: Ramon <mail@conblem.me>
(cherry picked from commit 9dc127ddb5)
This commit is contained in:
Max Peintner
2025-10-27 14:34:39 +01:00
committed by Livio Spring
parent 22b55b4dda
commit c126001a4b
23 changed files with 1589 additions and 652 deletions

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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<Record<string | number | symbol, string | undefined>>;
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 (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
<h1>
<Translated i18nKey="completeRegister.title" namespace="idp" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="completeRegister.description" namespace="idp" />
</p>
</div>
<div className="w-full">
<RegisterFormIDPIncomplete
idpUserId={idpUserId}
idpId={idpId}
idpUserName={idpUserName}
defaultValues={{
email: email || "",
firstname: givenName || "",
lastname: familyName || "",
}}
requestId={requestId}
organization={organization}
idpIntent={{
idpIntentId: id,
idpIntentToken: token,
}}
/>
</div>
</DynamicTheme>
);
}

View File

@@ -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<Record<string | number | symbol, string | undefined>>;
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 (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
<h1>
<Translated i18nKey="linkingFailed.title" namespace="idp" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="linkingFailed.description" namespace="idp" />
</p>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
</div>
</DynamicTheme>
);
}

View File

@@ -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<Record<string | number | symbol, string | undefined>>;
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 (
<IdpProcessHandler
provider={provider}
id={id}
token={token}
requestId={requestId}
organization={organization}
link={link}
postErrorRedirectUrl={postErrorRedirectUrl}
/>
);
}

View File

@@ -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<string | undefined> {
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<Record<string | number | symbol, string | undefined>>;
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 (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
<h1>
<Translated i18nKey="registerSuccess.title" namespace="idp" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="registerSuccess.description" namespace="idp" />
</p>
</div>
<div className="w-full">
<IdpSignin userId={newUser.userId} idpIntent={{ idpIntentId: id, idpIntentToken: token }} requestId={requestId} />
</div>
</DynamicTheme>
);
}
// 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()}`);
}

View File

@@ -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<string | null>(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 (
<div className="flex min-h-screen items-center justify-center">
{loading && (
<div className="flex flex-col items-center space-y-4">
<Spinner className="h-8 w-8" />
<p className="text-sm text-gray-600">{t("processing.message")}</p>
</div>
)}
{error && (
<div className="max-w-md py-4">
<Alert>{error}</Alert>
</div>
)}
</div>
);
}

View File

@@ -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<string | null>(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 (
<div className="flex items-center justify-center py-4">
{loading && <Spinner className="h-5 w-5" />}
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
</div>
);
}

View File

@@ -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 (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
<h1>
<Translated i18nKey="completeRegister.title" namespace="idp" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="completeRegister.description" namespace="idp" />
</p>
</div>
<div className="w-full">
<RegisterFormIDPIncomplete
idpUserId={idpUserId}
idpId={idpId}
idpUserName={idpUserName}
defaultValues={{
email: addHumanUser?.email?.email || "",
firstname: addHumanUser?.profile?.givenName || "",
lastname: addHumanUser?.profile?.familyName || "",
}}
requestId={requestId}
organization={organization}
idpIntent={idpIntent}
/>
</div>
</DynamicTheme>
);
}

View File

@@ -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 (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
<h1>
<Translated i18nKey="linkingError.title" namespace="idp" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="linkingError.description" namespace="idp" />
</p>
{error && <div className="w-full">{<Alert type={AlertType.ALERT}>{error}</Alert>}</div>}
</div>
<div className="w-full"></div>
</DynamicTheme>
);
}

View File

@@ -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 (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
<h1>
<Translated i18nKey="linkingSuccess.title" namespace="idp" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="linkingSuccess.description" namespace="idp" />
</p>
</div>
<div className="w-full">
<IdpSignin userId={userId} idpIntent={idpIntent} requestId={requestId} />
</div>
</DynamicTheme>
);
}

View File

@@ -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 (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>
<Translated i18nKey="loginError.title" namespace="idp" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="loginError.description" namespace="idp" />
</p>
{error && <div className="w-full">{<Alert type={AlertType.ALERT}>{error}</Alert>}</div>}
</div>
<div className="w-full"></div>
</DynamicTheme>
);
}

View File

@@ -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 (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>
<Translated i18nKey="loginSuccess.title" namespace="idp" />
</h1>
<p className="ztdl-p">
<Translated i18nKey="loginSuccess.description" namespace="idp" />
</p>
</div>
<div className="w-full">
<IdpSignin userId={userId} idpIntent={idpIntent} requestId={requestId} />
</div>
</DynamicTheme>
);
}

View File

@@ -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),
},
});

View File

@@ -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");
});
});
});

View File

@@ -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<string | undefined> {
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<string, string>, 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()}` };
}
}

View File

@@ -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(),
});