From 9492c12c09c3f76855e9f48028e409c5e5473e2c Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 9 Feb 2026 13:46:30 +0100 Subject: [PATCH] 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 --- apps/login/package.json | 4 +- .../[provider]/complete-registration/page.tsx | 5 +- apps/login/src/app/(login)/saml-post/route.ts | 49 -- .../login/src/components/auto-submit-form.tsx | 29 + .../src/components/change-password-form.tsx | 19 +- .../choose-second-factor-to-setup.tsx | 14 +- .../src/components/idp-process-handler.tsx | 10 + apps/login/src/components/login-otp.tsx | 203 +++---- apps/login/src/components/login-passkey.tsx | 53 +- apps/login/src/components/password-form.tsx | 136 ++--- .../register-form-idp-incomplete.tsx | 170 +++--- apps/login/src/components/register-form.tsx | 204 ++++--- apps/login/src/components/register-u2f.tsx | 69 +-- apps/login/src/components/session-item.tsx | 41 +- .../src/components/set-password-form.tsx | 19 +- .../components/set-register-password-form.tsx | 140 +++-- .../login/src/components/sign-in-with-idp.tsx | 2 + apps/login/src/components/totp-register.tsx | 23 +- apps/login/src/components/username-form.tsx | 147 +++-- apps/login/src/components/verify-form.tsx | 38 +- apps/login/src/lib/client.ts | 47 +- apps/login/src/lib/saml.test.ts | 572 ------------------ apps/login/src/lib/saml.ts | 77 +-- apps/login/src/lib/server/auth-flow.ts | 21 +- apps/login/src/lib/server/flow-initiation.ts | 36 +- apps/login/src/lib/server/idp-intent.ts | 48 +- apps/login/src/lib/server/idp.test.ts | 14 +- apps/login/src/lib/server/idp.ts | 22 +- apps/login/src/lib/server/loginname.test.ts | 12 +- apps/login/src/lib/server/loginname.ts | 20 +- apps/login/src/lib/server/passkeys.ts | 6 +- apps/login/src/lib/server/password.ts | 47 +- apps/login/src/lib/server/register.ts | 8 +- apps/login/src/lib/server/session.ts | 2 +- apps/login/src/lib/zitadel.ts | 27 +- pnpm-lock.yaml | 11 + 36 files changed, 891 insertions(+), 1454 deletions(-) delete mode 100644 apps/login/src/app/(login)/saml-post/route.ts create mode 100644 apps/login/src/components/auto-submit-form.tsx delete mode 100644 apps/login/src/lib/saml.test.ts diff --git a/apps/login/package.json b/apps/login/package.json index b089af96d..7ee6f843c 100644 --- a/apps/login/package.json +++ b/apps/login/package.json @@ -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", diff --git a/apps/login/src/app/(login)/idp/[provider]/complete-registration/page.tsx b/apps/login/src/app/(login)/idp/[provider]/complete-registration/page.tsx index dbf3e5abf..9741f4bba 100644 --- a/apps/login/src/app/(login)/idp/[provider]/complete-registration/page.tsx +++ b/apps/login/src/app/(login)/idp/[provider]/complete-registration/page.tsx @@ -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"); } diff --git a/apps/login/src/app/(login)/saml-post/route.ts b/apps/login/src/app/(login)/saml-post/route.ts deleted file mode 100644 index a2061a18e..000000000 --- a/apps/login/src/app/(login)/saml-post/route.ts +++ /dev/null @@ -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]) => - ``, - ) - .join("\n "); - - // Respond with an HTML form that auto-submits via POST - const html = ` - - -
- ${hiddenInputs} - -
- - - `; - return new NextResponse(html, { - headers: { "Content-Type": "text/html" }, - }); -} diff --git a/apps/login/src/components/auto-submit-form.tsx b/apps/login/src/components/auto-submit-form.tsx new file mode 100644 index 000000000..be7504de0 --- /dev/null +++ b/apps/login/src/components/auto-submit-form.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +type Props = { + url: string; + fields: Record; +}; + +export function AutoSubmitForm({ url, fields }: Props) { + const formRef = useRef(null); + + useEffect(() => { + if (formRef.current) { + formRef.current.submit(); + } + }, []); + + return ( +
+ {Object.entries(fields).map(([key, value]) => ( + + ))} + +
+ ); +} diff --git a/apps/login/src/components/change-password-form.tsx b/apps/login/src/components/change-password-form.tsx index 7560c0404..540747307 100644 --- a/apps/login/src/components/change-password-form.tsx +++ b/apps/login/src/components/change-password-form.tsx @@ -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(false); const [error, setError] = useState(""); + const [samlData, setSamlData] = useState<{ url: string; fields: Record } | 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,7 +118,9 @@ export function ChangePasswordForm({ passwordComplexitySettings, sessionId, logi hasMinLength; return ( -
+ <> + {samlData && } +
}
- + + ); } diff --git a/apps/login/src/components/choose-second-factor-to-setup.tsx b/apps/login/src/components/choose-second-factor-to-setup.tsx index d636cb722..8b164adfb 100644 --- a/apps/login/src/components/choose-second-factor-to-setup.tsx +++ b/apps/login/src/components/choose-second-factor-to-setup.tsx @@ -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(""); + const [samlData, setSamlData] = useState<{ url: string; fields: Record } | null>(null); if (loginName) { params.append("loginName", loginName); @@ -59,6 +62,7 @@ export function ChooseSecondFactorToSetup({ return ( <> + {samlData && }
{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" diff --git a/apps/login/src/components/idp-process-handler.tsx b/apps/login/src/components/idp-process-handler.tsx index c8b5e2460..61cd9db85 100644 --- a/apps/login/src/components/idp-process-handler.tsx +++ b/apps/login/src/components/idp-process-handler.tsx @@ -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(null); + const [samlData, setSamlData] = useState<{ url: string; fields: Record } | 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 (
+ {samlData && } {loading && (
diff --git a/apps/login/src/components/login-otp.tsx b/apps/login/src/components/login-otp.tsx index 721373310..2bf7f49ee 100644 --- a/apps/login/src/components/login-otp.tsx +++ b/apps/login/src/components/login-otp.tsx @@ -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(""); const [loading, setLoading] = useState(false); + const [samlData, setSamlData] = useState<{ url: string; fields: Record } | 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({ - loginName, - sessionId, - organization, - challenges, - requestId, - }) - .catch(() => { - setError("Could not request OTP challenge"); - return; - }) - .finally(() => { - setLoading(false); + let response; + try { + response = await updateOrCreateSession({ + loginName, + sessionId, + organization, + challenges, + requestId, }); + } catch { + return { error: "Could not request OTP challenge" }; + } if (response && "error" in response && response.error) { - setError(response.error); - return; + 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); + }); + } + }, [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,68 +209,66 @@ export function LoginOTP({ host, loginName, sessionId, requestId, organization, } return ( -
- {["email", "sms"].includes(method) && ( - -
- - - - -
-
- )} -
- -
- - {error && ( -
- {error} + <> + {samlData && } + + {["email", "sms"].includes(method) && ( + +
+ + + + +
+
+ )} +
+
- )} -
- - - -
- + {error && ( +
+ {error} +
+ )} + +
+ + + +
+ + ); } diff --git a/apps/login/src/components/login-passkey.tsx b/apps/login/src/components/login-passkey.tsx index ffe377f4f..fd56c71a8 100644 --- a/apps/login/src/components/login-passkey.tsx +++ b/apps/login/src/components/login-passkey.tsx @@ -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(""); const [loading, setLoading] = useState(false); + const [samlData, setSamlData] = useState<{ url: string; fields: Record } | null>(null); const t = useTranslations("passkey"); const router = useRouter(); @@ -106,37 +109,30 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org async function submitLogin(data: JsonObject) { setLoading(true); - const response = await sendPasskey({ - loginName, - sessionId, - organization, - checks: { - webAuthN: { credentialAssertionData: data }, - } as Checks, - requestId, - }) - .catch(() => { - setError(t("verify.errors.couldNotVerifyPasskey")); - return; - }) - .finally(() => { - setLoading(false); + try { + const response = await sendPasskey({ + loginName, + sessionId, + organization, + checks: { + webAuthN: { credentialAssertionData: data }, + } as Checks, + requestId, }); - 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 (!response) { - setError(t("verify.errors.noResponseReceived")); - } else { - setError(t("verify.errors.noRedirectProvided")); + if (!handled) { + if (!response) { + setError(t("verify.errors.noResponseReceived")); + } else { + setError(t("verify.errors.noRedirectProvided")); + } + } + } catch { + setError(t("verify.errors.couldNotVerifyPasskey")); + } finally { + setLoading(false); } } @@ -191,6 +187,7 @@ export function LoginPasskey({ loginName, sessionId, requestId, altPassword, org return (
+ {samlData && } {error && (
{error} diff --git a/apps/login/src/components/password-form.tsx b/apps/login/src/components/password-form.tsx index 2dff5e41a..7f767461d 100644 --- a/apps/login/src/components/password-form.tsx +++ b/apps/login/src/components/password-form.tsx @@ -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(""); const [error, setError] = useState(""); + const [samlData, setSamlData] = useState<{ url: string; fields: Record } | null>(null); const [loading, setLoading] = useState(false); @@ -45,30 +48,22 @@ export function PasswordForm({ loginSettings, loginName, organization, defaultOr setError(""); setLoading(true); - const response = await sendPassword({ - loginName, - organization, - defaultOrganization, - checks: create(ChecksSchema, { - password: { password: values.password }, - }), - requestId, - }) - .catch(() => { - setError(t("verify.errors.couldNotVerifyPassword")); - return; - }) - .finally(() => { - setLoading(false); + try { + const response = await sendPassword({ + loginName, + organization, + defaultOrganization, + checks: create(ChecksSchema, { + password: { password: values.password }, + }), + requestId, }); - 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,56 +109,59 @@ export function PasswordForm({ loginSettings, loginName, organization, defaultOr } return ( -
-
- - {!loginSettings?.hidePasswordReset && ( - + <> + {samlData && } + +
+ + {!loginSettings?.hidePasswordReset && ( + + )} + + {loginName && } +
+ + {info && ( +
+ {info} +
)} - {loginName && } -
+ {error && ( +
+ {error} +
+ )} - {info && ( -
- {info} +
+ + +
- )} - - {error && ( -
- {error} -
- )} - -
- - - -
- + + ); } diff --git a/apps/login/src/components/register-form-idp-incomplete.tsx b/apps/login/src/components/register-form-idp-incomplete.tsx index b5d12da01..d405ec1a2 100644 --- a/apps/login/src/components/register-form-idp-incomplete.tsx +++ b/apps/login/src/components/register-form-idp-incomplete.tsx @@ -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,96 +63,107 @@ export function RegisterFormIDPIncomplete({ const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [samlData, setSamlData] = useState<{ url: string; fields: Record } | null>(null); async function submitAndRegister(values: Inputs) { setLoading(true); - const response = await registerUserAndLinkToIDP({ - idpId: idpId, - idpUserName: idpUserName, - idpUserId: idpUserId, - email: values.email, - firstName: values.firstname, - lastName: values.lastname, - organization: organization, - requestId: requestId, - idpIntent: idpIntent, - }) - .catch(() => { - setError("Could not register user"); - return; - }) - .finally(() => { - setLoading(false); + try { + const response = await registerUserAndLinkToIDP({ + idpId: idpId, + idpUserName: idpUserName ? idpUserName : values.username, + idpUserId: idpUserId, + email: values.email, + firstName: values.firstname, + lastName: values.lastname, + organization: organization, + requestId: requestId, + idpIntent: idpIntent, }); - 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 && } + +
+ {!idpUserName && ( +
+ +
+ )} +
+
+ +
+
+ +
+
+
+ +
-
- -
-
- -
-
- {error && ( -
- {error} -
- )} + {error && ( +
+ {error} +
+ )} -
- - -
- +
+ + +
+ + ); } diff --git a/apps/login/src/components/register-form.tsx b/apps/login/src/components/register-form.tsx index 444c40381..7352d5cfd 100644 --- a/apps/login/src/components/register-form.tsx +++ b/apps/login/src/components/register-form.tsx @@ -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(false); const [selected, setSelected] = useState(methods[0]); const [error, setError] = useState(""); + const [samlData, setSamlData] = useState<{ url: string; fields: Record } | null>(null); const router = useRouter(); async function submitAndRegister(values: Inputs) { setLoading(true); - 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); + try { + const response = await registerUser({ + email: values.email, + firstName: values.firstname, + lastName: values.lastname, + organization: organization, + requestId: requestId, }); - if (response && "error" in response && response.error) { - setError(response.error); - return; - } + handleServerActionResponse(response, router, setSamlData, setError); - if (response && "redirect" in response && response.redirect) { - return router.push(response.redirect); + return response; + } catch { + setError(t("errors.couldNotRegisterUser")); + } finally { + setLoading(false); } - - return response; } async function submitAndContinue(value: Inputs, withPassword: boolean = false) { @@ -119,93 +114,96 @@ export function RegisterForm({ const canSubmit = formState.isValid && (!isLegalAcceptanceRequired || tosAndPolicyAccepted); return ( -
-
-
- -
-
- -
-
- -
-
- {(legal?.tosLink || legal?.privacyPolicyLink) && ( - - )} - {/* show chooser if both methods are allowed */} - {loginSettings && loginSettings.allowLocalAuthentication && loginSettings.passkeysType == PasskeysType.ALLOWED && ( - <> -

- -

- -
- + <> + {samlData && } + +
+
+
- - )} - {!loginSettings?.allowLocalAuthentication && - loginSettings?.passkeysType !== PasskeysType.ALLOWED && - (!loginSettings?.allowExternalIdp || !idpCount) && ( +
+ +
+
+ +
+
+ {(legal?.tosLink || legal?.privacyPolicyLink) && ( + + )} + {/* show chooser if both methods are allowed */} + {loginSettings && loginSettings.allowLocalAuthentication && loginSettings.passkeysType == PasskeysType.ALLOWED && ( + <> +

+ +

+ +
+ +
+ + )} + {!loginSettings?.allowLocalAuthentication && + loginSettings?.passkeysType !== PasskeysType.ALLOWED && + (!loginSettings?.allowExternalIdp || !idpCount) && ( +
+ + + +
+ )} + + {error && (
- - - + {error}
)} - {error && ( -
- {error} +
+ +
- )} - -
- - -
- + + ); } diff --git a/apps/login/src/components/register-u2f.tsx b/apps/login/src/components/register-u2f.tsx index 44276304b..7c0abf1b9 100644 --- a/apps/login/src/components/register-u2f.tsx +++ b/apps/login/src/components/register-u2f.tsx @@ -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(""); + const [samlData, setSamlData] = useState<{ url: string; fields: Record } | null>(null); const [loading, setLoading] = useState(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,42 +167,38 @@ 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 ( -
- {error && ( -
- {error} + <> + {samlData && } + + {error && ( +
+ {error} +
+ )} + +
+ + + +
- )} - -
- - - - -
- + + ); } diff --git a/apps/login/src/components/session-item.tsx b/apps/login/src/components/session-item.tsx index 463dcd5dd..f0b2ad415 100644 --- a/apps/login/src/components/session-item.tsx +++ b/apps/login/src/components/session-item.tsx @@ -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): { 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 } | null>(null); const [_error, setError] = useState(null); @@ -59,6 +62,7 @@ export function SessionItem({ session, reload, requestId }: { session: Session; return ( + {samlData && }
- + + ); } diff --git a/apps/login/src/components/set-register-password-form.tsx b/apps/login/src/components/set-register-password-form.tsx index eb6ad0db3..56ddd443c 100644 --- a/apps/login/src/components/set-register-password-form.tsx +++ b/apps/login/src/components/set-register-password-form.tsx @@ -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,34 +54,27 @@ export function SetRegisterPasswordForm({ const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [samlData, setSamlData] = useState<{ url: string; fields: Record } | null>(null); const router = useRouter(); async function submitRegister(values: Inputs) { setLoading(true); - const response = await registerUser({ - email: email, - firstName: firstname, - lastName: lastname, - organization: organization, - requestId: requestId, - password: values.password, - }) - .catch(() => { - setError(t("errors.couldNotRegisterUser")); - return; - }) - .finally(() => { - setLoading(false); + try { + const response = await registerUser({ + email: email, + firstName: firstname, + lastName: lastname, + organization: organization, + requestId: requestId, + password: values.password, }); - 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,58 +98,61 @@ export function SetRegisterPasswordForm({ hasMinLength; return ( -
-
-
- + <> + {samlData && } + +
+
+ +
+
+ +
-
- + )} + + {error && {error}} + +
+ +
-
- - {passwordComplexitySettings && ( - - )} - - {error && {error}} - -
- - -
- + + ); } diff --git a/apps/login/src/components/sign-in-with-idp.tsx b/apps/login/src/components/sign-in-with-idp.tsx index 74e157165..577503fef 100644 --- a/apps/login/src/components/sign-in-with-idp.tsx +++ b/apps/login/src/components/sign-in-with-idp.tsx @@ -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 (
+ {state?.samlData && } {showLabel && (

diff --git a/apps/login/src/components/totp-register.tsx b/apps/login/src/components/totp-register.tsx index a84789b45..8a6424689 100644 --- a/apps/login/src/components/totp-register.tsx +++ b/apps/login/src/components/totp-register.tsx @@ -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(""); const [loading, setLoading] = useState(false); + const [samlData, setSamlData] = useState<{ url: string; fields: Record } | null>(null); const router = useRouter(); const { register, handleSubmit, formState } = useForm({ @@ -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 (

+ {samlData && } {uri && ( <> diff --git a/apps/login/src/components/username-form.tsx b/apps/login/src/components/username-form.tsx index 611b8369d..c98131c54 100644 --- a/apps/login/src/components/username-form.tsx +++ b/apps/login/src/components/username-form.tsx @@ -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,36 +53,28 @@ export function UsernameForm({ const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [samlData, setSamlData] = useState<{ url: string; fields: Record } | null>(null); async function submitLoginName(values: Inputs, organization?: string) { setLoading(true); - const res = await sendLoginname({ - loginName: values.loginName, - organization, - defaultOrganization, - requestId, - suffix, - ignoreUnknownUsernames: loginSettings?.ignoreUnknownUsernames, - }) - .catch(() => { - setError(t("errors.internalError")); - return; - }) - .finally(() => { - setLoading(false); + try { + const res = await sendLoginname({ + loginName: values.loginName, + organization, + defaultOrganization, + requestId, + suffix, + ignoreUnknownUsernames: loginSettings?.ignoreUnknownUsernames, }); - if (res && "redirect" in res && res.redirect) { - return router.push(res.redirect); + handleServerActionResponse(res, router, setSamlData, setError); + return res; + } catch { + setError(t("errors.internalError")); + } finally { + setLoading(false); } - - if (res && "error" in res && res.error) { - setError(res.error); - return; - } - - return res; } useEffect(() => { @@ -100,59 +94,62 @@ export function UsernameForm({ } return ( -
-
- - {allowRegister && ( - - )} -
- - {error && ( -
- {error} + router.push("/register?" + registerParams); + }} + type="button" + disabled={loading} + data-testid="register-button" + > + + + )}
- )} -
- - - -
-
+ + {error && ( +
+ {error} +
+ )} +
+ + + +
+ + ); } diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index d971180c5..0ee2e7d41 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -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(""); + const [samlData, setSamlData] = useState<{ url: string; fields: Record } | null>(null); const [loading, setLoading] = useState(false); @@ -80,29 +83,21 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i setError(""); setLoading(true); - const response = await sendVerification({ - code: value.code, - userId, - isInvite: isInvite, - loginName: loginName, - organization: organization, - requestId: requestId, - }) - .catch(() => { - setError(t("errors.couldNotVerifyUser")); - return; - }) - .finally(() => { - setLoading(false); + try { + const response = await sendVerification({ + code: value.code, + userId, + isInvite: isInvite, + loginName: loginName, + organization: organization, + requestId: requestId, }); - 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 && }
diff --git a/apps/login/src/lib/client.ts b/apps/login/src/lib/client.ts index be210fa91..f63a55df2 100644 --- a/apps/login/src/lib/client.ts +++ b/apps/login/src/lib/client.ts @@ -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 } }> { 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 } } + | undefined + | null; + +export function handleServerActionResponse( + response: ServerActionResponse, + router: { push: (url: string) => void }, + setSamlData: (data: { url: string; fields: Record }) => 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; +} diff --git a/apps/login/src/lib/saml.test.ts b/apps/login/src/lib/saml.test.ts deleted file mode 100644 index 6ed5cc2cb..000000000 --- a/apps/login/src/lib/saml.test.ts +++ /dev/null @@ -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: "user@example.com", - organizationId: "org-123", - }, - }, - }; - - const mockCookie: any = { - id: "session-123", - token: "session-token-123", - loginName: "user@example.com", - }; - - 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: "user@example.com", - 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: "other@example.com", - }, - }, - }; - - 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: "other@example.com", - }; - - 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: "user@example.com", - }, - }, - } 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="), - }); - }); - }); -}); diff --git a/apps/login/src/lib/saml.ts b/apps/login/src/lib/saml.ts index 24c8b7273..7b1784c15 100644 --- a/apps/login/src/lib/saml.ts +++ b/apps/login/src/lib/saml.ts @@ -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 { - 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 { - 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 } } +> { + 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!" }; } diff --git a/apps/login/src/lib/server/auth-flow.ts b/apps/login/src/lib/server/auth-flow.ts index 369db766b..6fc0681ba 100644 --- a/apps/login/src/lib/server/auth-flow.ts +++ b/apps/login/src/lib/server/auth-flow.ts @@ -15,8 +15,7 @@ export interface AuthFlowParams { } async function loadSessions({ serviceConfig, ids }: { serviceConfig: ServiceConfig; ids: string[] }): Promise { - 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 } }> { 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" }; } diff --git a/apps/login/src/lib/server/flow-initiation.ts b/apps/login/src/lib/server/flow-initiation.ts index 63eaa847d..55a4168f4 100644 --- a/apps/login/src/lib/server/flow-initiation.ts +++ b/apps/login/src/lib/server/flow-initiation.ts @@ -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]) => ``) + .join("\n"); + + const html = ` + + + + ${hiddenInputs} + + + + + `; + + + 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 = ` -
- - + + + diff --git a/apps/login/src/lib/server/idp-intent.ts b/apps/login/src/lib/server/idp-intent.ts index c72264c83..8fc396515 100644 --- a/apps/login/src/lib/server/idp-intent.ts +++ b/apps/login/src/lib/server/idp-intent.ts @@ -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, includeToken?: boolean) => string; } -type IDPHandlerResult = { redirect?: string; error?: string } | null; +type IDPHandlerResult = { redirect?: string; error?: string; samlData?: { url: string; fields: Record } } | null; /** * CASE 1: Explicit Linking (via sessionId) @@ -259,6 +259,11 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise { +}): Promise<{ redirect?: string; error?: string; samlData?: { url: string; fields: Record } }> { // ... (headers and config retrieval) ... const _headers = await headers(); const { serviceConfig } = getServiceConfig(_headers); diff --git a/apps/login/src/lib/server/idp.test.ts b/apps/login/src/lib/server/idp.test.ts index 77bb3e783..48633e55b 100644 --- a/apps/login/src/lib/server/idp.test.ts +++ b/apps/login/src/lib/server/idp.test.ts @@ -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); diff --git a/apps/login/src/lib/server/idp.ts b/apps/login/src/lib/server/idp.ts index 44ccdbb12..5864dc241 100644 --- a/apps/login/src/lib/server/idp.ts +++ b/apps/login/src/lib/server/idp.ts @@ -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 } } + | undefined; export async function redirectToIdp(prevState: RedirectToIdpState, formData: FormData): Promise { 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 = { diff --git a/apps/login/src/lib/server/loginname.test.ts b/apps/login/src/lib/server/loginname.test.ts index a7b3e1441..9411ffa2c 100644 --- a/apps/login/src/lib/server/loginname.test.ts +++ b/apps/login/src/lib/server/loginname.test.ts @@ -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: "user@example.com", @@ -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: "user@example.com", @@ -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: "user@example.com", @@ -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: "user@example.com", @@ -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: "user@example.com", @@ -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: "user@company.com", diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index 8b285ecba..d29a2ce72 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -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 }; } }; diff --git a/apps/login/src/lib/server/passkeys.ts b/apps/login/src/lib/server/passkeys.ts index f880d9162..c99730d11 100644 --- a/apps/login/src/lib/server/passkeys.ts +++ b/apps/login/src/lib/server/passkeys.ts @@ -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; } diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 311bd207c..ed8e11027 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -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 } }> { 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>; + 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" }; - } + } 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, + ); + } + if (result && typeof result === "object") { return result; } - // Regular flow (no requestId) - return URL for client-side navigation - - const 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" }; - } - - 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 diff --git a/apps/login/src/lib/server/register.ts b/apps/login/src/lib/server/register.ts index 20477e1d5..f128b2545 100644 --- a/apps/login/src/lib/server/register.ts +++ b/apps/login/src/lib/server/register.ts @@ -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 } }> { 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 } }> { const t = await getTranslations("register"); const _headers = await headers(); diff --git a/apps/login/src/lib/server/session.ts b/apps/login/src/lib/server/session.ts index 676d60642..ca27cc2ec 100644 --- a/apps/login/src/lib/server/session.ts +++ b/apps/login/src/lib/server/session.ts @@ -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 } }> { const _headers = await headers(); const { serviceConfig } = getServiceConfig(_headers); diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 6ee28b028..e5077db65 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -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 { +}>): Promise<{ url: string; fields?: Record } | null> { // Use empty publicHost to avoid issues with redirect URIs pointing to the login UI instead of the zitadel API const userService: Client = 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; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0592e6c0e..89e9db0c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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(react@19.2.3) @@ -272,6 +275,9 @@ importers: '@testing-library/react': specifier: ^16.3.1 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@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/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/escape-html@1.0.4': + resolution: {integrity: sha512-qZ72SFTgUAZ5a7Tj6kf2SHLetiH5S6f8G5frB2SPQ3EyF02kxdyBFf4Tz4banE3xCgGnKgWLt//a6VuYHKYJTg==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -16206,6 +16215,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/escape-html@1.0.4': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1