Merge pull request #465 from zitadel/qa

Promote qa to prod
This commit is contained in:
Max Peintner
2025-05-28 13:19:10 +02:00
committed by GitHub
22 changed files with 504 additions and 633 deletions

View File

@@ -174,13 +174,15 @@
},
"verify": {
"userIdMissing": "Keine Benutzer-ID angegeben!",
"success": "Erfolgreich verifiziert",
"successTitle": "Benutzer verifiziert",
"successDescription": "Der Benutzer wurde erfolgreich verifiziert.",
"setupAuthenticator": "Authentifikator einrichten",
"verify": {
"title": "Benutzer verifizieren",
"description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.",
"noCodeReceived": "Keinen Code erhalten?",
"resendCode": "Code erneut senden",
"codeSent": "Ein Code wurde gerade an Ihre E-Mail-Adresse gesendet.",
"submit": "Weiter"
}
},

View File

@@ -174,13 +174,15 @@
},
"verify": {
"userIdMissing": "No userId provided!",
"success": "The user has been verified successfully.",
"successTitle": "User verified",
"successDescription": "The user has been verified successfully.",
"setupAuthenticator": "Setup authenticator",
"verify": {
"title": "Verify user",
"description": "Enter the Code provided in the verification email.",
"noCodeReceived": "Didn't receive a code?",
"resendCode": "Resend code",
"codeSent": "A code has just been sent to your email address.",
"submit": "Continue"
}
},

View File

@@ -174,13 +174,15 @@
},
"verify": {
"userIdMissing": "¡No se proporcionó userId!",
"success": "¡Verificación exitosa!",
"successTitle": "Usuario verificado",
"successDescription": "El usuario ha sido verificado con éxito.",
"setupAuthenticator": "Configurar autenticador",
"verify": {
"title": "Verificar usuario",
"description": "Introduce el código proporcionado en el correo electrónico de verificación.",
"noCodeReceived": "¿No recibiste un código?",
"resendCode": "Reenviar código",
"codeSent": "Se ha enviado un código a tu dirección de correo electrónico.",
"submit": "Continuar"
}
},

View File

@@ -174,13 +174,15 @@
},
"verify": {
"userIdMissing": "Nessun userId fornito!",
"success": "Verifica effettuata con successo!",
"successTitle": "Utente verificato",
"successDescription": "L'utente è stato verificato con successo.",
"setupAuthenticator": "Configura autenticatore",
"verify": {
"title": "Verifica utente",
"description": "Inserisci il codice fornito nell'email di verifica.",
"noCodeReceived": "Non hai ricevuto un codice?",
"resendCode": "Invia di nuovo il codice",
"codeSent": "Un codice è stato appena inviato al tuo indirizzo email.",
"submit": "Continua"
}
},

View File

@@ -174,13 +174,15 @@
},
"verify": {
"userIdMissing": "Nie podano identyfikatora użytkownika!",
"success": "Użytkownik został pomyślnie zweryfikowany.",
"successTitle": "Weryfikacja zakończona",
"successDescription": "Użytkownik został pomyślnie zweryfikowany.",
"setupAuthenticator": "Skonfiguruj uwierzytelnianie",
"verify": {
"title": "Zweryfikuj użytkownika",
"description": "Wprowadź kod z wiadomości weryfikacyjnej.",
"noCodeReceived": "Nie otrzymałeś kodu?",
"resendCode": "Wyślij kod ponownie",
"codeSent": "Kod został właśnie wysłany na twój adres e-mail.",
"submit": "Kontynuuj"
}
},

View File

@@ -174,13 +174,15 @@
},
"verify": {
"userIdMissing": "Не указан userId!",
"success": "Пользователь успешно подтверждён.",
"successTitle": "Пользователь подтверждён",
"successDescription": "Пользователь успешно подтверждён.",
"setupAuthenticator": "Настроить аутентификатор",
"verify": {
"title": "Подтверждение пользователя",
"description": "Введите код из письма подтверждения.",
"noCodeReceived": "Не получили код?",
"resendCode": "Отправить код повторно",
"codeSent": "Код отправлен на ваш email.",
"submit": "Продолжить"
}
},

View File

@@ -174,13 +174,15 @@
},
"verify": {
"userIdMissing": "未提供用户 ID",
"success": "用户验证成功。",
"successTitle": "用户验证",
"successDescription": "用户已成功验证。",
"setupAuthenticator": "设置认证器",
"verify": {
"title": "验证用户",
"description": "输入验证邮件中的验证码。",
"noCodeReceived": "没有收到验证码?",
"resendCode": "重发验证码",
"codeSent": "刚刚发送了一封包含验证码的电子邮件。",
"submit": "继续"
}
},

View File

