mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-02 14:12:27 +00:00
fix(login): provide a postError redirect url for idp flows (#10883)
# Which Problems Are Solved
Improves the user experience when IDP authentication succeeds but user
creation/linking fails by introducing a `postErrorRedirectUrl` parameter
and dedicated error pages instead of generic error screens.
<img width="580" height="636" alt="Screenshot 2025-10-16 at 09 21 07"
src="https://github.com/user-attachments/assets/db653c8f-b648-4cfe-922a-2f237f3b70b3"
/>
# How the Problems Are Solved
## New Pages
- **`/idp/[provider]/account-not-found`**: Displayed when no user
account exists and creation/linking is not allowed
- **`/idp/[provider]/registration-failed`**: Displayed when user
registration fails due to organization resolution issues
## Flow Improvements
- Added `postErrorRedirectUrl` parameter to track where the IDP flow was
initiated
- Each entry point (loginname, register, idp, authenticator/set)
specifies its own redirect URL
- Users are now redirected to appropriate error pages with clear
messaging instead of generic error screens
- All context (`requestId`, `organization`, `postErrorRedirectUrl`) is
preserved throughout the flow
## Updated Components
- `SignInWithIdp`: Now accepts and passes `postErrorRedirectUrl`
parameter
- `redirectToIdp` server action: Extracts and forwards
`postErrorRedirectUrl` through the IDP flow
- IDP success page: Routes to appropriate error pages based on failure
reason
## i18n
Added new translation keys:
- `idp.accountNotFound.*` - For missing account scenarios
- `idp.registrationFailed.*` - For organization resolution failures
(cherry picked from commit 00beaf6ddc)
This commit is contained in:
committed by
Livio Spring
parent
fd22d99f5b
commit
266d53307b
@@ -182,6 +182,18 @@
|
||||
"completeRegister": {
|
||||
"title": "Registrierung abschließen",
|
||||
"description": "Bitte vervollständige die Registrierung, um dein Konto zu erstellen."
|
||||
},
|
||||
"accountNotFound": {
|
||||
"title": "Konto nicht gefunden",
|
||||
"description": "Wir konnten kein Konto finden, das mit Ihren Identitätsanbieter-Anmeldedaten verknüpft ist.",
|
||||
"info": "Es wurde kein bestehendes Konto gefunden. Bitte melden Sie sich mit einem bestehenden Konto an oder wenden Sie sich an Ihren Administrator.",
|
||||
"backToLogin": "Zurück zur Anmeldung"
|
||||
},
|
||||
"registrationFailed": {
|
||||
"title": "Registrierung nicht verfügbar",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"ldap": {
|
||||
|
||||
@@ -182,6 +182,18 @@
|
||||
"completeRegister": {
|
||||
"title": "Complete your data",
|
||||
"description": "You need to complete your registration by providing your email address and name."
|
||||
},
|
||||
"accountNotFound": {
|
||||
"title": "Account Not Found",
|
||||
"description": "We couldn't find an account associated with your identity provider credentials.",
|
||||
"info": "No existing account was found. Please sign in with an existing account or contact your administrator for assistance.",
|
||||
"backToLogin": "Back to Login"
|
||||
},
|
||||
"registrationFailed": {
|
||||
"title": "Registration Not Available",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"ldap": {
|
||||
|
||||
@@ -182,6 +182,18 @@
|
||||
"completeRegister": {
|
||||
"title": "Completar registro",
|
||||
"description": "Para completar el registro, debes establecer una contraseña."
|
||||
},
|
||||
"accountNotFound": {
|
||||
"title": "Cuenta no encontrada",
|
||||
"description": "No pudimos encontrar una cuenta asociada con tus credenciales del proveedor de identidad.",
|
||||
"info": "No se encontró ninguna cuenta existente. Por favor, inicia sesión con una cuenta existente o contacta a tu administrador para obtener asistencia.",
|
||||
"backToLogin": "Volver al inicio de sesión"
|
||||
},
|
||||
"registrationFailed": {
|
||||
"title": "Registro no disponible",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"ldap": {
|
||||
|
||||
@@ -182,6 +182,18 @@
|
||||
"completeRegister": {
|
||||
"title": "Completa la registrazione",
|
||||
"description": "Completa la registrazione del tuo account."
|
||||
},
|
||||
"accountNotFound": {
|
||||
"title": "Account non trovato",
|
||||
"description": "Non siamo riusciti a trovare un account associato alle tue credenziali del provider di identità.",
|
||||
"info": "Nessun account esistente è stato trovato. Accedi con un account esistente o contatta il tuo amministratore per assistenza.",
|
||||
"backToLogin": "Torna al login"
|
||||
},
|
||||
"registrationFailed": {
|
||||
"title": "Registrazione non disponibile",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"ldap": {
|
||||
|
||||
@@ -182,6 +182,18 @@
|
||||
"completeRegister": {
|
||||
"title": "Ukończ rejestrację",
|
||||
"description": "Ukończ rejestrację swojego konta."
|
||||
},
|
||||
"accountNotFound": {
|
||||
"title": "Nie znaleziono konta",
|
||||
"description": "Nie mogliśmy znaleźć konta powiązanego z danymi logowania Twojego dostawcy tożsamości.",
|
||||
"info": "Nie znaleziono istniejącego konta. Zaloguj się przy użyciu istniejącego konta lub skontaktuj się z administratorem w celu uzyskania pomocy.",
|
||||
"backToLogin": "Powrót do logowania"
|
||||
},
|
||||
"registrationFailed": {
|
||||
"title": "Rejestracja niedostępna",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"ldap": {
|
||||
|
||||
@@ -182,6 +182,18 @@
|
||||
"completeRegister": {
|
||||
"title": "Завершите регистрацию",
|
||||
"description": "Завершите регистрацию вашего аккаунта."
|
||||
},
|
||||
"accountNotFound": {
|
||||
"title": "Аккаунт не найден",
|
||||
"description": "Мы не смогли найти аккаунт, связанный с учетными данными вашего провайдера идентификации.",
|
||||
"info": "Существующий аккаунт не найден. Пожалуйста, войдите с существующим аккаунтом или обратитесь к администратору за помощью.",
|
||||
"backToLogin": "Вернуться к входу"
|
||||
},
|
||||
"registrationFailed": {
|
||||
"title": "Регистрация недоступна",
|
||||
"description": "Мы не смогли завершить процесс регистрации.",
|
||||
"info": "Не удалось определить организацию для регистрации. Пожалуйста, обратитесь к администратору за помощью.",
|
||||
"backToLogin": "Вернуться к входу"
|
||||
}
|
||||
},
|
||||
"ldap": {
|
||||
@@ -319,7 +331,7 @@
|
||||
"userNotFound": "Пользователь не найден в системе",
|
||||
"couldNotLinkIDP": "Не удалось привязать IDP к пользователю",
|
||||
"couldNotRegisterUser": "Не удалось зарегистрировать пользователя"
|
||||
}
|
||||
}
|
||||
},
|
||||
"invite": {
|
||||
"title": "Пригласить пользователя",
|
||||
|
||||
@@ -182,6 +182,18 @@
|
||||
"completeRegister": {
|
||||
"title": "完成注册",
|
||||
"description": "完成您的账户注册。"
|
||||
},
|
||||
"accountNotFound": {
|
||||
"title": "未找到账户",
|
||||
"description": "我们无法找到与您的身份提供商凭据关联的账户。",
|
||||
"info": "未找到现有账户。请使用现有账户登录或联系您的管理员寻求帮助。",
|
||||
"backToLogin": "返回登录"
|
||||
},
|
||||
"registrationFailed": {
|
||||
"title": "注册不可用",
|
||||
"description": "我们无法完成注册过程。",
|
||||
"info": "无法确定注册的组织。请联系您的管理员寻求帮助。",
|
||||
"backToLogin": "返回登录"
|
||||
}
|
||||
},
|
||||
"ldap": {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Alert, AlertType } from "@/components/alert";
|
||||
import { Button } from "@/components/button";
|
||||
import { DynamicTheme } from "@/components/dynamic-theme";
|
||||
import { Translated } from "@/components/translated";
|
||||
import { getServiceUrlFromHeaders } from "@/lib/service-url";
|
||||
import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel";
|
||||
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("idp");
|
||||
return { title: t("accountNotFound.title") };
|
||||
}
|
||||
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
const { organization, postErrorRedirectUrl } = searchParams;
|
||||
|
||||
const _headers = await headers();
|
||||
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
|
||||
|
||||
let defaultOrganization;
|
||||
if (!organization) {
|
||||
const org: Organization | null = await getDefaultOrg({
|
||||
serviceUrl,
|
||||
});
|
||||
if (org) {
|
||||
defaultOrganization = org.id;
|
||||
}
|
||||
}
|
||||
|
||||
const branding = await getBrandingSettings({
|
||||
serviceUrl,
|
||||
organization: organization ?? defaultOrganization,
|
||||
});
|
||||
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="accountNotFound.title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="accountNotFound.description" namespace="idp" />
|
||||
</p>
|
||||
|
||||
<div className="w-full">
|
||||
<Alert type={AlertType.INFO}>
|
||||
<Translated i18nKey="accountNotFound.info" namespace="idp" />
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{postErrorRedirectUrl && (
|
||||
<Link href={postErrorRedirectUrl}>
|
||||
<Button className="bg-primary-light-500 hover:bg-primary-light-400 dark:bg-primary-dark-500 dark:hover:bg-primary-dark-400 w-full rounded-md px-4 py-3 text-center transition-all">
|
||||
<Translated i18nKey="accountNotFound.backToLogin" namespace="idp" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Alert, AlertType } from "@/components/alert";
|
||||
import { Button } from "@/components/button";
|
||||
import { DynamicTheme } from "@/components/dynamic-theme";
|
||||
import { Translated } from "@/components/translated";
|
||||
import { getServiceUrlFromHeaders } from "@/lib/service-url";
|
||||
import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel";
|
||||
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
|
||||
import { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("idp");
|
||||
return { title: t("registrationFailed.title") };
|
||||
}
|
||||
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
const { organization, postErrorRedirectUrl } = searchParams;
|
||||
|
||||
const _headers = await headers();
|
||||
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
|
||||
|
||||
let defaultOrganization;
|
||||
if (!organization) {
|
||||
const org: Organization | null = await getDefaultOrg({
|
||||
serviceUrl,
|
||||
});
|
||||
if (org) {
|
||||
defaultOrganization = org.id;
|
||||
}
|
||||
}
|
||||
|
||||
const branding = await getBrandingSettings({
|
||||
serviceUrl,
|
||||
organization: organization ?? defaultOrganization,
|
||||
});
|
||||
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="registrationFailed.title" namespace="idp" />
|
||||
</h1>
|
||||
<p className="ztdl-p">
|
||||
<Translated i18nKey="registrationFailed.description" namespace="idp" />
|
||||
</p>
|
||||
|
||||
<div className="w-full">
|
||||
<Alert type={AlertType.ALERT}>
|
||||
<Translated i18nKey="registrationFailed.info" namespace="idp" />
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{postErrorRedirectUrl && (
|
||||
<Link href={postErrorRedirectUrl}>
|
||||
<Button className="bg-primary-light-500 hover:bg-primary-light-400 dark:bg-primary-dark-500 dark:hover:bg-primary-dark-400 w-full rounded-md px-4 py-3 text-center transition-all">
|
||||
<Translated i18nKey="registrationFailed.backToLogin" namespace="idp" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</DynamicTheme>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
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";
|
||||
@@ -72,7 +73,7 @@ export default async function Page(props: {
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
let { id, token, requestId, organization, link } = searchParams;
|
||||
let { id, token, requestId, organization, link, postErrorRedirectUrl } = searchParams;
|
||||
const { provider } = params;
|
||||
|
||||
const _headers = await headers();
|
||||
@@ -275,7 +276,12 @@ export default async function Page(props: {
|
||||
}
|
||||
|
||||
if (!orgToRegisterOn) {
|
||||
return loginFailed(branding, "No organization found for registration");
|
||||
// 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({
|
||||
@@ -309,6 +315,11 @@ export default async function Page(props: {
|
||||
);
|
||||
}
|
||||
|
||||
// return login failed if no linking or creation is allowed and no user was found
|
||||
return loginFailed(branding, "No user found");
|
||||
// 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()}`);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
identityProviders={identityProviders}
|
||||
requestId={requestId}
|
||||
organization={organization}
|
||||
postErrorRedirectUrl="/idp"
|
||||
showLabel={false}
|
||||
></SignInWithIdp>
|
||||
)}
|
||||
|
||||
@@ -86,6 +86,7 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
identityProviders={identityProviders}
|
||||
requestId={requestId}
|
||||
organization={organization}
|
||||
postErrorRedirectUrl="/loginname"
|
||||
></SignInWithIdp>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface SignInWithIDPProps {
|
||||
requestId?: string;
|
||||
organization?: string;
|
||||
linkOnly?: boolean;
|
||||
postErrorRedirectUrl?: string;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ export function SignInWithIdp({
|
||||
requestId,
|
||||
organization,
|
||||
linkOnly,
|
||||
postErrorRedirectUrl,
|
||||
showLabel = true,
|
||||
}: Readonly<SignInWithIDPProps>) {
|
||||
const [state, action, _isPending] = useActionState(redirectToIdp, {});
|
||||
@@ -58,6 +60,7 @@ export function SignInWithIdp({
|
||||
<input type="hidden" name="requestId" value={requestId} />
|
||||
<input type="hidden" name="organization" value={organization} />
|
||||
<input type="hidden" name="linkOnly" value={linkOnly ? "true" : "false"} />
|
||||
{postErrorRedirectUrl && <input type="hidden" name="postErrorRedirectUrl" value={postErrorRedirectUrl} />}
|
||||
<Component key={id} name={name} />
|
||||
</form>
|
||||
) : null;
|
||||
|
||||
276
apps/login/src/lib/server/idp.test.ts
Normal file
276
apps/login/src/lib/server/idp.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
|
||||
import { redirectToIdp } from "./idp";
|
||||
|
||||
// Mock all the dependencies
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn((url: string) => {
|
||||
throw new Error(`REDIRECT: ${url}`);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../service-url", () => ({
|
||||
getServiceUrlFromHeaders: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./host", () => ({
|
||||
getOriginalHost: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../zitadel", () => ({
|
||||
startIdentityProviderFlow: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("redirectToIdp", () => {
|
||||
let mockHeaders: any;
|
||||
let mockGetServiceUrlFromHeaders: any;
|
||||
let mockGetOriginalHost: any;
|
||||
let mockStartIdentityProviderFlow: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Import mocked modules
|
||||
const { headers } = await import("next/headers");
|
||||
const { getServiceUrlFromHeaders } = await import("../service-url");
|
||||
const { getOriginalHost } = await import("./host");
|
||||
const { startIdentityProviderFlow } = await import("../zitadel");
|
||||
|
||||
// Setup mocks
|
||||
mockHeaders = vi.mocked(headers);
|
||||
mockGetServiceUrlFromHeaders = vi.mocked(getServiceUrlFromHeaders);
|
||||
mockGetOriginalHost = vi.mocked(getOriginalHost);
|
||||
mockStartIdentityProviderFlow = vi.mocked(startIdentityProviderFlow);
|
||||
|
||||
// Default mock implementations
|
||||
mockHeaders.mockResolvedValue({} as any);
|
||||
mockGetServiceUrlFromHeaders.mockReturnValue({
|
||||
serviceUrl: "https://api.example.com",
|
||||
});
|
||||
mockGetOriginalHost.mockResolvedValue("example.com");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("postErrorRedirectUrl parameter handling", () => {
|
||||
test("should include postErrorRedirectUrl in success and failure URLs when provided", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("id", "idp123");
|
||||
formData.append("provider", "google");
|
||||
formData.append("requestId", "req123");
|
||||
formData.append("organization", "org123");
|
||||
formData.append("postErrorRedirectUrl", "https://app.example.com/error");
|
||||
|
||||
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
|
||||
|
||||
try {
|
||||
await redirectToIdp(undefined, formData);
|
||||
} catch (error: any) {
|
||||
// Redirect throws in tests
|
||||
expect(error.message).toContain("REDIRECT:");
|
||||
}
|
||||
|
||||
expect(mockStartIdentityProviderFlow).toHaveBeenCalledWith({
|
||||
serviceUrl: "https://api.example.com",
|
||||
idpId: "idp123",
|
||||
urls: {
|
||||
successUrl: expect.stringContaining("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror"),
|
||||
failureUrl: expect.stringContaining("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror"),
|
||||
},
|
||||
});
|
||||
|
||||
// Verify both URLs contain all expected parameters
|
||||
const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0];
|
||||
const successUrl = callArgs.urls.successUrl;
|
||||
const failureUrl = callArgs.urls.failureUrl;
|
||||
|
||||
expect(successUrl).toContain("requestId=req123");
|
||||
expect(successUrl).toContain("organization=org123");
|
||||
expect(successUrl).toContain("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror");
|
||||
|
||||
expect(failureUrl).toContain("requestId=req123");
|
||||
expect(failureUrl).toContain("organization=org123");
|
||||
expect(failureUrl).toContain("postErrorRedirectUrl=https%3A%2F%2Fapp.example.com%2Ferror");
|
||||
});
|
||||
|
||||
test("should not include postErrorRedirectUrl in URLs when not provided", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("id", "idp123");
|
||||
formData.append("provider", "google");
|
||||
formData.append("requestId", "req123");
|
||||
formData.append("organization", "org123");
|
||||
|
||||
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
|
||||
|
||||
try {
|
||||
await redirectToIdp(undefined, formData);
|
||||
} catch (error: any) {
|
||||
// Redirect throws in tests
|
||||
expect(error.message).toContain("REDIRECT:");
|
||||
}
|
||||
|
||||
expect(mockStartIdentityProviderFlow).toHaveBeenCalledWith({
|
||||
serviceUrl: "https://api.example.com",
|
||||
idpId: "idp123",
|
||||
urls: {
|
||||
successUrl: expect.not.stringContaining("postErrorRedirectUrl"),
|
||||
failureUrl: expect.not.stringContaining("postErrorRedirectUrl"),
|
||||
},
|
||||
});
|
||||
|
||||
const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0];
|
||||
expect(callArgs.urls.successUrl).not.toContain("postErrorRedirectUrl");
|
||||
expect(callArgs.urls.failureUrl).not.toContain("postErrorRedirectUrl");
|
||||
});
|
||||
|
||||
test("should not include postErrorRedirectUrl when it is an empty string", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("id", "idp123");
|
||||
formData.append("provider", "google");
|
||||
formData.append("postErrorRedirectUrl", "");
|
||||
|
||||
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
|
||||
|
||||
try {
|
||||
await redirectToIdp(undefined, formData);
|
||||
} catch (error: any) {
|
||||
// Redirect throws in tests
|
||||
expect(error.message).toContain("REDIRECT:");
|
||||
}
|
||||
|
||||
const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0];
|
||||
expect(callArgs.urls.successUrl).not.toContain("postErrorRedirectUrl");
|
||||
expect(callArgs.urls.failureUrl).not.toContain("postErrorRedirectUrl");
|
||||
});
|
||||
|
||||
test("should include postErrorRedirectUrl in LDAP redirect URL", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("id", "ldap123");
|
||||
formData.append("provider", "ldap");
|
||||
formData.append("requestId", "req123");
|
||||
formData.append("organization", "org123");
|
||||
formData.append("postErrorRedirectUrl", "/custom-error");
|
||||
|
||||
try {
|
||||
await redirectToIdp(undefined, formData);
|
||||
} catch (error: any) {
|
||||
// Redirect throws in tests
|
||||
expect(error.message).toContain("REDIRECT: /idp/ldap?");
|
||||
expect(error.message).toContain("requestId=req123");
|
||||
expect(error.message).toContain("organization=org123");
|
||||
expect(error.message).toContain("postErrorRedirectUrl=%2Fcustom-error");
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle postErrorRedirectUrl with special characters", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("id", "idp123");
|
||||
formData.append("provider", "google");
|
||||
formData.append("postErrorRedirectUrl", "https://app.example.com/error?code=123&message=test");
|
||||
|
||||
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
|
||||
|
||||
try {
|
||||
await redirectToIdp(undefined, formData);
|
||||
} catch (error: any) {
|
||||
// Redirect throws in tests
|
||||
expect(error.message).toContain("REDIRECT:");
|
||||
}
|
||||
|
||||
const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0];
|
||||
const successUrl = new URL(callArgs.urls.successUrl);
|
||||
const failureUrl = new URL(callArgs.urls.failureUrl);
|
||||
|
||||
// Verify the URL is properly encoded
|
||||
expect(successUrl.searchParams.get("postErrorRedirectUrl")).toBe(
|
||||
"https://app.example.com/error?code=123&message=test",
|
||||
);
|
||||
expect(failureUrl.searchParams.get("postErrorRedirectUrl")).toBe(
|
||||
"https://app.example.com/error?code=123&message=test",
|
||||
);
|
||||
});
|
||||
|
||||
test("should preserve postErrorRedirectUrl alongside linkOnly parameter", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("id", "idp123");
|
||||
formData.append("provider", "google");
|
||||
formData.append("linkOnly", "true");
|
||||
formData.append("postErrorRedirectUrl", "/custom-error");
|
||||
|
||||
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
|
||||
|
||||
try {
|
||||
await redirectToIdp(undefined, formData);
|
||||
} catch (error: any) {
|
||||
// Redirect throws in tests
|
||||
expect(error.message).toContain("REDIRECT:");
|
||||
}
|
||||
|
||||
const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0];
|
||||
const successUrl = callArgs.urls.successUrl;
|
||||
const failureUrl = callArgs.urls.failureUrl;
|
||||
|
||||
// Both parameters should be present
|
||||
expect(successUrl).toContain("link=true");
|
||||
expect(successUrl).toContain("postErrorRedirectUrl=%2Fcustom-error");
|
||||
expect(failureUrl).toContain("link=true");
|
||||
expect(failureUrl).toContain("postErrorRedirectUrl=%2Fcustom-error");
|
||||
});
|
||||
|
||||
test("should handle relative postErrorRedirectUrl paths", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("id", "idp123");
|
||||
formData.append("provider", "github");
|
||||
formData.append("postErrorRedirectUrl", "/loginname");
|
||||
|
||||
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
|
||||
|
||||
try {
|
||||
await redirectToIdp(undefined, formData);
|
||||
} catch (error: any) {
|
||||
// Redirect throws in tests
|
||||
expect(error.message).toContain("REDIRECT:");
|
||||
}
|
||||
|
||||
const callArgs = mockStartIdentityProviderFlow.mock.calls[0][0];
|
||||
expect(callArgs.urls.successUrl).toContain("postErrorRedirectUrl=%2Floginname");
|
||||
expect(callArgs.urls.failureUrl).toContain("postErrorRedirectUrl=%2Floginname");
|
||||
});
|
||||
});
|
||||
|
||||
describe("General redirect behavior", () => {
|
||||
test("should return error when IDP flow returns null", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("id", "idp123");
|
||||
formData.append("provider", "google");
|
||||
|
||||
mockStartIdentityProviderFlow.mockResolvedValue(null);
|
||||
|
||||
const result = await redirectToIdp(undefined, formData);
|
||||
|
||||
expect(result).toEqual({ error: "Unexpected response from IDP flow" });
|
||||
});
|
||||
|
||||
test("should redirect when IDP flow returns a valid URL", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("id", "idp123");
|
||||
formData.append("provider", "google");
|
||||
|
||||
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
|
||||
|
||||
try {
|
||||
await redirectToIdp(undefined, formData);
|
||||
// Should not reach here
|
||||
expect(true).toBe(false);
|
||||
} catch (error: any) {
|
||||
// Redirect throws in tests
|
||||
expect(error.message).toBe("REDIRECT: https://idp.example.com/auth");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,10 +29,12 @@ export async function redirectToIdp(prevState: RedirectToIdpState, formData: For
|
||||
const organization = formData.get("organization") as string;
|
||||
const idpId = formData.get("id") as string;
|
||||
const provider = formData.get("provider") as string;
|
||||
const postErrorRedirectUrl = formData.get("postErrorRedirectUrl") as string;
|
||||
|
||||
if (linkOnly) params.set("link", "true");
|
||||
if (requestId) params.set("requestId", requestId);
|
||||
if (organization) params.set("organization", organization);
|
||||
if (postErrorRedirectUrl) params.set("postErrorRedirectUrl", postErrorRedirectUrl);
|
||||
|
||||
// redirect to LDAP page where username and password is requested
|
||||
if (provider === "ldap") {
|
||||
|
||||
Reference in New Issue
Block a user