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]>
This commit is contained in:
Max Peintner
2026-02-09 13:46:30 +01:00
committed by GitHub
co-authored by conblem
parent 6f4f54708e
commit 9492c12c09
36 changed files with 891 additions and 1454 deletions
+3 -1
View File
@@ -29,6 +29,7 @@
"clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"deepmerge": "^4.3.1",
"escape-html": "^1.0.3",
"lucide-react": "^0.562.0",
"moment": "^2.30.1",
"next": "15.5.9",
@@ -52,11 +53,12 @@
"@playwright/test": "^1.57.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1",
"@types/escape-html": "^1.0.4",
"@types/node": "^22.19.3",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"@types/uuid": "^10.0.0",
"@types/tinycolor2": "^1.4.6",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0",
"@vercel/git-hooks": "1.0.0",
@@ -18,10 +18,9 @@ export default async function CompleteRegistrationPage(props: {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const branding = await getBrandingSettings({ serviceConfig, organization,
});
const branding = await getBrandingSettings({ serviceConfig, organization });
if (!id || !token || !idpId || !organization || !idpUserId || !idpUserName) {
if (!id || !token || !idpId || !organization || !idpUserId) {
throw new Error("Missing required parameters");
}
@@ -1,49 +0,0 @@
import { getSAMLFormCookie } from "@/lib/saml";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const url = searchParams.get("url");
const id = searchParams.get("id");
if (!url) {
return new NextResponse("Missing url parameter", { status: 400 });
}
if (!id) {
return new NextResponse("Missing id parameter", { status: 400 });
}
const formData = await getSAMLFormCookie(id);
const formDataParsed = formData ? JSON.parse(formData) : null;
if (!formDataParsed) {
return new NextResponse("SAML form data not found", { status: 404 });
}
// Generate hidden input fields for all key-value pairs in formDataParsed
const hiddenInputs = Object.entries(formDataParsed)
.map(
([key, value]) =>
`<input type="hidden" name="${key}" value="${value}" />`,
)
.join("\n ");
// Respond with an HTML form that auto-submits via POST
const html = `
<html>
<body onload="document.forms[0].submit()">
<form action="${url}" method="post">
${hiddenInputs}
<noscript>
<button type="submit">Continue</button>
</noscript>
</form>
</body>
</html>
`;
return new NextResponse(html, {
headers: { "Content-Type": "text/html" },
});
}
@@ -0,0 +1,29 @@
"use client";
import { useEffect, useRef } from "react";
type Props = {
url: string;
fields: Record<string, string>;
};
export function AutoSubmitForm({ url, fields }: Props) {
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (formRef.current) {
formRef.current.submit();
}
}, []);
return (
<form ref={formRef} action={url} method="post">
{Object.entries(fields).map(([key, value]) => (
<input key={key} type="hidden" name={key} value={value} />
))}
<noscript>
<button type="submit">Continue</button>
</noscript>
</form>
);
}
@@ -16,6 +16,8 @@ import { TextInput } from "./input";
import { PasswordComplexity } from "./password-complexity";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { handleServerActionResponse } from "@/lib/client";
import { AutoSubmitForm } from "./auto-submit-form";
type Inputs =
| {
@@ -47,6 +49,7 @@ export function ChangePasswordForm({ passwordComplexitySettings, sessionId, logi
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
async function submitChange(values: Inputs) {
setLoading(true);
@@ -90,14 +93,7 @@ export function ChangePasswordForm({ passwordComplexitySettings, sessionId, logi
setLoading(false);
});
if (passwordResponse && "error" in passwordResponse && passwordResponse.error) {
setError(passwordResponse.error);
return;
}
if (passwordResponse && "redirect" in passwordResponse && passwordResponse.redirect) {
return router.push(passwordResponse.redirect);
}
handleServerActionResponse(passwordResponse as any, router, setSamlData, setError);
return;
}
@@ -122,6 +118,8 @@ export function ChangePasswordForm({ passwordComplexitySettings, sessionId, logi
hasMinLength;
return (
<>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
<form className="w-full">
<div className="mb-4 grid grid-cols-1 gap-4 pt-4">
<div className="">
@@ -175,5 +173,6 @@ export function ChangePasswordForm({ passwordComplexitySettings, sessionId, logi
</Button>
</div>
</form>
</>
);
}
@@ -7,6 +7,8 @@ import { useRouter } from "next/navigation";
import { EMAIL, SMS, TOTP, U2F } from "./auth-methods";
import { Translated } from "./translated";
import { useState } from "react";
import { handleServerActionResponse } from "@/lib/client";
import { AutoSubmitForm } from "./auto-submit-form";
import { Alert } from "./alert";
type Props = {
@@ -40,6 +42,7 @@ export function ChooseSecondFactorToSetup({
const params = new URLSearchParams({});
const [error, setError] = useState<string>("");
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
if (loginName) {
params.append("loginName", loginName);
@@ -59,6 +62,7 @@ export function ChooseSecondFactorToSetup({
return (
<>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
<div className="grid w-full grid-cols-1 gap-5 pt-4">
{loginSettings.secondFactors.map((factor) => {
switch (factor) {
@@ -89,15 +93,7 @@ export function ChooseSecondFactorToSetup({
requestId,
});
if (skipResponse && "error" in skipResponse && skipResponse.error) {
setError(skipResponse.error);
return;
}
// For regular flows (non-OIDC/SAML), return URL for client-side navigation
if ("redirect" in skipResponse && skipResponse.redirect) {
router.push(skipResponse.redirect);
}
handleServerActionResponse(skipResponse, router, setSamlData, setError);
}}
type="button"
data-testid="reset-button"
@@ -1,6 +1,7 @@
"use client";
import { processIDPCallback } from "@/lib/server/idp-intent";
import { AutoSubmitForm } from "./auto-submit-form";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
@@ -37,6 +38,7 @@ export function IdpProcessHandler({
const t = useTranslations("idp");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
const executedRef = useRef(false);
const router = useRouter();
@@ -74,6 +76,13 @@ export function IdpProcessHandler({
return;
}
if (result.samlData) {
console.log("[IDP Process Handler] Received samlData, rendering AutoSubmitForm");
setSamlData(result.samlData);
setLoading(false);
return;
}
setError(t("processing.noRedirect"));
setLoading(false);
})
@@ -86,6 +95,7 @@ export function IdpProcessHandler({
return (
<div className="flex min-h-screen items-center justify-center">
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
{loading && (
<div className="flex flex-col items-center space-y-4">
<Spinner className="h-8 w-8" />
+44 -51
View File
@@ -1,13 +1,13 @@
"use client";
import { completeFlowOrGetUrl } from "@/lib/client";
import { handleServerActionResponse, completeFlowOrGetUrl } from "@/lib/client";
import { updateOrCreateSession } from "@/lib/server/session";
import { create } from "@zitadel/client";
import { RequestChallengesSchema } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Alert, AlertType } from "./alert";
import { BackButton } from "./back-button";
@@ -16,6 +16,7 @@ import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { AutoSubmitForm } from "./auto-submit-form";
// either loginName or sessionId must be provided
type Props = {
@@ -38,6 +39,7 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
const router = useRouter();
@@ -50,22 +52,10 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
},
});
useEffect(() => {
if (!initialized.current && ["email", "sms"].includes(method) && !code) {
initialized.current = true;
setLoading(true);
updateSessionForOTPChallenge()
.catch((error) => {
setError(error);
return;
})
.finally(() => {
setLoading(false);
});
}
}, []);
async function updateSessionForOTPChallenge() {
const updateSessionForOTPChallenge = useCallback(async (): Promise<{
error?: string;
[key: string]: any;
}> => {
let challenges;
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
@@ -93,29 +83,41 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
});
}
setLoading(true);
const response = await updateOrCreateSession({
let response;
try {
response = await updateOrCreateSession({
loginName,
sessionId,
organization,
challenges,
requestId,
})
.catch(() => {
setError("Could not request OTP challenge");
return;
});
} catch {
return { error: "Could not request OTP challenge" };
}
if (response && "error" in response && response.error) {
return { error: response.error };
}
return response;
}, [method, host, requestId, loginName, sessionId, organization]);
useEffect(() => {
if (!initialized.current && ["email", "sms"].includes(method) && !code) {
initialized.current = true;
setLoading(true);
updateSessionForOTPChallenge()
.then((response) => {
if (response?.error) {
setError(response.error);
}
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
return response;
}
}, [updateSessionForOTPChallenge, method, code]);
async function submitCode(values: Inputs, organization?: string) {
setLoading(true);
@@ -174,7 +176,7 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
return response;
}
function setCodeAndContinue(values: Inputs, organization?: string) {
function setCodeAndContinue(values: Inputs) {
return submitCode(values, organization).then(async (response) => {
if (response && "sessionId" in response) {
setLoading(true);
@@ -198,14 +200,7 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
);
setLoading(false);
if ("error" in callbackResponse) {
setError(callbackResponse.error);
return;
}
if ("redirect" in callbackResponse) {
return router.push(callbackResponse.redirect);
}
handleServerActionResponse(callbackResponse, router, setSamlData, setError);
} else {
setLoading(false);
}
@@ -214,6 +209,8 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
}
return (
<>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
<form className="w-full">
{["email", "sms"].includes(method) && (
<Alert type={AlertType.INFO}>
@@ -226,16 +223,13 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
disabled={loading}
type="button"
className="ml-4 cursor-pointer text-primary-light-500 hover:text-primary-light-400 disabled:cursor-default disabled:text-gray-400 dark:text-primary-dark-500 hover:dark:text-primary-dark-400 dark:disabled:text-gray-700"
onClick={() => {
onClick={async () => {
setLoading(true);
updateSessionForOTPChallenge()
.catch((error) => {
setError(error);
return;
})
.finally(() => {
const response = await updateSessionForOTPChallenge();
if (response?.error) {
setError(response.error);
}
setLoading(false);
});
}}
data-testid="resend-button"
>
@@ -268,14 +262,13 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization,
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit((e) => {
setCodeAndContinue(e, organization);
})}
onClick={handleSubmit(setCodeAndContinue)}
data-testid="submit-button"
>
{loading && <Spinner className="mr-2 h-5 w-5" />} <Translated i18nKey="verify.submit" namespace="otp" />
</Button>
</div>
</form>
</>
);
}
+13 -16
View File
@@ -9,11 +9,13 @@ import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { handleServerActionResponse } from "@/lib/client";
import { Alert } from "./alert";
import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { AutoSubmitForm } from "./auto-submit-form";
// either loginName or sessionId must be provided
type Props = {
@@ -28,6 +30,7 @@ type Props = {
export function LoginPasskey({ loginName, sessionId, requestId, altPassword, organization, login = true }: Props) {
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
const t = useTranslations("passkey");
const router = useRouter();
@@ -106,6 +109,7 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
async function submitLogin(data: JsonObject) {
setLoading(true);
try {
const response = await sendPasskey({
loginName,
sessionId,
@@ -114,31 +118,23 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
webAuthN: { credentialAssertionData: data },
} as Checks,
requestId,
})
.catch(() => {
setError(t("verify.errors.couldNotVerifyPasskey"));
return;
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
const handled = handleServerActionResponse(response, router, setSamlData, setError);
if (response && "redirect" in response && response.redirect) {
return router.push(response.redirect);
}
// If we got here, something went wrong - no redirect or error was returned
if (!handled) {
if (!response) {
setError(t("verify.errors.noResponseReceived"));
} else {
setError(t("verify.errors.noRedirectProvided"));
}
}
} catch {
setError(t("verify.errors.couldNotVerifyPasskey"));
} finally {
setLoading(false);
}
}
async function submitLoginAndContinue(publicKey: any): Promise<boolean | void> {
publicKey.challenge = coerceToArrayBuffer(publicKey.challenge, "publicKey.challenge");
@@ -191,6 +187,7 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org
return (
<div className="w-full">
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
{error && (
<div className="py-4">
<Alert>{error}</Alert>
+12 -14
View File
@@ -14,6 +14,8 @@ import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { handleServerActionResponse } from "@/lib/client";
import { AutoSubmitForm } from "./auto-submit-form";
type Inputs = {
password: string;
@@ -36,6 +38,7 @@ export function PasswordForm({ loginSettings, loginName, organization, defaultOr
const [info, setInfo] = useState<string>("");
const [error, setError] = useState<string>("");
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
const [loading, setLoading] = useState<boolean>(false);
@@ -45,6 +48,7 @@ export function PasswordForm({ loginSettings, loginName, organization, defaultOr
setError("");
setLoading(true);
try {
const response = await sendPassword({
loginName,
organization,
@@ -53,22 +57,13 @@ export function PasswordForm({ loginSettings, loginName, organization, defaultOr
password: { password: values.password },
}),
requestId,
})
.catch(() => {
setError(t("verify.errors.couldNotVerifyPassword"));
return;
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
if (response && "redirect" in response && response.redirect) {
return router.push(response.redirect);
handleServerActionResponse(response, router, setSamlData, setError);
} catch {
setError(t("verify.errors.couldNotVerifyPassword"));
} finally {
setLoading(false);
}
}
@@ -114,6 +109,8 @@ export function PasswordForm({ loginSettings, loginName, organization, defaultOr
}
return (
<>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
<form className="w-full">
<div className={`${error && "transform-gpu animate-shake"}`}>
<TextInput
@@ -165,5 +162,6 @@ export function PasswordForm({ loginSettings, loginName, organization, defaultOr
</Button>
</div>
</form>
</>
);
}
@@ -1,6 +1,7 @@
"use client";
import { registerUserAndLinkToIDP } from "@/lib/server/register";
import { handleServerActionResponse } from "@/lib/client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { FieldValues, useForm } from "react-hook-form";
@@ -11,12 +12,14 @@ import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { AutoSubmitForm } from "./auto-submit-form";
type Inputs =
| {
firstname: string;
lastname: string;
email: string;
username?: string;
}
| FieldValues;
@@ -34,7 +37,7 @@ type Props = {
};
idpUserId: string;
idpId: string;
idpUserName: string;
idpUserName?: string;
};
export function RegisterFormIDPIncomplete({
@@ -60,12 +63,14 @@ export function RegisterFormIDPIncomplete({
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
async function submitAndRegister(values: Inputs) {
setLoading(true);
try {
const response = await registerUserAndLinkToIDP({
idpId: idpId,
idpUserName: idpUserName,
idpUserName: idpUserName ? idpUserName : values.username,
idpUserId: idpUserId,
email: values.email,
firstName: values.firstname,
@@ -73,30 +78,37 @@ export function RegisterFormIDPIncomplete({
organization: organization,
requestId: requestId,
idpIntent: idpIntent,
})
.catch(() => {
setError("Could not register user");
return;
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
if (response && "redirect" in response && response.redirect) {
return router.push(response.redirect);
handleServerActionResponse(response, router, setSamlData, setError);
} catch {
setError("Could not register user");
} finally {
setLoading(false);
}
}
const { errors } = formState;
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="mb-4 grid grid-cols-1 gap-4">
{!idpUserName && (
<div className="">
<TextInput
type="text"
autoComplete="username"
required
{...register("username", { required: "Username is required" })}
label="Username"
error={errors.username?.message as string}
data-testid="username-text-input"
/>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="">
<TextInput
type="firstname"
@@ -119,7 +131,8 @@ export function RegisterFormIDPIncomplete({
data-testid="lastname-text-input"
/>
</div>
<div className="col-span-2">
</div>
<div className="">
<TextInput
type="email"
autoComplete="email"
@@ -151,5 +164,6 @@ export function RegisterFormIDPIncomplete({
</Button>
</div>
</form>
</>
);
}
+13 -15
View File
@@ -1,6 +1,7 @@
"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";
@@ -15,6 +16,7 @@ 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 =
| {
@@ -59,36 +61,29 @@ export function RegisterForm({
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,
})
.catch(() => {
setError(t("errors.couldNotRegisterUser"));
return;
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
if (response && "redirect" in response && response.redirect) {
return router.push(response.redirect);
}
handleServerActionResponse(response, router, setSamlData, setError);
return response;
} catch {
setError(t("errors.couldNotRegisterUser"));
} finally {
setLoading(false);
}
}
async function submitAndContinue(value: Inputs, withPassword: boolean = false) {
@@ -119,6 +114,8 @@ export function RegisterForm({
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="">
@@ -207,5 +204,6 @@ export function RegisterForm({
</Button>
</div>
</form>
</>
);
}
+8 -17
View File
@@ -1,7 +1,7 @@
"use client";
import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
import { completeFlowOrGetUrl } from "@/lib/client";
import { completeFlowOrGetUrl, handleServerActionResponse } from "@/lib/client";
import { addU2F, verifyU2F } from "@/lib/server/u2f";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { RegisterU2FResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
@@ -12,6 +12,7 @@ import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { AutoSubmitForm } from "./auto-submit-form";
type Props = {
loginName?: string;
@@ -24,6 +25,7 @@ type Props = {
export function RegisterU2f({ loginName, sessionId, organization, requestId, checkAfter, loginSettings }: Props) {
const [error, setError] = useState<string>("");
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
const [loading, setLoading] = useState<boolean>(false);
@@ -155,14 +157,7 @@ export function RegisterU2f({ loginName, sessionId, organization, requestId, che
loginSettings?.defaultRedirectUri,
);
if ("error" in callbackResponse) {
setError(callbackResponse.error);
return;
}
if ("redirect" in callbackResponse) {
return router.push(callbackResponse.redirect);
}
handleServerActionResponse(callbackResponse, router, setSamlData, setError);
} else if (loginName) {
const callbackResponse = await completeFlowOrGetUrl(
{
@@ -172,20 +167,15 @@ export function RegisterU2f({ loginName, sessionId, organization, requestId, che
loginSettings?.defaultRedirectUri,
);
if ("error" in callbackResponse) {
setError(callbackResponse.error);
return;
}
if ("redirect" in callbackResponse) {
return router.push(callbackResponse.redirect);
}
handleServerActionResponse(callbackResponse, router, setSamlData, setError);
}
}
}
}
return (
<>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
<form className="w-full">
{error && (
<div className="py-4">
@@ -209,5 +199,6 @@ export function RegisterU2f({ loginName, sessionId, organization, requestId, che
</Button>
</div>
</form>
</>
);
}
+11 -22
View File
@@ -12,6 +12,8 @@ import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { Avatar } from "./avatar";
import { Translated } from "./translated";
import { handleServerActionResponse } from "@/lib/client";
import { AutoSubmitForm } from "./auto-submit-form";
export function isSessionValid(session: Partial<Session>): {
valid: boolean;
@@ -52,6 +54,7 @@ export function SessionItem({ session, reload, requestId }: { session: Session;
}
const { valid, verifiedAt } = isSessionValid(session);
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
const [_error, setError] = useState<string | null>(null);
@@ -59,6 +62,7 @@ export function SessionItem({ session, reload, requestId }: { session: Session;
return (
<Tooltip.Root delayDuration={300}>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
<Tooltip.Trigger asChild>
<button
onClick={async () => {
@@ -70,36 +74,21 @@ export function SessionItem({ session, reload, requestId }: { session: Session;
const callbackResponse = await continueWithSession(sessionPayload);
if (callbackResponse && "error" in callbackResponse) {
setError(callbackResponse.error);
return;
}
if (callbackResponse && "redirect" in callbackResponse) {
return router.push(callbackResponse.redirect);
}
handleServerActionResponse(callbackResponse, router, setSamlData, (e) => setError(e));
} else if (session.factors?.user) {
setLoading(true);
try {
const res = await sendLoginname({
loginName: session.factors?.user?.loginName,
organization: session.factors.user.organizationId,
requestId: requestId,
})
.catch(() => {
setError("An internal error occurred");
return;
})
.finally(() => {
setLoading(false);
});
if (res && "redirect" in res && res.redirect) {
return router.push(res.redirect);
}
if (res && "error" in res && res.error) {
setError(res.error);
return;
handleServerActionResponse(res, router, setSamlData, (e) => setError(e));
} catch {
setError("An internal error occurred");
} finally {
setLoading(false);
}
}
}}
@@ -16,6 +16,8 @@ import { TextInput } from "./input";
import { PasswordComplexity } from "./password-complexity";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { handleServerActionResponse } from "@/lib/client";
import { AutoSubmitForm } from "./auto-submit-form";
type Inputs =
| {
@@ -57,6 +59,7 @@ export function SetPasswordForm({
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
const router = useRouter();
@@ -144,14 +147,7 @@ export function SetPasswordForm({
setLoading(false);
});
if (passwordResponse && "error" in passwordResponse && passwordResponse.error) {
setError(passwordResponse.error);
return;
}
if (passwordResponse && "redirect" in passwordResponse && passwordResponse.redirect) {
return router.push(passwordResponse.redirect);
}
handleServerActionResponse(passwordResponse as any, router, setSamlData, setError);
return;
}
@@ -176,6 +172,8 @@ export function SetPasswordForm({
hasMinLength;
return (
<>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
<form className="w-full">
<div className="mb-4 grid grid-cols-1 gap-4 pt-4">
{codeRequired && (
@@ -265,5 +263,6 @@ export function SetPasswordForm({
</Button>
</div>
</form>
</>
);
}
@@ -2,6 +2,7 @@
import { lowerCaseValidator, numberValidator, symbolValidator, upperCaseValidator } from "@/helpers/validators";
import { registerUser } from "@/lib/server/register";
import { handleServerActionResponse } from "@/lib/client";
import { PasswordComplexitySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -14,6 +15,7 @@ import { TextInput } from "./input";
import { PasswordComplexity } from "./password-complexity";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { AutoSubmitForm } from "./auto-submit-form";
type Inputs =
| {
@@ -52,11 +54,13 @@ export function SetRegisterPasswordForm({
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
const router = useRouter();
async function submitRegister(values: Inputs) {
setLoading(true);
try {
const response = await registerUser({
email: email,
firstName: firstname,
@@ -64,22 +68,13 @@ export function SetRegisterPasswordForm({
organization: organization,
requestId: requestId,
password: values.password,
})
.catch(() => {
setError(t("errors.couldNotRegisterUser"));
return;
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response && response.error) {
setError(response.error);
return;
}
if (response && "redirect" in response && response.redirect) {
return router.push(response.redirect);
handleServerActionResponse(response, router, setSamlData, setError);
} catch {
setError(t("errors.couldNotRegisterUser"));
} finally {
setLoading(false);
}
}
@@ -103,6 +98,8 @@ export function SetRegisterPasswordForm({
hasMinLength;
return (
<>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
<form className="w-full">
<div className="mb-4 grid grid-cols-1 gap-4 pt-4">
<div className="">
@@ -156,5 +153,6 @@ export function SetRegisterPasswordForm({
</Button>
</div>
</form>
</>
);
}
@@ -13,6 +13,7 @@ import { SignInWithGithub } from "./idps/sign-in-with-github";
import { SignInWithGitlab } from "./idps/sign-in-with-gitlab";
import { SignInWithGoogle } from "./idps/sign-in-with-google";
import { Translated } from "./translated";
import { AutoSubmitForm } from "./auto-submit-form";
export interface SignInWithIDPProps {
children?: ReactNode;
@@ -68,6 +69,7 @@ export function SignInWithIdp({
return (
<div className="flex w-full flex-col space-y-2 text-sm">
{state?.samlData && <AutoSubmitForm url={state.samlData.url} fields={state.samlData.fields} />}
{showLabel && (
<p className="ztdl-p text-center">
<Translated i18nKey="orSignInWith" namespace="idp" />
+6 -17
View File
@@ -1,6 +1,6 @@
"use client";
import { completeFlowOrGetUrl } from "@/lib/client";
import { completeFlowOrGetUrl, handleServerActionResponse } from "@/lib/client";
import { verifyTOTP } from "@/lib/server/verify";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import Link from "next/link";
@@ -15,6 +15,7 @@ import { CopyToClipboard } from "./copy-to-clipboard";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { AutoSubmitForm } from "./auto-submit-form";
type Inputs = {
code: string;
@@ -33,6 +34,7 @@ type Props = {
export function TotpRegister({ uri, loginName, sessionId, requestId, organization, checkAfter, loginSettings }: Props) {
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
const router = useRouter();
const { register, handleSubmit, formState } = useForm<Inputs>({
@@ -74,14 +76,7 @@ export function TotpRegister({ uri, loginName, sessionId, requestId, organizatio
loginSettings?.defaultRedirectUri,
);
if ("error" in callbackResponse) {
setError(callbackResponse.error);
return;
}
if ("redirect" in callbackResponse) {
return router.push(callbackResponse.redirect);
}
handleServerActionResponse(callbackResponse, router, setSamlData, setError);
} else if (loginName) {
const callbackResponse = await completeFlowOrGetUrl(
{
@@ -91,14 +86,7 @@ export function TotpRegister({ uri, loginName, sessionId, requestId, organizatio
loginSettings?.defaultRedirectUri,
);
if ("error" in callbackResponse) {
setError(callbackResponse.error);
return;
}
if ("redirect" in callbackResponse) {
return router.push(callbackResponse.redirect);
}
handleServerActionResponse(callbackResponse, router, setSamlData, setError);
}
}
})
@@ -113,6 +101,7 @@ export function TotpRegister({ uri, loginName, sessionId, requestId, organizatio
return (
<div className="flex flex-col items-center">
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
{uri && (
<>
<QRCodeSVG className="my-4 h-40 w-40 rounded-md bg-white p-2" value={uri} />
+13 -16
View File
@@ -1,6 +1,7 @@
"use client";
import { sendLoginname } from "@/lib/server/loginname";
import { handleServerActionResponse } from "@/lib/client";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@@ -12,6 +13,7 @@ import { TextInput } from "./input";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { useTranslations } from "next-intl";
import { AutoSubmitForm } from "./auto-submit-form";
type Inputs = {
loginName: string;
@@ -51,10 +53,12 @@ export function UsernameForm({
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
async function submitLoginName(values: Inputs, organization?: string) {
setLoading(true);
try {
const res = await sendLoginname({
loginName: values.loginName,
organization,
@@ -62,25 +66,15 @@ export function UsernameForm({
requestId,
suffix,
ignoreUnknownUsernames: loginSettings?.ignoreUnknownUsernames,
})
.catch(() => {
setError(t("errors.internalError"));
return;
})
.finally(() => {
setLoading(false);
});
if (res && "redirect" in res && res.redirect) {
return router.push(res.redirect);
}
if (res && "error" in res && res.error) {
setError(res.error);
return;
}
handleServerActionResponse(res, router, setSamlData, setError);
return res;
} catch {
setError(t("errors.internalError"));
} finally {
setLoading(false);
}
}
useEffect(() => {
@@ -100,6 +94,8 @@ export function UsernameForm({
}
return (
<>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
<form className="w-full">
<div className="">
<TextInput
@@ -154,5 +150,6 @@ export function UsernameForm({
</Button>
</div>
</form>
</>
);
}
+10 -14
View File
@@ -2,6 +2,7 @@
import { Alert, AlertType } from "@/components/alert";
import { resendVerification, sendVerification } from "@/lib/server/verify";
import { handleServerActionResponse } from "@/lib/client";
import { UNKNOWN_USER_ID } from "@/lib/constants";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -12,6 +13,7 @@ import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
import { Translated } from "./translated";
import { AutoSubmitForm } from "./auto-submit-form";
type Inputs = {
code: string;
@@ -39,6 +41,7 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
const t = useTranslations("verify");
const [error, setError] = useState<string>("");
const [samlData, setSamlData] = useState<{ url: string; fields: Record<string, string> } | null>(null);
const [loading, setLoading] = useState<boolean>(false);
@@ -80,6 +83,7 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
setError("");
setLoading(true);
try {
const response = await sendVerification({
code: value.code,
userId,
@@ -87,22 +91,13 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
loginName: loginName,
organization: organization,
requestId: requestId,
})
.catch(() => {
setError(t("errors.couldNotVerifyUser"));
return;
})
.finally(() => {
setLoading(false);
});
if (response && "error" in response && response?.error) {
setError(response.error);
return;
}
if (response && "redirect" in response && response?.redirect) {
return router.push(response?.redirect);
handleServerActionResponse(response, router, setSamlData, setError);
} catch {
setError(t("errors.couldNotVerifyUser"));
} finally {
setLoading(false);
}
},
[isInvite, userId],
@@ -117,6 +112,7 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
return (
<>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
<form className="w-full">
<Alert type={AlertType.INFO}>
<div className="flex flex-row">
+41 -6
View File
@@ -43,7 +43,7 @@ function goToSignedInPage(
export async function completeFlowOrGetUrl(
command: FinishFlowCommand & { organization?: string },
defaultRedirectUri?: string,
): Promise<{ redirect: string } | { error: string }> {
): Promise<{ redirect: string } | { error: string } | { samlData: { url: string; fields: Record<string, string> } }> {
console.log("completeFlowOrGetUrl called with:", command, "defaultRedirectUri:", defaultRedirectUri);
// Complete OIDC/SAML flows directly with server action
@@ -58,16 +58,16 @@ export async function completeFlowOrGetUrl(
sessionId: command.sessionId,
requestId: command.requestId,
});
console.log("completeFlowOrGetUrl: OIDC/SAML flow result:", result);
console.log("completeFlowOrGetUrl: got OIDC/SAML flow result");
return result;
}
console.log("completeFlowOrGetUrl: Regular flow, getting next URL");
// For all other cases, return URL for navigation
const url = await getNextUrl(command, defaultRedirectUri);
console.log("completeFlowOrGetUrl: Next URL:", url);
console.log("completeFlowOrGetUrl: got Next URL:", url);
const result = { redirect: url };
console.log("completeFlowOrGetUrl: Final result:", result);
console.log("completeFlowOrGetUrl: got final result");
return result;
}
@@ -93,7 +93,7 @@ export async function getNextUrl(
...command,
organization: command.organization,
});
console.log("getNextUrl: Device flow result:", result);
console.log("getNextUrl: Got Device flow result");
return result;
}
@@ -106,6 +106,41 @@ export async function getNextUrl(
}
const result = goToSignedInPage(command);
console.log("getNextUrl: Using goToSignedInPage result:", result);
console.log("getNextUrl: got goToSignedInPage result");
return result;
}
export type ServerActionResponse =
| { redirect: string }
| { error: string }
| { samlData: { url: string; fields: Record<string, string> } }
| undefined
| null;
export function handleServerActionResponse(
response: ServerActionResponse,
router: { push: (url: string) => void },
setSamlData: (data: { url: string; fields: Record<string, string> }) => void,
setError: (error: string) => void,
): boolean {
if (!response) {
return false;
}
if ("redirect" in response && response.redirect) {
router.push(response.redirect);
return true;
}
if ("samlData" in response && response.samlData) {
setSamlData(response.samlData);
return true;
}
if ("error" in response && response.error) {
setError(response.error);
return true;
}
return false;
}
-572
View File
@@ -1,572 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { getSAMLFormUID, setSAMLFormCookie, getSAMLFormCookie, loginWithSAMLAndSession } from "./saml";
// Mock dependencies
vi.mock("uuid");
vi.mock("next/headers", () => ({
cookies: vi.fn(),
}));
vi.mock("./session", () => ({
isSessionValid: vi.fn(),
}));
vi.mock("@/lib/server/loginname", () => ({
sendLoginname: vi.fn(),
}));
vi.mock("@/lib/zitadel", () => ({
createResponse: vi.fn(),
getLoginSettings: vi.fn(),
}));
vi.mock("@zitadel/client", () => ({
create: vi.fn((schema, data) => data),
}));
import { v4 as uuidv4 } from "uuid";
import { cookies } from "next/headers";
import { isSessionValid } from "./session";
import { sendLoginname } from "@/lib/server/loginname";
import { createResponse, getLoginSettings, ServiceConfig } from "@/lib/zitadel";
describe("saml", () => {
let mockCookies: any;
beforeEach(() => {
vi.clearAllMocks();
mockCookies = {
get: vi.fn(),
set: vi.fn(),
};
vi.mocked(cookies).mockResolvedValue(mockCookies);
// Suppress console logs during tests
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("getSAMLFormUID", () => {
it("should generate a UUID v4", async () => {
const mockUuid = "saml-uid-123-456";
vi.mocked(uuidv4).mockReturnValue(mockUuid as any);
const result = await getSAMLFormUID();
expect(result).toBe(mockUuid);
expect(uuidv4).toHaveBeenCalledOnce();
});
it("should generate unique UIDs on multiple calls", async () => {
const uuid1 = "saml-uid-1";
const uuid2 = "saml-uid-2";
vi.mocked(uuidv4)
.mockReturnValueOnce(uuid1 as any)
.mockReturnValueOnce(uuid2 as any);
const result1 = await getSAMLFormUID();
const result2 = await getSAMLFormUID();
expect(result1).toBe(uuid1);
expect(result2).toBe(uuid2);
expect(result1).not.toBe(result2);
});
});
describe("setSAMLFormCookie", () => {
const mockUid = "test-saml-uid";
beforeEach(() => {
vi.mocked(uuidv4).mockReturnValue(mockUid as any);
});
it("should set SAML form cookie with correct parameters", async () => {
const samlValue = "SAMLRequest=encodedvalue";
const uid = await setSAMLFormCookie(samlValue);
expect(uid).toBe(mockUid);
expect(mockCookies.set).toHaveBeenCalledWith({
name: mockUid,
value: samlValue,
httpOnly: true,
secure: expect.any(Boolean),
sameSite: "lax",
path: "/",
maxAge: 5 * 60,
});
});
it("should set httpOnly to true", async () => {
await setSAMLFormCookie("test-value");
const callArgs = mockCookies.set.mock.calls[0][0];
expect(callArgs.httpOnly).toBe(true);
});
it("should set sameSite to 'lax'", async () => {
await setSAMLFormCookie("test-value");
const callArgs = mockCookies.set.mock.calls[0][0];
expect(callArgs.sameSite).toBe("lax");
});
it("should set path to root", async () => {
await setSAMLFormCookie("test-value");
const callArgs = mockCookies.set.mock.calls[0][0];
expect(callArgs.path).toBe("/");
});
it("should set maxAge to 5 minutes (300 seconds)", async () => {
await setSAMLFormCookie("test-value");
const callArgs = mockCookies.set.mock.calls[0][0];
expect(callArgs.maxAge).toBe(300);
expect(callArgs.maxAge).toBe(5 * 60);
});
it("should warn when value exceeds 4000 characters", async () => {
const largeSamlValue = "x".repeat(5000);
await setSAMLFormCookie(largeSamlValue);
// Expect no warning about size as it was removed for security/performance
expect(console.warn).not.toHaveBeenCalled();
});
it("should handle empty SAML value", async () => {
const uid = await setSAMLFormCookie("");
expect(uid).toBe(mockUid);
expect(mockCookies.set).toHaveBeenCalledWith(
expect.objectContaining({
value: "",
}),
);
});
it("should handle very long SAML values", async () => {
const longValue = "x".repeat(10000);
const uid = await setSAMLFormCookie(longValue);
expect(uid).toBe(mockUid);
// Expect no warning for long values
expect(console.warn).not.toHaveBeenCalled();
});
it("should throw error when cookie setting fails", async () => {
const error = new Error("Cookie set failed");
mockCookies.set.mockRejectedValue(error);
await expect(setSAMLFormCookie("test-value")).rejects.toThrow("Failed to set SAML form cookie");
});
it("should log error with details when cookie setting fails", async () => {
const error = new Error("Storage quota exceeded");
mockCookies.set.mockRejectedValue(error);
await expect(setSAMLFormCookie("test-value")).rejects.toThrow();
});
});
describe("getSAMLFormCookie", () => {
it("should retrieve SAML form cookie by UID", async () => {
const uid = "test-saml-uid";
const cookieValue = "SAMLRequest=encodedvalue";
mockCookies.get.mockReturnValue({
name: uid,
value: cookieValue,
});
const result = await getSAMLFormCookie(uid);
expect(result).toBe(cookieValue);
expect(mockCookies.get).toHaveBeenCalledWith(uid);
});
it("should return null if cookie not found", async () => {
mockCookies.get.mockReturnValue(undefined);
const result = await getSAMLFormCookie("non-existent-uid");
expect(result).toBeNull();
});
it("should return null if cookie has empty value", async () => {
mockCookies.get.mockReturnValue({
name: "test-uid",
value: "",
});
const result = await getSAMLFormCookie("test-uid");
expect(result).toBeNull();
});
it("should handle errors gracefully", async () => {
const error = new Error("Cookie read failed");
mockCookies.get.mockImplementation(() => {
throw error;
});
const result = await getSAMLFormCookie("test-uid");
expect(result).toBeNull();
});
it("should handle null cookie value", async () => {
mockCookies.get.mockReturnValue({
name: "test-uid",
value: null,
});
const result = await getSAMLFormCookie("test-uid");
expect(result).toBeNull();
});
it("should retrieve large SAML values", async () => {
const largeValue = "x".repeat(5000);
mockCookies.get.mockReturnValue({
name: "test-uid",
value: largeValue,
});
const result = await getSAMLFormCookie("test-uid");
expect(result).toBe(largeValue);
});
});
describe("loginWithSAMLAndSession", () => {
const mockSession: any = {
id: "session-123",
factors: {
user: {
loginName: "[email protected]",
organizationId: "org-123",
},
},
};
const mockCookie: any = {
id: "session-123",
token: "session-token-123",
loginName: "[email protected]",
};
const baseParams = {
serviceConfig: { baseUrl: "https://example.com" } as ServiceConfig,
samlRequest: "saml-request-id",
sessionId: "session-123",
sessions: [mockSession],
sessionCookies: [mockCookie],
};
it("should return redirect URL for valid session", async () => {
vi.mocked(isSessionValid).mockResolvedValue(true);
vi.mocked(createResponse).mockResolvedValue({
url: "https://sp.example.com/acs",
} as any);
const result = await loginWithSAMLAndSession(baseParams);
expect(result).toEqual({
redirect: "https://sp.example.com/acs",
});
});
it("should validate session using isSessionValid", async () => {
vi.mocked(isSessionValid).mockResolvedValue(true);
vi.mocked(createResponse).mockResolvedValue({
url: "https://sp.example.com/acs",
} as any);
await loginWithSAMLAndSession(baseParams);
expect(isSessionValid).toHaveBeenCalledWith({
serviceConfig: baseParams.serviceConfig,
session: mockSession,
});
});
it("should call createResponse with session info", async () => {
vi.mocked(isSessionValid).mockResolvedValue(true);
vi.mocked(createResponse).mockResolvedValue({
url: "https://sp.example.com/acs",
} as any);
await loginWithSAMLAndSession(baseParams);
expect(createResponse).toHaveBeenCalledWith({
serviceConfig: baseParams.serviceConfig,
req: expect.objectContaining({
samlRequestId: "saml-request-id",
responseKind: {
case: "session",
value: {
sessionId: "session-123",
sessionToken: "session-token-123",
},
},
}),
});
});
it("should redirect to re-authentication if session is invalid", async () => {
vi.mocked(isSessionValid).mockResolvedValue(false);
vi.mocked(sendLoginname).mockResolvedValue({
redirect: "/loginname",
});
const result = await loginWithSAMLAndSession(baseParams);
expect(sendLoginname).toHaveBeenCalledWith({
loginName: "[email protected]",
organization: "org-123",
requestId: "saml_saml-request-id",
});
expect(result).toEqual({
redirect: "/loginname",
});
});
it("should return error if session not found", async () => {
const params = {
...baseParams,
sessionId: "non-existent-session",
};
const result = await loginWithSAMLAndSession(params);
expect(result).toEqual({
error: "Session not found or invalid",
});
});
it("should return error if session cookie not found", async () => {
const params = {
...baseParams,
sessionCookies: [],
};
vi.mocked(isSessionValid).mockResolvedValue(true);
const result = await loginWithSAMLAndSession(params);
expect(result).toEqual({
error: "Session not found or invalid",
});
});
it("should return error if createResponse returns no URL", async () => {
vi.mocked(isSessionValid).mockResolvedValue(true);
vi.mocked(createResponse).mockResolvedValue({} as any);
const result = await loginWithSAMLAndSession(baseParams);
expect(result).toEqual({
error: "An error occurred!",
});
});
it("should handle SAML request already used (error code 9)", async () => {
vi.mocked(isSessionValid).mockResolvedValue(true);
vi.mocked(createResponse).mockRejectedValue({ code: 9 });
vi.mocked(getLoginSettings).mockResolvedValue({
defaultRedirectUri: "https://example.com/default",
} as any);
const result = await loginWithSAMLAndSession(baseParams);
expect(result).toEqual({
redirect: "https://example.com/default",
});
});
it("should fallback to /signedin if no defaultRedirectUri (error code 9)", async () => {
vi.mocked(isSessionValid).mockResolvedValue(true);
vi.mocked(createResponse).mockRejectedValue({ code: 9 });
vi.mocked(getLoginSettings).mockResolvedValue({} as any);
const result = await loginWithSAMLAndSession(baseParams);
expect(result).toEqual({
redirect: expect.stringContaining("/signedin"),
});
});
it("should include loginName and organization in /signedin redirect", async () => {
vi.mocked(isSessionValid).mockResolvedValue(true);
vi.mocked(createResponse).mockRejectedValue({ code: 9 });
vi.mocked(getLoginSettings).mockResolvedValue({} as any);
const result = await loginWithSAMLAndSession(baseParams);
expect(result).toEqual({
redirect: expect.stringContaining("loginName=user%40example.com"),
});
expect(result).toEqual({
redirect: expect.stringContaining("organization=org-123"),
});
});
it("should return error for unknown errors from createResponse", async () => {
vi.mocked(isSessionValid).mockResolvedValue(true);
vi.mocked(createResponse).mockRejectedValue(new Error("Network error"));
const result = await loginWithSAMLAndSession(baseParams);
expect(result).toEqual({
error: "Unknown error occurred",
});
});
it("should handle session without user factors", async () => {
const sessionWithoutUser = {
id: "session-123",
factors: {},
} as any;
const params = {
...baseParams,
sessions: [sessionWithoutUser],
};
vi.mocked(isSessionValid).mockResolvedValue(true);
vi.mocked(createResponse).mockResolvedValue({
url: "https://sp.example.com/acs",
} as any);
const result = await loginWithSAMLAndSession(params);
expect(result).toEqual({
redirect: "https://sp.example.com/acs",
});
});
it("should handle multiple sessions with correct selection", async () => {
const otherSession: any = {
id: "session-456",
factors: {
user: {
loginName: "[email protected]",
},
},
};
const params = {
...baseParams,
sessions: [otherSession, mockSession],
};
vi.mocked(isSessionValid).mockResolvedValue(true);
vi.mocked(createResponse).mockResolvedValue({
url: "https://sp.example.com/acs",
} as any);
await loginWithSAMLAndSession(params);
expect(isSessionValid).toHaveBeenCalledWith({
serviceConfig: baseParams.serviceConfig,
session: mockSession,
});
});
it("should match session cookie by ID", async () => {
const otherCookie: any = {
id: "session-456",
token: "other-token",
loginName: "[email protected]",
};
const params = {
...baseParams,
sessionCookies: [otherCookie, mockCookie],
};
vi.mocked(isSessionValid).mockResolvedValue(true);
vi.mocked(createResponse).mockResolvedValue({
url: "https://sp.example.com/acs",
} as any);
await loginWithSAMLAndSession(params);
expect(createResponse).toHaveBeenCalledWith({
serviceConfig: baseParams.serviceConfig,
req: expect.objectContaining({
responseKind: {
case: "session",
value: {
sessionId: "session-123",
sessionToken: "session-token-123",
},
},
}),
});
});
it("should handle error code 9 without loginName", async () => {
const sessionWithoutLoginName = {
id: "session-123",
factors: {
user: {
organizationId: "org-123",
},
},
} as any;
const params = {
...baseParams,
sessions: [sessionWithoutLoginName],
};
vi.mocked(isSessionValid).mockResolvedValue(true);
vi.mocked(createResponse).mockRejectedValue({ code: 9 });
vi.mocked(getLoginSettings).mockResolvedValue({} as any);
const result = await loginWithSAMLAndSession(params);
expect(result).toEqual({
redirect: expect.stringContaining("/signedin"),
});
expect(result).toEqual({
redirect: expect.not.stringContaining("loginName="),
});
});
it("should handle error code 9 without organization", async () => {
const sessionWithoutOrg = {
id: "session-123",
factors: {
user: {
loginName: "[email protected]",
},
},
} as any;
const params = {
...baseParams,
sessions: [sessionWithoutOrg],
};
vi.mocked(isSessionValid).mockResolvedValue(true);
vi.mocked(createResponse).mockRejectedValue({ code: 9 });
vi.mocked(getLoginSettings).mockResolvedValue({} as any);
const result = await loginWithSAMLAndSession(params);
expect(result).toEqual({
redirect: expect.stringContaining("/signedin"),
});
expect(result).toEqual({
redirect: expect.not.stringContaining("organization="),
});
});
});
});
+21 -56
View File
@@ -4,8 +4,6 @@ import { createResponse, getLoginSettings, ServiceConfig } from "@/lib/zitadel";
import { create } from "@zitadel/client";
import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { cookies } from "next/headers";
import { v4 as uuidv4 } from "uuid";
import { isSessionValid } from "./session";
type LoginWithSAMLAndSession = {
@@ -16,64 +14,17 @@ type LoginWithSAMLAndSession = {
sessionCookies: Cookie[];
};
export async function getSAMLFormUID() {
return uuidv4();
}
export async function setSAMLFormCookie(value: string): Promise<string> {
const cookiesList = await cookies();
const uid = await getSAMLFormUID();
try {
// Log the attempt
await cookiesList.set({
name: uid,
value: value,
httpOnly: true,
secure: process.env.NODE_ENV === "production", // Required for HTTPS in production
sameSite: "lax", // Allows cookies with top-level navigation (needed for SAML redirects)
path: "/",
maxAge: 5 * 60, // 5 minutes
});
// Note: We can't reliably verify immediately due to Next.js cookies API behavior
// Instead, we'll rely on the getSAMLFormCookie function to detect failures
console.log(`Successfully set SAML form cookie with uid: ${uid}`);
return uid;
} catch (error) {
throw new Error(`Failed to set SAML form cookie: ${error instanceof Error ? error.message : String(error)}`);
}
}
export async function getSAMLFormCookie(uid: string): Promise<string | null> {
const cookiesList = await cookies();
try {
const cookie = cookiesList.get(uid);
if (!cookie) {
return null;
}
if (!cookie.value) {
return null;
}
return cookie.value;
} catch {
return null;
}
}
export async function loginWithSAMLAndSession({
serviceConfig,
samlRequest,
sessionId,
sessions,
sessionCookies,
}: LoginWithSAMLAndSession): Promise<{ error: string } | { redirect: string }> {
}: LoginWithSAMLAndSession): Promise<
{ error: string } | { redirect: string } | { samlData: { url: string; fields: Record<string, string> } }
> {
console.log(`Login with session: ${sessionId} and samlRequest: ${samlRequest}`);
const selectedSession = sessions.find((s) => s.id === sessionId);
if (selectedSession && selectedSession.id) {
@@ -93,6 +44,10 @@ export async function loginWithSAMLAndSession({
if (res && "redirect" in res && res?.redirect) {
return { redirect: res.redirect };
}
if (res && "samlData" in res && res?.samlData) {
return { samlData: res.samlData };
}
}
const cookie = sessionCookies.find((cookie) => cookie.id === selectedSession?.id);
@@ -105,7 +60,7 @@ export async function loginWithSAMLAndSession({
// works not with _rsc request
try {
const { url } = await createResponse({
const { url, binding } = await createResponse({
serviceConfig,
req: create(CreateResponseRequestSchema, {
samlRequestId: samlRequest,
@@ -115,8 +70,18 @@ export async function loginWithSAMLAndSession({
},
}),
});
if (url) {
if (url && binding.case === "redirect") {
return { redirect: url };
} else if (url && binding.case === "post") {
return {
samlData: {
url,
fields: {
RelayState: binding.value.relayState,
SAMLResponse: binding.value.samlResponse,
},
},
};
} else {
return { error: "An error occurred!" };
}
+15 -6
View File
@@ -15,8 +15,7 @@ export interface AuthFlowParams {
}
async function loadSessions({ serviceConfig, ids }: { serviceConfig: ServiceConfig; ids: string[] }): Promise<Session[]> {
const response = await listSessions({ serviceConfig, ids: ids.filter((id: string | undefined) => !!id),
});
const response = await listSessions({ serviceConfig, ids: ids.filter((id: string | undefined) => !!id) });
return response?.sessions ?? [];
}
@@ -27,7 +26,9 @@ async function loadSessions({ serviceConfig, ids }: { serviceConfig: ServiceConf
* This is the shared logic for flow completion
* Returns either an error or a redirect URL for client-side navigation
*/
export async function completeAuthFlow(command: AuthFlowParams): Promise<{ error: string } | { redirect: string }> {
export async function completeAuthFlow(
command: AuthFlowParams,
): Promise<{ error: string } | { redirect: string } | { samlData: { url: string; fields: Record<string, string> } }> {
const { sessionId, requestId } = command;
const _headers = await headers();
@@ -43,7 +44,9 @@ export async function completeAuthFlow(command: AuthFlowParams): Promise<{ error
if (requestId.startsWith("oidc_")) {
// Complete OIDC flow
const result = await loginWithOIDCAndSession({ serviceConfig, authRequest: requestId.replace("oidc_", ""),
const result = await loginWithOIDCAndSession({
serviceConfig,
authRequest: requestId.replace("oidc_", ""),
sessionId,
sessions,
sessionCookies,
@@ -58,14 +61,20 @@ export async function completeAuthFlow(command: AuthFlowParams): Promise<{ error
return result;
} else if (requestId.startsWith("saml_")) {
// Complete SAML flow
const result = await loginWithSAMLAndSession({ serviceConfig, samlRequest: requestId.replace("saml_", ""),
const result = await loginWithSAMLAndSession({
serviceConfig,
samlRequest: requestId.replace("saml_", ""),
sessionId,
sessions,
sessionCookies,
});
// Safety net - ensure we always return a valid object
if (!result || typeof result !== "object" || (!("redirect" in result) && !("error" in result))) {
if (
!result ||
typeof result !== "object" ||
(!("redirect" in result) && !("error" in result) && !("samlData" in result))
) {
console.error("Auth flow: Invalid result from loginWithSAMLAndSession:", result);
return { error: "Authentication completed but navigation failed" };
}
+31 -5
View File
@@ -21,6 +21,7 @@ import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { NextRequest, NextResponse } from "next/server";
import { DEFAULT_CSP } from "../../../constants/csp";
import escapeHtml from "escape-html";
const ORG_SCOPE_REGEX = /urn:zitadel:iam:org:id:([0-9]+)/;
const ORG_DOMAIN_SCOPE_REGEX = /urn:zitadel:iam:org:domain:primary:(.+)/;
@@ -131,7 +132,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
params.set("organization", organization);
}
let url: string | null = await startIdentityProviderFlow({
const response = await startIdentityProviderFlow({
serviceConfig,
idpId,
urls: {
@@ -140,10 +141,35 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
},
});
if (!url) {
if (!response || !response.url) {
return NextResponse.json({ error: "Could not start IDP flow" }, { status: 500 });
}
if (response.fields) {
const hiddenInputs = Object.entries(response.fields)
.map(([key, value]) => `<input type="hidden" name="${escapeHtml(key)}" value="${escapeHtml(value)}" />`)
.join("\n");
const html = `
<html>
<body onload="document.forms[0].submit()">
<form action="${escapeHtml(response.url)}" method="post">
${hiddenInputs}
<noscript>
<button type="submit">Continue</button>
</noscript>
</form>
</body>
</html>
`;
return new NextResponse(html, {
headers: { "Content-Type": "text/html" },
});
}
let url = response.url;
if (url.startsWith("/")) {
url = constructUrl(request, url).toString();
}
@@ -404,9 +430,9 @@ export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Pr
const html = `
<html>
<body onload="document.forms[0].submit()">
<form action="${url}" method="post">
<input type="hidden" name="RelayState" value="${binding.value.relayState}" />
<input type="hidden" name="SAMLResponse" value="${binding.value.samlResponse}" />
<form action="${escapeHtml(url)}" method="post">
<input type="hidden" name="RelayState" value="${escapeHtml(binding.value.relayState)}" />
<input type="hidden" name="SAMLResponse" value="${escapeHtml(binding.value.samlResponse)}" />
<noscript>
<button type="submit">Continue</button>
</noscript>
+34 -14
View File
@@ -1,21 +1,22 @@
"use server";
import { getSessionCookieById } from "@/lib/cookies";
import { getServiceConfig } from "@/lib/service-url";
import {
retrieveIDPIntent,
getIDPByID,
addIDPLink,
listUsers,
addHuman,
addIDPLink,
getActiveIdentityProviders,
getDefaultOrg,
getIDPByID,
getLoginSettings,
getOrgsByDomain,
getActiveIdentityProviders,
getSession,
getUserByID,
getDefaultOrg,
updateHuman,
listUsers,
retrieveIDPIntent,
ServiceConfig,
updateHuman,
} from "@/lib/zitadel";
import { headers } from "next/headers";
import { Code, ConnectError, create } from "@zitadel/client";
import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb";
import { OrganizationSchema } from "@zitadel/proto/zitadel/object/v2/object_pb";
@@ -24,12 +25,11 @@ import {
AddHumanUserRequestSchema,
UpdateHumanUserRequestSchema,
} from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { getSession } from "@/lib/zitadel";
import { getSessionCookieById } from "@/lib/cookies";
import { createNewSessionFromIdpIntent } from "./idp";
import { getTranslations } from "next-intl/server";
import crypto from "crypto";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { getFingerprintIdCookie } from "../fingerprint";
import { createNewSessionFromIdpIntent } from "./idp";
const ORG_SUFFIX_REGEX = /(?<=@)(.+)/;
@@ -121,7 +121,7 @@ interface IDPHandlerContext {
buildRedirectParams: (additionalParams?: Record<string, string>, includeToken?: boolean) => string;
}
type IDPHandlerResult = { redirect?: string; error?: string } | null;
type IDPHandlerResult = { redirect?: string; error?: string; samlData?: { url: string; fields: Record<string, string> } } | null;
/**
* CASE 1: Explicit Linking (via sessionId)
@@ -259,6 +259,11 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise<IDPHandler
return { redirect: sessionResult.redirect };
}
if ("samlData" in sessionResult && sessionResult.samlData) {
console.log("[IDP Process] Session created, returning samlData");
return { samlData: sessionResult.samlData };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error) {
console.error("[IDP Process] Error linking IDP:", error);
@@ -324,6 +329,11 @@ async function handleUserExists(ctx: IDPHandlerContext): Promise<IDPHandlerResul
return { redirect: sessionResult.redirect };
}
if ("samlData" in sessionResult && sessionResult.samlData) {
console.log("[IDP Process] Session created, returning samlData");
return { samlData: sessionResult.samlData };
}
return { error: t("errors.sessionCreationFailed") };
}
@@ -418,6 +428,11 @@ async function handleAutoLinking(ctx: IDPHandlerContext): Promise<IDPHandlerResu
return { redirect: sessionResult.redirect };
}
if ("samlData" in sessionResult && sessionResult.samlData) {
console.log("[IDP Process] Session created, returning samlData");
return { samlData: sessionResult.samlData };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error) {
console.error("[IDP Process] Error auto-linking user:", error);
@@ -486,6 +501,11 @@ async function handleAutoCreation(ctx: IDPHandlerContext): Promise<IDPHandlerRes
return { redirect: sessionResult.redirect };
}
if ("samlData" in sessionResult && sessionResult.samlData) {
console.log("[IDP Process] Session created, returning samlData");
return { samlData: sessionResult.samlData };
}
return { error: t("errors.sessionCreationFailed") };
} catch (error: unknown) {
console.error("[IDP Process] Error auto-creating user:", error);
@@ -575,7 +595,7 @@ export async function processIDPCallback({
postErrorRedirectUrl?: string;
sessionId?: string;
linkFingerprint?: string;
}): Promise<{ redirect?: string; error?: string }> {
}): Promise<{ redirect?: string; error?: string; samlData?: { url: string; fields: Record<string, string> } }> {
// ... (headers and config retrieval) ...
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
+7 -7
View File
@@ -126,7 +126,7 @@ describe("redirectToIdp", () => {
formData.append("requestId", "req123");
formData.append("organization", "org123");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
mockStartIdentityProviderFlow.mockResolvedValue({ url: "https://idp.example.com/auth" });
try {
await redirectToIdp(undefined, formData);
@@ -155,7 +155,7 @@ describe("redirectToIdp", () => {
formData.append("provider", "google");
formData.append("postErrorRedirectUrl", "");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
mockStartIdentityProviderFlow.mockResolvedValue({ url: "https://idp.example.com/auth" });
try {
await redirectToIdp(undefined, formData);
@@ -194,7 +194,7 @@ describe("redirectToIdp", () => {
formData.append("provider", "google");
formData.append("postErrorRedirectUrl", "https://app.example.com/error?code=123&message=test");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
mockStartIdentityProviderFlow.mockResolvedValue({ url: "https://idp.example.com/auth" });
try {
await redirectToIdp(undefined, formData);
@@ -223,7 +223,7 @@ describe("redirectToIdp", () => {
formData.append("sessionId", "session123");
formData.append("postErrorRedirectUrl", "/custom-error");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
mockStartIdentityProviderFlow.mockResolvedValue({ url: "https://idp.example.com/auth" });
try {
await redirectToIdp(undefined, formData);
@@ -254,7 +254,7 @@ describe("redirectToIdp", () => {
formData.append("provider", "github");
formData.append("postErrorRedirectUrl", "/loginname");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
mockStartIdentityProviderFlow.mockResolvedValue({ url: "https://idp.example.com/auth" });
try {
await redirectToIdp(undefined, formData);
@@ -279,7 +279,7 @@ describe("redirectToIdp", () => {
const result = await redirectToIdp(undefined, formData);
expect(result).toEqual({ error: "Unexpected response from IDP flow" });
expect(result).toEqual({ error: "Could not start IDP flow" });
});
test("should redirect when IDP flow returns a valid URL", async () => {
@@ -287,7 +287,7 @@ describe("redirectToIdp", () => {
formData.append("id", "idp123");
formData.append("provider", "google");
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
mockStartIdentityProviderFlow.mockResolvedValue({ url: "https://idp.example.com/auth" });
try {
await redirectToIdp(undefined, formData);
+18 -4
View File
@@ -19,7 +19,9 @@ import { checkEmailVerification, checkMFAFactors } from "../verify-helper";
import { createSessionForIdpAndUpdateCookie } from "./cookie";
import { getPublicHost } from "./host";
export type RedirectToIdpState = { error?: string | null } | undefined;
export type RedirectToIdpState =
| { error?: string | null; samlData?: { url: string; fields: Record<string, string> } }
| undefined;
export async function redirectToIdp(prevState: RedirectToIdpState, formData: FormData): Promise<RedirectToIdpState> {
const _headers = await headers();
@@ -77,10 +79,18 @@ export async function redirectToIdp(prevState: RedirectToIdpState, formData: For
return { error: "Could not start IDP flow" };
}
if (response && "error" in response && response?.error) {
return { error: response.error };
}
if (response && "redirect" in response && response?.redirect) {
redirect(response.redirect);
}
if (response && "samlData" in response && response?.samlData) {
return { samlData: response.samlData };
}
return { error: "Unexpected response from IDP flow" };
}
@@ -95,7 +105,7 @@ export type StartIDPFlowCommand = {
async function startIDPFlow(command: StartIDPFlowCommand) {
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const url = await startIdentityProviderFlow({
const response = await startIdentityProviderFlow({
serviceConfig: command.serviceConfig,
idpId: command.idpId,
urls: {
@@ -104,11 +114,15 @@ async function startIDPFlow(command: StartIDPFlowCommand) {
},
});
if (!url) {
if (!response || !response.url) {
return { error: "Could not start IDP flow" };
}
return { redirect: url };
if (response.fields) {
return { samlData: { url: response.url, fields: response.fields } };
}
return { redirect: response.url };
}
export type CreateNewSessionCommand = {
+6 -6
View File
@@ -239,7 +239,7 @@ describe("sendLoginname", () => {
mockListIDPLinks.mockResolvedValue({
result: [{ idpId: "idp123" }],
});
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
mockStartIdentityProviderFlow.mockResolvedValue({ url: "https://idp.example.com/auth" });
const result = await sendLoginname({
loginName: "[email protected]",
@@ -279,7 +279,7 @@ describe("sendLoginname", () => {
identityProviders: [{ id: "org-idp-123", type: 0 }],
});
mockIdpTypeToSlug.mockReturnValue("google");
mockStartIdentityProviderFlow.mockResolvedValue("https://org-idp.example.com/auth");
mockStartIdentityProviderFlow.mockResolvedValue({ url: "https://org-idp.example.com/auth" });
const result = await sendLoginname({
loginName: "[email protected]",
@@ -349,7 +349,7 @@ describe("sendLoginname", () => {
mockListIDPLinks.mockResolvedValue({
result: [{ idpId: "idp123" }],
});
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
mockStartIdentityProviderFlow.mockResolvedValue({ url: "https://idp.example.com/auth" });
const result = await sendLoginname({
loginName: "[email protected]",
@@ -417,7 +417,7 @@ describe("sendLoginname", () => {
mockListIDPLinks.mockResolvedValue({
result: [{ idpId: "idp123" }],
});
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
mockStartIdentityProviderFlow.mockResolvedValue({ url: "https://idp.example.com/auth" });
const result = await sendLoginname({
loginName: "[email protected]",
@@ -471,7 +471,7 @@ describe("sendLoginname", () => {
mockGetActiveIdentityProviders.mockResolvedValue({
identityProviders: [{ id: "idp123", type: "OIDC" }],
});
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth");
mockStartIdentityProviderFlow.mockResolvedValue({ url: "https://idp.example.com/auth" });
const result = await sendLoginname({
loginName: "[email protected]",
@@ -594,7 +594,7 @@ describe("sendLoginname", () => {
mockGetActiveIdentityProviders.mockResolvedValue({
identityProviders: [{ id: "idp123", type: "OIDC" }],
});
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth?org=discovered-org-456");
mockStartIdentityProviderFlow.mockResolvedValue({ url: "https://idp.example.com/auth?org=discovered-org-456" });
const result = await sendLoginname({
loginName: "[email protected]",
+14 -6
View File
@@ -143,7 +143,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const url = await startIdentityProviderFlow({
const response = await startIdentityProviderFlow({
serviceConfig,
idpId: activeIdps[0].id,
urls: {
@@ -156,11 +156,15 @@ export async function sendLoginname(command: SendLoginnameCommand) {
},
});
if (!url) {
if (!response || !response.url) {
return { error: t("errors.couldNotStartIDPFlow") };
}
return { redirect: url };
if (response.fields) {
return { samlData: { url: response.url, fields: response.fields } };
}
return { redirect: response.url };
}
}
@@ -198,7 +202,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const url = await startIdentityProviderFlow({
const response = await startIdentityProviderFlow({
serviceConfig,
idpId: idp.id,
urls: {
@@ -211,11 +215,15 @@ export async function sendLoginname(command: SendLoginnameCommand) {
},
});
if (!url) {
if (!response || !response.url) {
return { error: t("errors.couldNotStartIDPFlow") };
}
return { redirect: url };
if (response.fields) {
return { samlData: { url: response.url, fields: response.fields } };
}
return { redirect: response.url };
}
};
+1 -5
View File
@@ -326,11 +326,7 @@ export async function sendPasskey(command: SendPasskeyCommand) {
}
// Check if we got a valid redirect result
if (redirectResult && typeof redirectResult === "object" && "redirect" in redirectResult && redirectResult.redirect) {
return redirectResult;
}
if (redirectResult && typeof redirectResult === "object" && "error" in redirectResult) {
if (redirectResult && typeof redirectResult === "object") {
return redirectResult;
}
+15 -22
View File
@@ -127,7 +127,9 @@ export type UpdateSessionCommand = {
requestId?: string;
};
export async function sendPassword(command: UpdateSessionCommand): Promise<{ error: string } | { redirect: string }> {
export async function sendPassword(
command: UpdateSessionCommand,
): Promise<{ error: string } | { redirect: string } | { samlData: { url: string; fields: Record<string, string> } }> {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const t = await getTranslations("password");
@@ -368,10 +370,12 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err
return mfaFactorCheck;
}
let result: Awaited<ReturnType<typeof completeFlowOrGetUrl>>;
if (command.requestId && session.id) {
// OIDC/SAML flow - use completeFlowOrGetUrl for proper handling
// OIDC/SAML flow
console.log("Password auth: OIDC/SAML flow with requestId:", command.requestId, "sessionId:", session.id);
const result = await completeFlowOrGetUrl(
result = await completeFlowOrGetUrl(
{
sessionId: session.id,
requestId: command.requestId,
@@ -379,34 +383,23 @@ export async function sendPassword(command: UpdateSessionCommand): Promise<{ err
},
loginSettingsByUser?.defaultRedirectUri,
);
console.log("Password auth: OIDC/SAML flow result:", result);
// Safety net - ensure we always return a valid object
if (!result || typeof result !== "object" || (!("redirect" in result) && !("error" in result))) {
console.error("Password auth: Invalid result from completeFlowOrGetUrl (OIDC/SAML):", result);
return { error: "Authentication completed but navigation failed" };
}
return result;
}
// Regular flow (no requestId) - return URL for client-side navigation
const result = await completeFlowOrGetUrl(
} else {
// Regular flow (no requestId)
console.log("Password auth: Regular flow with loginName:", session.factors.user.loginName);
result = await completeFlowOrGetUrl(
{
loginName: session.factors.user.loginName,
organization: session.factors?.user?.organizationId,
},
loginSettingsByUser?.defaultRedirectUri,
);
// Safety net - ensure we always return a valid object
if (!result || typeof result !== "object" || (!("redirect" in result) && !("error" in result))) {
console.error("Password auth: Invalid result from completeFlowOrGetUrl:", result);
return { error: "Authentication completed but navigation failed" };
}
if (result && typeof result === "object") {
return result;
}
return { error: "Authentication completed but navigation failed" };
}
// this function lets users with code set a password or users with valid User Verification Check
+6 -2
View File
@@ -27,7 +27,9 @@ export type RegisterUserResponse = {
sessionId: string;
factors: Factors | undefined;
};
export async function registerUser(command: RegisterUserCommand) {
export async function registerUser(
command: RegisterUserCommand,
): Promise<{ error: string } | { redirect: string } | { samlData: { url: string; fields: Record<string, string> } }> {
const t = await getTranslations("register");
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
@@ -153,7 +155,9 @@ export type registerUserAndLinkToIDPResponse = {
sessionId: string;
factors: Factors | undefined;
};
export async function registerUserAndLinkToIDP(command: RegisterUserAndLinkToIDPommand) {
export async function registerUserAndLinkToIDP(
command: RegisterUserAndLinkToIDPommand,
): Promise<{ error: string } | { redirect: string } | { samlData: { url: string; fields: Record<string, string> } }> {
const t = await getTranslations("register");
const _headers = await headers();
+1 -1
View File
@@ -37,7 +37,7 @@ export async function skipMFAAndContinueWithNextUrl({
sessionId?: string;
requestId?: string;
organization?: string;
}): Promise<{ redirect: string } | { error: string }> {
}): Promise<{ redirect: string } | { error: string } | { samlData: { url: string; fields: Record<string, string> } }> {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
+4 -23
View File
@@ -32,7 +32,7 @@ import {
import { unstable_cacheLife as cacheLife } from "next/cache";
import { getTranslations } from "next-intl/server";
import { getUserAgent } from "./fingerprint";
import { setSAMLFormCookie } from "./saml";
import { createServiceForHost } from "./service";
const useCache = process.env.DEBUG !== "true";
@@ -774,7 +774,7 @@ export async function startIdentityProviderFlow({
}: WithServiceConfig<{
idpId: string;
urls: RedirectURLsJson;
}>): Promise<string | null> {
}>): Promise<{ url: string; fields?: Record<string, string> } | null> {
// Use empty publicHost to avoid issues with redirect URIs pointing to the login UI instead of the zitadel API
const userService: Client<typeof UserService> = await createServiceForHost(UserService, {
...serviceConfig,
@@ -791,30 +791,11 @@ export async function startIdentityProviderFlow({
})
.then(async (resp) => {
if (resp.nextStep.case === "authUrl" && resp.nextStep.value) {
return resp.nextStep.value;
return { url: resp.nextStep.value };
} else if (resp.nextStep.case === "formData" && resp.nextStep.value) {
const formData: FormData = resp.nextStep.value;
const redirectUrl = "/saml-post";
try {
const stringifiedFields = JSON.stringify(formData.fields);
// Check cookie size limits (typical limit is 4KB)
if (stringifiedFields.length > 4000) {
console.warn(
`SAML form cookie value is large (${stringifiedFields.length} characters), may exceed browser limits`,
);
}
const dataId = await setSAMLFormCookie(stringifiedFields);
const params = new URLSearchParams({ url: formData.url, id: dataId });
return `${redirectUrl}?${params.toString()}`;
} catch (stringifyError) {
throw new Error(
`Failed to serialize SAML form data: ${stringifyError instanceof Error ? stringifyError.message : String(stringifyError)}`,
);
}
return { url: formData.url, fields: formData.fields };
} else {
return null;
}
+11
View File
@@ -208,6 +208,9 @@ importers:
deepmerge:
specifier: ^4.3.1
version: 4.3.1
escape-html:
specifier: ^1.0.3
version: 1.0.3
lucide-react:
specifier: ^0.562.0
version: 0.562.0([email protected])
@@ -272,6 +275,9 @@ importers:
'@testing-library/react':
specifier: ^16.3.1
version: 16.3.2(@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@types/escape-html':
specifier: ^1.0.4
version: 1.0.4
'@types/node':
specifier: ^22.19.3
version: 22.19.7
@@ -4903,6 +4909,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/[email protected]':
resolution: {integrity: sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==}
'@types/[email protected]':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -16206,6 +16215,8 @@ snapshots:
dependencies:
'@types/ms': 2.1.0
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/eslint': 9.6.1