@@ -7,6 +7,7 @@ import { UserAvatar } from "@/components/user-avatar";
import { getSessionCookieById } from "@/lib/cookies";
import { getServiceUrlFromHeaders } from "@/lib/service-url";
import { loadMostRecentSession } from "@/lib/session";
import { checkUserVerification } from "@/lib/verify-helper";
import {
getActiveIdentityProviders,
getBrandingSettings,
@@ -18,6 +19,7 @@ import {
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
@@ -92,20 +94,50 @@ export default async function Page(props: {
});
}
if (!sessionWithData) {
if (
!sessionWithData ||
!sessionWithData.factors ||
!sessionWithData.factors.user
) {
return <Alert>{tError("unknownContext")}</Alert>;
}
const branding = await getBrandingSettings({
serviceUrl,
organization: sessionWithData.factors?.user?.organizationId,
organization: sessionWithData.factors.user?.organizationId,
});
const loginSettings = await getLoginSettings({
serviceUrl,
organization: sessionWithData.factors?.user?.organizationId,
organization: sessionWithData.factors.user?.organizationId,
});
// check if user was verified recently
const isUserVerified = await checkUserVerification(
sessionWithData.factors.user?.id,
);
if (!isUserVerified) {
const params = new URLSearchParams({
loginName: sessionWithData.factors.user.loginName as string,
invite: "true",
send: "true", // set this to true to request a new code immediately
});
if (requestId) {
params.append("requestId", requestId);
}
if (organization || sessionWithData.factors.user.organizationId) {
params.append(
"organization",
organization ?? (sessionWithData.factors.user.organizationId as string),
);
}
redirect(`/verify?` + params);
}
const identityProviders = await getActiveIdentityProviders({
serviceUrl,
orgId: sessionWithData.factors?.user?.organizationId,
@@ -152,13 +184,12 @@ export default async function Page(props: {
></ChooseAuthenticatorToSetup>
)}
{loginSettings?.allowExternalIdp && identityProviders && (
{loginSettings?.allowExternalIdp && !!identityProviders.length && (
<>
{identityProviders.length && (
<div className="py-3 flex flex-col">
<p className="ztdl-p text-center">{t("linkWithIDP")}</p>
</div>
)}
<div className="py-3 flex flex-col">
<p className="ztdl-p text-center">{t("linkWithIDP")}</p>
</div>
<SignInWithIdp
identityProviders={identityProviders}
requestId={requestId}

View File

@@ -1,73 +0,0 @@
import { Alert, AlertType } from "@/components/alert";
import { DynamicTheme } from "@/components/dynamic-theme";
import { InviteForm } from "@/components/invite-form";
import { getServiceUrlFromHeaders } from "@/lib/service-url";
import {
getBrandingSettings,
getDefaultOrg,
getLoginSettings,
getPasswordComplexitySettings,
} from "@/lib/zitadel";
import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers";
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}) {
const searchParams = await props.searchParams;
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "invite" });
let { firstname, lastname, email, organization } = searchParams;
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
if (!organization) {
const org = await getDefaultOrg({ serviceUrl });
if (!org) {
throw new Error("No default organization found");
}
organization = org.id;
}
const loginSettings = await getLoginSettings({
serviceUrl,
organization,
});
const passwordComplexitySettings = await getPasswordComplexitySettings({
serviceUrl,
organization,
});
const branding = await getBrandingSettings({
serviceUrl,
organization,
});
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("title")}</h1>
<p className="ztdl-p">{t("description")}</p>
{!loginSettings?.allowRegister ? (
<Alert type={AlertType.ALERT}>{t("notAllowed")}</Alert>
) : (
<Alert type={AlertType.INFO}>{t("info")}</Alert>
)}
{passwordComplexitySettings && loginSettings?.allowRegister && (
<InviteForm
organization={organization}
firstname={firstname}
lastname={lastname}
email={email}
></InviteForm>
)}
</div>
</DynamicTheme>
);
}

View File

@@ -1,81 +0,0 @@
import { Alert, AlertType } from "@/components/alert";
import { Button, ButtonVariants } from "@/components/button";
import { DynamicTheme } from "@/components/dynamic-theme";
import { UserAvatar } from "@/components/user-avatar";
import { getServiceUrlFromHeaders } from "@/lib/service-url";
import { getBrandingSettings, getDefaultOrg, getUserByID } from "@/lib/zitadel";
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import Link from "next/link";
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}) {
const searchParams = await props.searchParams;
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "invite" });
let { userId, organization } = searchParams;
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
if (!organization) {
const org = await getDefaultOrg({ serviceUrl });
if (!org) {
throw new Error("No default organization found");
}
organization = org.id;
}
const branding = await getBrandingSettings({
serviceUrl,
organization,
});
let user: User | undefined;
let human: HumanUser | undefined;
if (userId) {
const userResponse = await getUserByID({
serviceUrl,
userId,
});
if (userResponse) {
user = userResponse.user;
if (user?.type.case === "human") {
human = user.type.value as HumanUser;
}
}
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("success.title")}</h1>
<p className="ztdl-p">{t("success.description")}</p>
{user && (
<UserAvatar
loginName={user.preferredLoginName}
displayName={human?.profile?.displayName}
showDropdown={false}
/>
)}
{human?.email?.isVerified ? (
<Alert type={AlertType.INFO}>{t("success.verified")}</Alert>
) : (
<Alert type={AlertType.INFO}>{t("success.notVerifiedYet")}</Alert>
)}
<div className="mt-8 flex w-full flex-row items-center justify-between">
<span></span>
<Link href="/invite">
<Button type="submit" variant={ButtonVariants.Primary}>
{t("success.submit")}
</Button>
</Link>
</div>
</div>
</DynamicTheme>
);
}

