Files
zitadel/apps/login/src/components/register-form.tsx
T
9492c12c09 fix(login): refactor SAML Post Flow to bypass cookie limits, fix SAML SP post (#11170)
Closes #10816
Closes #10759

# Which Problems Are Solved

This PR refactors the SAML Post binding flow to address issues with
payloads exceeding browser cookie size limits (typically ~4KB).
Previously, the SAML form data was serialized into a cookie, and the
user was redirected to an intermediate /saml-post page to render the
form. This approach failed for large SAML assertions.

# How the Problems Are Solved

The new implementation bypasses cookies entirely for the payload
transfer. Server actions now return the form data directly to the
client, which immediately renders and submits the form.

- Direct Data Transfer: Updated `startIdentityProviderFlow` to return an
object { url, fields } instead of a redirect URL when the next step is
formData.
- Server Actions: Updated redirectToIdp, sendLoginname, and
completeAuthFlow to propagate this samlData object to the client.
- Client-Side Auto-Submission: Added a new AutoSubmitForm component that
renders a hidden form with the provided fields and automatically submits
it to the IdP on mount.

---------

Co-authored-by: conblem <[email protected]>
2026-02-09 13:46:30 +01:00

210 lines
6.9 KiB
TypeScript

"use client";
import { registerUser } from "@/lib/server/register";
import { handleServerActionResponse } from "@/lib/client";
import { LegalAndSupportSettings } from "@zitadel/proto/zitadel/settings/v2/legal_settings_pb";
import { LoginSettings, PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { FieldValues, useForm } from "react-hook-form";
import { Alert, AlertType } from "./alert";
import { AuthenticationMethod, AuthenticationMethodRadio, methods } from "./authentication-method-radio";
import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { PrivacyPolicyCheckboxes } from "./privacy-policy-checkboxes";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { AutoSubmitForm } from "./auto-submit-form";
type Inputs =
| {
firstname: string;
lastname: string;
email: string;
}
| FieldValues;
type Props = {
legal: LegalAndSupportSettings;
firstname?: string;
lastname?: string;
email?: string;
organization: string;
requestId?: string;
loginSettings?: LoginSettings;
idpCount: number;
};
export function RegisterForm({
legal,
email,
firstname,
lastname,
organization,
requestId,
loginSettings,
idpCount = 0,
}: Props) {
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onChange",
defaultValues: {
email: email ?? "",
firstName: firstname ?? "",
lastname: lastname ?? "",
},
});
const t = useTranslations("register");
const [loading, setLoading] = useState<boolean>(false);
const [selected, setSelected] = useState<AuthenticationMethod>(methods[0]);
const [error, setError] = useState<string>("");
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
const router = useRouter();
async function submitAndRegister(values: Inputs) {
setLoading(true);
try {
const response = await registerUser({
email: values.email,
firstName: values.firstname,
lastName: values.lastname,
organization: organization,
requestId: requestId,
});
handleServerActionResponse(response, router, setSamlData, setError);
return response;
} catch {
setError(t("errors.couldNotRegisterUser"));
} finally {
setLoading(false);
}
}
async function submitAndContinue(value: Inputs, withPassword: boolean = false) {
const registerParams: any = value;
if (organization) {
registerParams.organization = organization;
}
if (requestId) {
registerParams.requestId = requestId;
}
// redirect user to /register/password if password is chosen
if (withPassword) {
return router.push(`/register/password?` + new URLSearchParams(registerParams));
} else {
return submitAndRegister(value);
}
}
const { errors } = formState;
const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false);
// Check if legal acceptance is required
const isLegalAcceptanceRequired = !!(legal?.tosLink || legal?.privacyPolicyLink);
const canSubmit = formState.isValid && (!isLegalAcceptanceRequired || tosAndPolicyAccepted);
return (
<>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
<form className="w-full">
<div className="mb-4 grid grid-cols-2 gap-4">
<div className="">
<TextInput
type="firstname"
autoComplete="firstname"
required
{...register("firstname", { required: t("required.firstname") })}
label={t("labels.firstname")}
error={errors.firstname?.message as string}
data-testid="firstname-text-input"
/>
</div>
<div className="">
<TextInput
type="lastname"
autoComplete="lastname"
required
{...register("lastname", { required: t("required.lastname") })}
label={t("labels.lastname")}
error={errors.lastname?.message as string}
data-testid="lastname-text-input"
/>
</div>
<div className="col-span-2">
<TextInput
type="email"
autoComplete="email"
required
{...register("email", { required: t("required.email") })}
label={t("labels.email")}
error={errors.email?.message as string}
data-testid="email-text-input"
/>
</div>
</div>
{(legal?.tosLink || legal?.privacyPolicyLink) && (
<PrivacyPolicyCheckboxes legal={legal} onChange={setTosAndPolicyAccepted} />
)}
{/* show chooser if both methods are allowed */}
{loginSettings && loginSettings.allowLocalAuthentication && loginSettings.passkeysType == PasskeysType.ALLOWED && (
<>
<p className="ztdl-p mb-6 mt-4 block text-left">
<Translated i18nKey="selectMethod" namespace="register" />
</p>
<div className="pb-4">
<AuthenticationMethodRadio selected={selected} selectionChanged={setSelected} />
</div>
</>
)}
{!loginSettings?.allowLocalAuthentication &&
loginSettings?.passkeysType !== PasskeysType.ALLOWED &&
(!loginSettings?.allowExternalIdp || !idpCount) && (
<div className="py-4">
<Alert type={AlertType.INFO}>
<Translated i18nKey="noMethodAvailableWarning" namespace="register" />
</Alert>
</div>
)}
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center justify-between">
<BackButton data-testid="back-button" />
<Button
type="submit"
variant={ButtonVariants.Primary}
disabled={loading || !canSubmit}
onClick={handleSubmit((values) => {
const usePasswordToContinue: boolean =
loginSettings?.allowLocalAuthentication && loginSettings?.passkeysType == PasskeysType.ALLOWED
? !(selected === methods[0]) // choose selection if both available
: !!loginSettings?.allowLocalAuthentication; // if password is chosen
// set password as default if only password is allowed
return submitAndContinue(values, usePasswordToContinue);
})}
data-testid="submit-button"
>
{loading && <Spinner className="mr-2 h-5 w-5" />}
<Translated i18nKey="submit" namespace="register" />
</Button>
</div>
</form>
</>
);
}