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