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