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:
Max Peintner
2025-10-16 11:24:54 +02:00
committed by Livio Spring
parent fd22d99f5b
commit 266d53307b
15 changed files with 515 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "Пригласить пользователя",

View File

@@ -182,6 +182,18 @@
"completeRegister": {
"title": "完成注册",
"description": "完成您的账户注册。"
},
"accountNotFound": {
"title": "未找到账户",
"description": "我们无法找到与您的身份提供商凭据关联的账户。",
"info": "未找到现有账户。请使用现有账户登录或联系您的管理员寻求帮助。",
"backToLogin": "返回登录"
},
"registrationFailed": {
"title": "注册不可用",
"description": "我们无法完成注册过程。",
"info": "无法确定注册的组织。请联系您的管理员寻求帮助。",
"backToLogin": "返回登录"
}
},
"ldap": {

View File

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

View File

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

View File

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

View File

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

View File

@@ -86,6 +86,7 @@ export default async function Page(props: { searchParams: Promise<Record<string
identityProviders={identityProviders}
requestId={requestId}
organization={organization}
postErrorRedirectUrl="/loginname"
></SignInWithIdp>
</div>
)}

View File

@@ -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;

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

View File

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