View File

@@ -1,18 +1,12 @@
import { Alert } from "@/components/alert";
import { Alert, AlertType } from "@/components/alert";
import { DynamicTheme } from "@/components/dynamic-theme";
import { UserAvatar } from "@/components/user-avatar";
import { VerifyForm } from "@/components/verify-form";
import { VerifyRedirectButton } from "@/components/verify-redirect-button";
import { sendEmailCode } from "@/lib/server/verify";
import { sendEmailCode, sendInviteEmailCode } from "@/lib/server/verify";
import { getServiceUrlFromHeaders } from "@/lib/service-url";
import { loadMostRecentSession } from "@/lib/session";
import {
getBrandingSettings,
getUserByID,
listAuthenticationMethodTypes,
} from "@/lib/zitadel";
import { getBrandingSettings, getUserByID } from "@/lib/zitadel";
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers";
@@ -22,16 +16,11 @@ export default async function Page(props: { searchParams: Promise<any> }) {
const t = await getTranslations({ locale, namespace: "verify" });
const tError = await getTranslations({ locale, namespace: "error" });
const { userId, loginName, code, organization, requestId, invite } =
const { userId, loginName, code, organization, requestId, invite, send } =
searchParams;
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const host = _headers.get("host");
if (!host || typeof host !== "string") {
throw new Error("No host found");
}
const branding = await getBrandingSettings({
serviceUrl,
@@ -43,10 +32,40 @@ export default async function Page(props: { searchParams: Promise<any> }) {
let human: HumanUser | undefined;
let id: string | undefined;
const doSend = invite !== "true";
const doSend = send === "true";
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
async function sendEmail(userId: string) {
const host = _headers.get("host");
if (!host || typeof host !== "string") {
throw new Error("No host found");
}
if (invite === "true") {
await sendInviteEmailCode({
userId,
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` +
(requestId ? `&requestId=${requestId}` : ""),
}).catch((error) => {
console.error("Could not send invitation email", error);
throw Error("Failed to send invitation email");
});
} else {
await sendEmailCode({
userId,
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
(requestId ? `&requestId=${requestId}` : ""),
}).catch((error) => {
console.error("Could not send verification email", error);
throw Error("Failed to send verification email");
});
}
}
if ("loginName" in searchParams) {
sessionFactors = await loadMostRecentSession({
serviceUrl,
@@ -57,29 +76,11 @@ export default async function Page(props: { searchParams: Promise<any> }) {
});
if (doSend && sessionFactors?.factors?.user?.id) {
await sendEmailCode({
serviceUrl,
userId: sessionFactors?.factors?.user?.id,
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` +
(requestId ? `&requestId=${requestId}` : ""),
}).catch((error) => {
console.error("Could not resend verification email", error);
throw Error("Failed to send verification email");
});
await sendEmail(sessionFactors.factors.user.id);
}
} else if ("userId" in searchParams && userId) {
if (doSend) {
await sendEmailCode({
serviceUrl,
userId,
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` +
(requestId ? `&requestId=${requestId}` : ""),
}).catch((error) => {
console.error("Could not resend verification email", error);
throw Error("Failed to send verification email");
});
await sendEmail(userId);
}
const userResponse = await getUserByID({
@@ -96,12 +97,8 @@ export default async function Page(props: { searchParams: Promise<any> }) {
id = userId ?? sessionFactors?.factors?.user?.id;
let authMethods: AuthenticationMethodType[] | null = null;
if (human?.email?.isVerified) {
const authMethodsResponse = await listAuthenticationMethodTypes(userId);
if (authMethodsResponse.authMethodTypes) {
authMethods = authMethodsResponse.authMethodTypes;
}
if (!id) {
throw Error("Failed to get user id");
}
const params = new URLSearchParams({
@@ -138,6 +135,12 @@ export default async function Page(props: { searchParams: Promise<any> }) {
</>
)}
{id && send && (
<div className="py-4 w-full">
<Alert type={AlertType.INFO}>{t("verify.codeSent")}</Alert>
</div>
)}
{sessionFactors ? (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
@@ -155,27 +158,14 @@ export default async function Page(props: { searchParams: Promise<any> }) {
)
)}
{id &&
(human?.email?.isVerified ? (
// show page for already verified users
<VerifyRedirectButton
userId={id}
loginName={loginName}
organization={organization}
requestId={requestId}
authMethods={authMethods}
/>
) : (
// check if auth methods are set
<VerifyForm
loginName={loginName}
organization={organization}
userId={id}
code={code}
isInvite={invite === "true"}
requestId={requestId}
/>
))}
<VerifyForm
loginName={loginName}
organization={organization}
userId={id}
code={code}
isInvite={invite === "true"}
requestId={requestId}
/>
</div>
</DynamicTheme>
);

View File

@@ -0,0 +1,109 @@
import { DynamicTheme } from "@/components/dynamic-theme";
import { UserAvatar } from "@/components/user-avatar";
import { getSessionCookieById } from "@/lib/cookies";
import { getServiceUrlFromHeaders } from "@/lib/service-url";
import { loadMostRecentSession } from "@/lib/session";
import {
getBrandingSettings,
getLoginSettings,
getSession,
getUserByID,
} from "@/lib/zitadel";
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { getLocale, getTranslations } from "next-intl/server";
import { headers } from "next/headers";
async function loadSessionById(
serviceUrl: string,
sessionId: string,
organization?: string,
) {
const recent = await getSessionCookieById({ sessionId, organization });
return getSession({
serviceUrl,
sessionId: recent.id,
sessionToken: recent.token,
}).then((response) => {
if (response?.session) {
return response.session;
}
});
}
export default async function Page(props: { searchParams: Promise<any> }) {
const searchParams = await props.searchParams;
const locale = getLocale();
const t = await getTranslations({ locale, namespace: "verify" });
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const { loginName, requestId, organization, userId } = searchParams;
const branding = await getBrandingSettings({
serviceUrl,
organization,
});
const sessionFactors = await loadMostRecentSession({
serviceUrl,
sessionParams: { loginName, organization },
}).catch((error) => {
console.warn("Error loading session:", error);
});
let loginSettings;
if (!requestId) {
loginSettings = await getLoginSettings({
serviceUrl,
organization,
});
}
const id = userId ?? sessionFactors?.factors?.user?.id;
if (!id) {
throw Error("Failed to get user id");
}
const userResponse = await getUserByID({
serviceUrl,
userId: id,
});
let user: User | undefined;
let human: HumanUser | undefined;
if (userResponse) {
user = userResponse.user;
if (user?.type.case === "human") {
human = user.type.value as HumanUser;
}
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("successTitle")}</h1>
<p className="ztdl-p mb-6 block">{t("successDescription")}</p>
{sessionFactors ? (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
) : (
user && (
<UserAvatar
loginName={user.preferredLoginName}
displayName={human?.profile?.displayName}
showDropdown={false}
/>
)
)}
</div>
</DynamicTheme>
);
}

View File

@@ -83,6 +83,16 @@ export function RegisterPasskey({
return;
}
if ("error" in resp && resp.error) {
setError(resp.error);
return;
}
if (!("passkeyId" in resp)) {
setError("An error on registering passkey");
return;
}
const passkeyId = resp.passkeyId;
const options: CredentialCreationOptions =
(resp.publicKeyCredentialCreationOptions as CredentialCreationOptions) ??
@@ -92,6 +102,7 @@ export function RegisterPasskey({
setError("An error on registering passkey");
return;
}
options.publicKey.challenge = coerceToArrayBuffer(
options.publicKey.challenge,
"challenge",

View File

@@ -74,7 +74,7 @@ export function SignInWithIdp({
return (
<div className="flex flex-col w-full space-y-2 text-sm">
{identityProviders?.map(renderIDPButton)}
{!!identityProviders.length && identityProviders?.map(renderIDPButton)}
{state?.error && (
<div className="py-4">
<Alert>{state?.error}</Alert>

View File

@@ -63,6 +63,11 @@ export function VerifyForm({
setLoading(false);
});
if (response && "error" in response && response?.error) {
setError(response.error);
return;
}
return response;
}

View File

@@ -1,90 +0,0 @@
"use client";
import {
sendVerificationRedirectWithoutCheck,
SendVerificationRedirectWithoutCheckCommand,
} from "@/lib/server/verify";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { Alert, AlertType } from "./alert";
import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
import { Spinner } from "./spinner";
export function VerifyRedirectButton({
userId,
loginName,
requestId,
authMethods,
organization,
}: {
userId?: string;
loginName?: string;
requestId: string;
authMethods: AuthenticationMethodType[] | null;
organization?: string;
}) {
const t = useTranslations("verify");
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
async function submitAndContinue(): Promise<boolean | void> {
setLoading(true);
let command = {
organization,
requestId,
} as SendVerificationRedirectWithoutCheckCommand;
if (userId) {
command = {
...command,
userId,
} as SendVerificationRedirectWithoutCheckCommand;
} else if (loginName) {
command = {
...command,
loginName,
} as SendVerificationRedirectWithoutCheckCommand;
}
await sendVerificationRedirectWithoutCheck(command)
.catch(() => {
setError("Could not verify");
return;
})
.finally(() => {
setLoading(false);
});
}
return (
<>
<Alert type={AlertType.INFO}>{t("success")}</Alert>
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
<BackButton />
<span className="flex-grow"></span>
{authMethods?.length === 0 && (
<Button
onClick={() => submitAndContinue()}
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
{t("setupAuthenticator")}
</Button>
)}
</div>
</>
);
}

View File

@@ -9,7 +9,6 @@ import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp";
import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { getServiceUrlFromHeaders } from "../service-url";
import { checkInvite } from "../verify-helper";
import {
getActiveIdentityProviders,
getIDPByID,
@@ -254,37 +253,27 @@ export async function sendLoginname(command: SendLoginnameCommand) {
userId: session.factors?.user?.id,
});
// this can be expected to be an invite as users created in console have a password set.
// always resend invite if user has no auth method set
if (!methods.authMethodTypes || !methods.authMethodTypes.length) {
// redirect to /verify invite if no auth method is set and email is not verified
const inviteCheck = checkInvite(
session,
humanUser,
session.factors.user.organizationId,
command.requestId,
);
if (inviteCheck?.redirect) {
return inviteCheck;
}
const paramsAuthenticatorSetup = new URLSearchParams({
loginName: session.factors?.user?.loginName,
userId: session.factors?.user?.id, // verify needs user id
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
send: "true", // set this to true to request a new code immediately
invite: "true",
});
if (command.requestId) {
params.append("requestId", command.requestId);
}
if (command.organization || session.factors?.user?.organizationId) {
paramsAuthenticatorSetup.append(
params.append(
"organization",
command.organization ?? session.factors?.user?.organizationId,
command.organization ??
(session.factors?.user?.organizationId as string),
);
}
if (command.requestId) {
paramsAuthenticatorSetup.append("requestId", command.requestId);
}
return { redirect: "/authenticator/set?" + paramsAuthenticatorSetup };
return { redirect: `/verify?` + params };
}
if (methods.authMethodTypes.length == 1) {

View File

@@ -5,10 +5,12 @@ import {
getLoginSettings,
getSession,
getUserByID,
listAuthenticationMethodTypes,
registerPasskey,
verifyPasskeyRegistration as zitadelVerifyPasskeyRegistration,
} from "@/lib/zitadel";
import { create, Duration } from "@zitadel/client";
import { create, Duration, Timestamp, timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import {
RegisterPasskeyResponse,
@@ -23,7 +25,10 @@ import {
getSessionCookieByLoginName,
} from "../cookies";
import { getServiceUrlFromHeaders } from "../service-url";
import { checkEmailVerification } from "../verify-helper";
import {
checkEmailVerification,
checkUserVerification,
} from "../verify-helper";
import { setSessionAndUpdateCookie } from "./cookie";
type VerifyPasskeyCommand = {
@@ -37,9 +42,25 @@ type RegisterPasskeyCommand = {
sessionId: string;
};
function isSessionValid(session: Partial<Session>): {
valid: boolean;
verifiedAt?: Timestamp;
} {
const validPassword = session?.factors?.password?.verifiedAt;
const validPasskey = session?.factors?.webAuthN?.verifiedAt;
const stillValid = session.expirationDate
? timestampDate(session.expirationDate) > new Date()
: true;
const verifiedAt = validPassword || validPasskey;
const valid = !!((validPassword || validPasskey) && stillValid);
return { valid, verifiedAt };
}
export async function registerPasskeyLink(
command: RegisterPasskeyCommand,
): Promise<RegisterPasskeyResponse> {
): Promise<RegisterPasskeyResponse | { error: string }> {
const { sessionId } = command;
const _headers = await headers();
@@ -57,6 +78,36 @@ export async function registerPasskeyLink(
sessionToken: sessionCookie.token,
});
if (!session?.session?.factors?.user?.id) {
return { error: "Could not determine user from session" };
}
const sessionValid = isSessionValid(session.session);
if (!sessionValid) {
const authmethods = await listAuthenticationMethodTypes({
serviceUrl,
userId: session.session.factors.user.id,
});
// if the user has no authmethods set, we need to check if the user was verified
if (authmethods.authMethodTypes.length !== 0) {
return {
error:
"You have to authenticate or have a valid User Verification Check",
};
}
// check if a verification was done earlier
const hasValidUserVerificationCheck = await checkUserVerification(
session.session.factors.user.id,
);
if (!hasValidUserVerificationCheck) {
return { error: "User Verification Check has to be done" };
}
}
const [hostname, port] = host.split(":");
if (!hostname) {

View File

@@ -37,6 +37,7 @@ import {
checkEmailVerification,
checkMFAFactors,
checkPasswordChangeRequired,
checkUserVerification,
} from "../verify-helper";
type ResetPasswordCommand = {
@@ -297,6 +298,7 @@ export async function sendPassword(command: UpdateSessionCommand) {
return { redirect: url };
}
// this function lets users with code set a password or users with valid User Verification Check
export async function changePassword(command: {
code?: string;
userId: string;
@@ -316,11 +318,39 @@ export async function changePassword(command: {
}
const userId = user.userId;
if (user.state === UserState.INITIAL) {
return { error: "User Initial State is not supported" };
}
// check if the user has no password set in order to set a password
if (!command.code) {
const authmethods = await listAuthenticationMethodTypes({
serviceUrl,
userId,
});
// if the user has no authmethods set, we need to check if the user was verified
if (authmethods.authMethodTypes.length !== 0) {
return {
error:
"You have to provide a code or have a valid User Verification Check",
};
}
// check if a verification was done earlier
const hasValidUserVerificationCheck = await checkUserVerification(
user.userId,
);
if (!hasValidUserVerificationCheck) {
return { error: "User Verification Check has to be done" };
}
}
return setUserPassword({
serviceUrl,
userId,
password: command.password,
user,
code: command.code,
});
}

View File

@@ -1,24 +1,25 @@
"use server";
import {
createInviteCode,
getLoginSettings,
getSession,
getUserByID,
listAuthenticationMethodTypes,
resendEmailCode,
resendInviteCode,
verifyEmail,
verifyInviteCode,
verifyTOTPRegistration,
sendEmailCode as zitadelSendEmailCode,
} from "@/lib/zitadel";
import crypto from "crypto";
import { create } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { headers } from "next/headers";
import { cookies, headers } from "next/headers";
import { getNextUrl } from "../client";
import { getSessionCookieByLoginName } from "../cookies";
import { getOrSetFingerprintId } from "../fingerprint";
import { getServiceUrlFromHeaders } from "../service-url";
import { loadMostRecentSession } from "../session";
import { checkMFAFactors } from "../verify-helper";
@@ -69,14 +70,16 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
serviceUrl,
userId: command.userId,
verificationCode: command.code,
}).catch(() => {
}).catch((error) => {
console.warn(error);
return { error: "Could not verify invite" };
})
: await verifyEmail({
serviceUrl,
userId: command.userId,
verificationCode: command.code,
}).catch(() => {
}).catch((error) => {
console.warn(error);
return { error: "Could not verify email" };
});
@@ -89,20 +92,26 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
}
let session: Session | undefined;
let user: User | undefined;
const userResponse = await getUserByID({
serviceUrl,
userId: command.userId,
});
if ("loginName" in command) {
const sessionCookie = await getSessionCookieByLoginName({
loginName: command.loginName,
organization: command.organization,
}).catch((error) => {
console.warn("Ignored error:", error);
});
if (!userResponse || !userResponse.user) {
return { error: "Could not load user" };
}
if (!sessionCookie) {
return { error: "Could not load session cookie" };
}
const user = userResponse.user;
const sessionCookie = await getSessionCookieByLoginName({
loginName:
"loginName" in command ? command.loginName : user.preferredLoginName,
organization: command.organization,
}).catch((error) => {
console.warn("Ignored error:", error); // checked later
});
if (sessionCookie) {
session = await getSession({
serviceUrl,
sessionId: sessionCookie.id,
@@ -112,65 +121,9 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
return response.session;
}
});
if (!session?.factors?.user?.id) {
return { error: "Could not create session for user" };
}
const userResponse = await getUserByID({
serviceUrl,
userId: session?.factors?.user?.id,
});
if (!userResponse?.user) {
return { error: "Could not load user" };
}
user = userResponse.user;
} else {
const userResponse = await getUserByID({
serviceUrl,
userId: command.userId,
});
if (!userResponse || !userResponse.user) {
return { error: "Could not load user" };
}
user = userResponse.user;
const checks = create(ChecksSchema, {
user: {
search: {
case: "loginName",
value: userResponse.user.preferredLoginName,
},
},
});
session = await createSessionAndUpdateCookie({
checks,
requestId: command.requestId,
});
}
if (!session?.factors?.user?.id) {
return { error: "Could not create session for user" };
}
if (!session?.factors?.user?.id) {
return { error: "Could not create session for user" };
}
if (!user) {
return { error: "Could not load user" };
}
const loginSettings = await getLoginSettings({
serviceUrl,
organization: user.details?.resourceOwner,
});
// load auth methods for user
const authMethodResponse = await listAuthenticationMethodTypes({
serviceUrl,
userId: user.userId,
@@ -186,6 +139,26 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
authMethodResponse.authMethodTypes &&
authMethodResponse.authMethodTypes.length == 0
) {
if (!sessionCookie) {
const checks = create(ChecksSchema, {
user: {
search: {
case: "loginName",
value: userResponse.user.preferredLoginName,
},
},
});
session = await createSessionAndUpdateCookie({
checks,
requestId: command.requestId,
});
}
if (!session) {
return { error: "Could not create session" };
}
const params = new URLSearchParams({
sessionId: session.id,
});
@@ -193,9 +166,62 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
if (session.factors?.user?.loginName) {
params.set("loginName", session.factors?.user?.loginName);
}
// set hash of userId and userAgentId to prevent attacks, checks are done for users with invalid sessions and invalid userAgentId
const cookiesList = await cookies();
const userAgentId = await getOrSetFingerprintId();
const verificationCheck = crypto
.createHash("sha256")
.update(`${user.userId}:${userAgentId}`)
.digest("hex");
await cookiesList.set({
name: "verificationCheck",
value: verificationCheck,
httpOnly: true,
path: "/",
maxAge: 300, // 5 minutes
});
return { redirect: `/authenticator/set?${params}` };
}
// if no session found only show success page,
// if user is invited, recreate invite flow to not depend on session
if (!session?.factors?.user?.id) {
const verifySuccessParams = new URLSearchParams({});
if (command.userId) {
verifySuccessParams.set("userId", command.userId);
}
if (
("loginName" in command && command.loginName) ||
user.preferredLoginName
) {
verifySuccessParams.set(
"loginName",
"loginName" in command && command.loginName
? command.loginName
: user.preferredLoginName,
);
}
if (command.requestId) {
verifySuccessParams.set("requestId", command.requestId);
}
if (command.organization) {
verifySuccessParams.set("organization", command.organization);
}
return { redirect: `/verify/success?${verifySuccessParams}` };
}
const loginSettings = await getLoginSettings({
serviceUrl,
organization: user.details?.resourceOwner,
});
// redirect to mfa factor if user has one, or redirect to set one up
const mfaFactorCheck = await checkMFAFactors(
serviceUrl,
@@ -254,193 +280,50 @@ export async function resendVerification(command: resendVerifyEmailCommand) {
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
return command.isInvite
? resendInviteCode({ serviceUrl, userId: command.userId })
: resendEmailCode({
? createInviteCode({
serviceUrl,
userId: command.userId,
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` +
(command.requestId ? `&requestId=${command.requestId}` : ""),
}).catch((error) => {
if (error.code === 9) {
return { error: "User is already verified!" };
}
return { error: "Could not resend invite" };
})
: zitadelSendEmailCode({
userId: command.userId,
serviceUrl,
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
`${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
(command.requestId ? `&requestId=${command.requestId}` : ""),
});
}
type sendEmailCommand = {
serviceUrl: string;
type SendEmailCommand = {
userId: string;
urlTemplate: string;
};
export async function sendEmailCode(command: sendEmailCommand) {
export async function sendEmailCode(command: SendEmailCommand) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
return zitadelSendEmailCode({
serviceUrl: command.serviceUrl,
serviceUrl,
userId: command.userId,
urlTemplate: command.urlTemplate,
});
}
export type SendVerificationRedirectWithoutCheckCommand = {
organization?: string;
requestId?: string;
} & (
| { userId: string; loginName?: never }
| { userId?: never; loginName: string }
);
export async function sendVerificationRedirectWithoutCheck(
command: SendVerificationRedirectWithoutCheckCommand,
) {
export async function sendInviteEmailCode(command: SendEmailCommand) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
if (!("loginName" in command || "userId" in command)) {
return { error: "No userId, nor loginname provided" };
}
let session: Session | undefined;
let user: User | undefined;
if ("loginName" in command) {
const sessionCookie = await getSessionCookieByLoginName({
loginName: command.loginName,
organization: command.organization,
}).catch((error) => {
console.warn("Ignored error:", error);
});
if (!sessionCookie) {
return { error: "Could not load session cookie" };
}
session = await getSession({
serviceUrl,
sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
}).then((response) => {
if (response?.session) {
return response.session;
}
});
if (!session?.factors?.user?.id) {
return { error: "Could not create session for user" };
}
const userResponse = await getUserByID({
serviceUrl,
userId: session?.factors?.user?.id,
});
if (!userResponse?.user) {
return { error: "Could not load user" };
}
user = userResponse.user;
} else if ("userId" in command) {
const userResponse = await getUserByID({
serviceUrl,
userId: command.userId,
});
if (!userResponse?.user) {
return { error: "Could not load user" };
}
user = userResponse.user;
const checks = create(ChecksSchema, {
user: {
search: {
case: "loginName",
value: userResponse.user.preferredLoginName,
},
},
});
session = await createSessionAndUpdateCookie({
checks,
requestId: command.requestId,
});
}
if (!session?.factors?.user?.id) {
return { error: "Could not create session for user" };
}
if (!session?.factors?.user?.id) {
return { error: "Could not create session for user" };
}
if (!user) {
return { error: "Could not load user" };
}
const authMethodResponse = await listAuthenticationMethodTypes({
return createInviteCode({
serviceUrl,
userId: user.userId,
userId: command.userId,
urlTemplate: command.urlTemplate,
});
if (!authMethodResponse || !authMethodResponse.authMethodTypes) {
return { error: "Could not load possible authenticators" };
}
// if no authmethods are found on the user, redirect to set one up
if (
authMethodResponse &&
authMethodResponse.authMethodTypes &&
authMethodResponse.authMethodTypes.length == 0
) {
const params = new URLSearchParams({
sessionId: session.id,
});
if (session.factors?.user?.loginName) {
params.set("loginName", session.factors?.user?.loginName);
}
return { redirect: `/authenticator/set?${params}` };
}
const loginSettings = await getLoginSettings({
serviceUrl,
organization: user.details?.resourceOwner,
});
// redirect to mfa factor if user has one, or redirect to set one up
const mfaFactorCheck = await checkMFAFactors(
serviceUrl,
session,
loginSettings,
authMethodResponse.authMethodTypes,
command.organization,
command.requestId,
);
if (mfaFactorCheck?.redirect) {
return mfaFactorCheck;
}
// login user if no additional steps are required
if (command.requestId && session.id) {
const nextUrl = await getNextUrl(
{
sessionId: session.id,
requestId: command.requestId,
organization:
command.organization ?? session.factors?.user?.organizationId,
},
loginSettings?.defaultRedirectUri,
);
return { redirect: nextUrl };
}
const url = await getNextUrl(
{
loginName: session.factors.user.loginName,
organization: session.factors?.user?.organizationId,
},
loginSettings?.defaultRedirectUri,
);
return { redirect: url };
}

View File

@@ -4,7 +4,10 @@ import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings
import { PasswordExpirySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
import { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import crypto from "crypto";
import moment from "moment";
import { cookies } from "next/headers";
import { getFingerprintIdCookie } from "./fingerprint";
import { getUserByID } from "./zitadel";
export function checkPasswordChangeRequired(
@@ -44,7 +47,7 @@ export function checkPasswordChangeRequired(
}
}
export function checkInvite(
export function checkEmailVerified(
session: Session,
humanUser?: HumanUser,
organization?: string,
@@ -54,7 +57,7 @@ export function checkInvite(
const paramsVerify = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
userId: session.factors?.user?.id as string, // verify needs user id
invite: "true", // TODO: check - set this to true as we dont expect old email verification method here
send: "true", // we request a new email code once the page is loaded
});
if (organization || session.factors?.user?.organizationId) {
@@ -84,6 +87,7 @@ export function checkEmailVerification(
) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
send: "true", // set this to true as we dont expect old email codes to be valid anymore
});
if (requestId) {
@@ -248,3 +252,38 @@ export async function checkMFAFactors(
return { redirect: `/mfa/set?` + params };
}
}
export async function checkUserVerification(userId: string): Promise<boolean> {
// check if a verification was done earlier
const cookiesList = await cookies();
// only read cookie to prevent issues on page.tsx
const fingerPrintCookie = await getFingerprintIdCookie();
if (!fingerPrintCookie || !fingerPrintCookie.value) {
return false;
}
const verificationCheck = crypto
.createHash("sha256")
.update(`${userId}:${fingerPrintCookie.value}`)
.digest("hex");
const cookieValue = await cookiesList.get("verificationCheck")?.value;
if (!cookieValue) {
console.warn(
"User verification check cookie not found. User verification check failed.",
);
return false;
}
if (cookieValue !== verificationCheck) {
console.warn(
`User verification check failed. Expected ${verificationCheck} but got ${cookieValue}`,
);
return false;
}
return true;
}

View File

@@ -29,11 +29,7 @@ import {
SearchQuery,
SearchQuerySchema,
} from "@zitadel/proto/zitadel/user/v2/query_pb";
import {
SendInviteCodeSchema,
User,
UserState,
} from "@zitadel/proto/zitadel/user/v2/user_pb";
import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb";
import {
AddHumanUserRequest,
ResendEmailCodeRequest,
@@ -506,21 +502,6 @@ export async function verifyInviteCode({
return userService.verifyInviteCode({ userId, verificationCode }, {});
}
export async function resendInviteCode({
serviceUrl,
userId,
}: {
serviceUrl: string;
userId: string;
}) {
const userService: Client<typeof UserService> = await createServiceForHost(
UserService,
serviceUrl,
);
return userService.resendInviteCode({ userId }, {});
}
export async function sendEmailCode({
serviceUrl,
userId,
@@ -1170,13 +1151,11 @@ export async function setUserPassword({
serviceUrl,
userId,
password,
user,
code,
}: {
serviceUrl: string;
userId: string;
password: string;
user: User;
code?: string;
}) {
let payload = create(SetPasswordRequestSchema, {
@@ -1186,22 +1165,6 @@ export async function setUserPassword({
},
});
// check if the user has no password set in order to set a password
if (!code) {
const authmethods = await listAuthenticationMethodTypes({
serviceUrl,
userId,
});
// if the user has no authmethods set, we can set a password otherwise we need a code
if (
!(authmethods.authMethodTypes.length === 0) &&
user.state !== UserState.INITIAL
) {
return { error: "Provide a code to set a password" };
}
}
if (code) {
payload = {
...payload,