Merge pull request #324 from zitadel/qa

promote qa to prod: check email verification
This commit is contained in:
Max Peintner
2025-01-03 11:40:21 +01:00
committed by GitHub
32 changed files with 1180 additions and 508 deletions

View File

@@ -8,5 +8,5 @@ export async function loginnameScreen(page: Page, username: string) {
export async function loginnameScreenExpect(page: Page, username: string) {
await expect(page.getByTestId(usernameTextInput)).toHaveValue(username);
await expect(page.getByTestId("error").locator("div")).toContainText("Could not find user");
await expect(page.getByTestId("error").locator("div")).toContainText("User not found in the system");
}

View File

@@ -1,3 +1,3 @@
ZITADEL_API_URL=http://localhost:22222
CACHE_REVALIDATION_INTERVAL_IN_SECONDS=3600
EMAIL_VERIFICATION=true
DEBUG=true

View File

@@ -0,0 +1,114 @@
import { stub } from "../support/mock";
describe("verify invite", () => {
beforeEach(() => {
stub("zitadel.org.v2.OrganizationService", "ListOrganizations", {
data: {
details: {
totalResult: 1,
},
result: [{ id: "256088834543534543" }],
},
});
stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", {
data: {
authMethodTypes: [], // user with no auth methods was invited
},
});
stub("zitadel.user.v2.UserService", "GetUserByID", {
data: {
user: {
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
human: {
userId: "221394658884845598",
state: 1,
username: "john@zitadel.com",
loginNames: ["john@zitadel.com"],
preferredLoginName: "john@zitadel.com",
profile: {
givenName: "John",
familyName: "Doe",
avatarUrl: "https://zitadel.com/avatar.jpg",
},
email: {
email: "john@zitadel.com",
isVerified: false,
},
},
},
},
});
stub("zitadel.session.v2.SessionService", "CreateSession", {
data: {
details: {
sequence: 859,
changeDate: new Date("2024-04-04T09:40:55.577Z"),
resourceOwner: "220516472055706145",
},
sessionId: "221394658884845598",
sessionToken:
"SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q",
challenges: undefined,
},
});
stub("zitadel.session.v2.SessionService", "GetSession", {
data: {
session: {
id: "221394658884845598",
creationDate: new Date("2024-04-04T09:40:55.577Z"),
changeDate: new Date("2024-04-04T09:40:55.577Z"),
sequence: 859,
factors: {
user: {
id: "221394658884845598",
loginName: "john@zitadel.com",
},
password: undefined,
webAuthN: undefined,
intent: undefined,
},
metadata: {},
},
},
});
stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", {
data: {
settings: {
passkeysType: 1,
allowUsernamePassword: true,
},
},
});
});
it.only("shows authenticators after successful invite verification", () => {
stub("zitadel.user.v2.UserService", "VerifyInviteCode");
cy.visit("/verify?userId=221394658884845598&code=abc&invite=true");
cy.location("pathname", { timeout: 10_000 }).should(
"eq",
"/authenticator/set",
);
});
it("shows an error if invite code validation failed", () => {
stub("zitadel.user.v2.UserService", "VerifyInviteCode", {
code: 3,
error: "error validating code",
});
// TODO: Avoid uncaught exception in application
cy.once("uncaught:exception", () => false);
cy.visit("/verify?userId=221394658884845598&code=abc&invite=true");
cy.contains("Could not verify invite", { timeout: 10_000 });
});
});

View File

@@ -165,6 +165,7 @@ describe("login", () => {
},
});
});
it("should redirect a user with passwordless authentication to /passkey", () => {
cy.visit("/loginname?loginName=john%40zitadel.com&submit=true");
cy.location("pathname", { timeout: 10_000 }).should("eq", "/passkey");

View File

@@ -1,6 +1,6 @@
import { stub } from "../support/mock";
describe("verify invite", () => {
describe("verify email", () => {
beforeEach(() => {
stub("zitadel.org.v2.OrganizationService", "ListOrganizations", {
data: {
@@ -13,10 +13,12 @@ describe("verify invite", () => {
stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", {
data: {
authMethodTypes: [],
authMethodTypes: [1], // set one method such that we know that the user was not invited
},
});
stub("zitadel.user.v2.UserService", "SendEmailCode");
stub("zitadel.user.v2.UserService", "GetUserByID", {
data: {
user: {
@@ -81,62 +83,14 @@ describe("verify invite", () => {
});
});
it.only("shows authenticators after successful invite verification", () => {
stub("zitadel.user.v2.UserService", "VerifyInviteCode");
cy.visit("/verify?userId=221394658884845598&code=abc&invite=true");
cy.location("pathname", { timeout: 10_000 }).should(
"eq",
"/authenticator/set",
);
});
it("shows an error if invite code validation failed", () => {
stub("zitadel.user.v2.UserService", "VerifyInviteCode", {
code: 3,
error: "error validating code",
});
// TODO: Avoid uncaught exception in application
cy.once("uncaught:exception", () => false);
cy.visit("/verify?userId=221394658884845598&code=abc&invite=true");
cy.contains("Could not verify invite", { timeout: 10_000 });
});
});
describe("verify email", () => {
beforeEach(() => {
stub("zitadel.org.v2.OrganizationService", "ListOrganizations", {
data: {
details: {
totalResult: 1,
},
result: [{ id: "256088834543534543" }],
},
});
stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", {
data: {
authMethodTypes: [],
},
});
});
it("shows password and passkey method after successful invite verification", () => {
stub("zitadel.user.v2.UserService", "VerifyEmail");
cy.visit("/verify?userId=221394658884845598&code=abc");
cy.location("pathname", { timeout: 10_000 }).should(
"eq",
"/authenticator/set",
);
});
it("shows an error if invite code validation failed", () => {
it("shows an error if email code validation failed", () => {
stub("zitadel.user.v2.UserService", "VerifyEmail", {
code: 3,
error: "error validating code",
});
// TODO: Avoid uncaught exception in application
cy.once("uncaught:exception", () => false);
cy.visit("/verify?userId=221394658884845598&code=abc&submit=true");
cy.visit("/verify?userId=221394658884845598&code=abc");
cy.contains("Could not verify email", { timeout: 10_000 });
});
});

View File

@@ -3,13 +3,7 @@
"service": "zitadel.settings.v2.SettingsService",
"method": "GetBrandingSettings",
"out": {
"data": {
"settings": {
"darkTheme": {
"backgroundColor": "#ff0000"
}
}
}
"data": {}
}
},
{

View File

@@ -9,7 +9,8 @@
"test:unit": "vitest",
"test:unit:watch": "pnpm test:unit --watch",
"test:integration": "pnpm mock:build && concurrently --names 'mock,test' --success command-test --kill-others 'pnpm:mock' 'env-cmd -f ./.env.integration start-server-and-test start http://localhost:3000 \"test:integration:run\"'",
"test:integration:watch": "concurrently --names 'mock,test' --kill-others 'pnpm:mock' 'env-cmd -f ./.env.integration start-server-and-test dev http://localhost:3000 \"pnpm nodemon -e js,jsx,ts,tsx,css,scss --ignore \\\"__test__/**\\\" --exec \\\"pnpm test:integration:run\\\"\"'",
"test:integration:watch:run": "concurrently --names 'mock,test' --kill-others 'pnpm:mock' 'env-cmd -f ./.env.integration start-server-and-test dev http://localhost:3000 \"pnpm nodemon -e js,jsx,ts,tsx,css,scss --ignore \\\"__test__/**\\\" --exec \\\"pnpm test:integration:run\\\"\"'",
"test:integration:watch:open": "concurrently --names 'mock,test' --kill-others 'pnpm:mock' 'env-cmd -f ./.env.integration start-server-and-test dev http://localhost:3000 \"pnpm nodemon -e js,jsx,ts,tsx,css,scss --ignore \\\"__test__/**\\\" --exec \\\"pnpm test:integration:open\\\"\"'",
"test:integration:run": "cypress run --config-file ./cypress/cypress.config.ts --quiet",
"test:integration:open": "cypress open --config-file ./cypress/cypress.config.ts",
"mock": "pnpm mock:build && pnpm mock:run",

View File

@@ -49,6 +49,16 @@ export default async function Page(props: {
organization ?? defaultOrganization,
);
const params = new URLSearchParams();
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
@@ -57,16 +67,7 @@ export default async function Page(props: {
<div className="flex flex-col w-full space-y-2">
<SessionsList sessions={sessions} authRequestId={authRequestId} />
<Link
href={
authRequestId
? `/loginname?` +
new URLSearchParams({
authRequestId,
})
: "/loginname"
}
>
<Link href={`/loginname?` + params}>
<div className="flex flex-row items-center py-3 px-4 hover:bg-black/10 dark:hover:bg-white/10 rounded-md transition-all">
<div className="w-8 h-8 mr-4 flex flex-row justify-center items-center rounded-full bg-black/5 dark:bg-white/5">
<UserPlusIcon className="h-5 w-5" />

View File

@@ -3,6 +3,8 @@ 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 { loadMostRecentSession } from "@/lib/session";
import {
getBrandingSettings,
getUserByID,
@@ -23,9 +25,39 @@ export default async function Page(props: { searchParams: Promise<any> }) {
const branding = await getBrandingSettings(organization);
let sessionFactors;
let user: User | undefined;
let human: HumanUser | undefined;
if (userId) {
let id: string | undefined;
const doSend = invite !== "true";
if ("loginName" in searchParams) {
sessionFactors = await loadMostRecentSession({
loginName,
organization,
});
if (doSend && sessionFactors?.factors?.user?.id) {
await sendEmailCode({
userId: sessionFactors?.factors?.user?.id,
authRequestId,
}).catch((error) => {
console.error("Could not resend verification email", error);
throw Error("Failed to send verification email");
});
}
} else if ("userId" in searchParams && userId) {
if (doSend) {
await sendEmailCode({
userId,
authRequestId,
}).catch((error) => {
console.error("Could not resend verification email", error);
throw Error("Failed to send verification email");
});
}
const userResponse = await getUserByID(userId);
if (userResponse) {
user = userResponse.user;
@@ -35,6 +67,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);
@@ -66,7 +100,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
<h1>{t("verify.title")}</h1>
<p className="ztdl-p mb-6 block">{t("verify.description")}</p>
{!userId && (
{!id && (
<>
<h1>{t("verify.title")}</h1>
<p className="ztdl-p mb-6 block">{t("verify.description")}</p>
@@ -77,29 +111,44 @@ export default async function Page(props: { searchParams: Promise<any> }) {
</>
)}
{user && (
{sessionFactors ? (
<UserAvatar
loginName={user.preferredLoginName}
displayName={human?.profile?.displayName}
showDropdown={false}
/>
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}
/>
)
)}
{human?.email?.isVerified ? (
<VerifyRedirectButton
userId={userId}
authRequestId={authRequestId}
authMethods={authMethods}
/>
) : (
// check if auth methods are set
<VerifyForm
userId={userId}
code={code}
isInvite={invite === "true"}
params={params}
/>
)}
{id &&
(human?.email?.isVerified ? (
// show page for already verified users
<VerifyRedirectButton
userId={id}
loginName={loginName}
organization={organization}
authRequestId={authRequestId}
authMethods={authMethods}
/>
) : (
// check if auth methods are set
<VerifyForm
loginName={loginName}
organization={organization}
userId={id}
code={code}
isInvite={invite === "true"}
authRequestId={authRequestId}
/>
))}
</div>
</DynamicTheme>
);

View File

@@ -221,7 +221,7 @@ export async function GET(request: NextRequest) {
const res = await sendLoginname(command);
if (res?.redirect) {
if (res && "redirect" in res && res?.redirect) {
const absoluteUrl = new URL(res.redirect, request.url);
return NextResponse.redirect(absoluteUrl.toString());
}
@@ -429,7 +429,7 @@ export async function GET(request: NextRequest) {
const res = await sendLoginname(command);
if (res?.redirect) {
if (res && "redirect" in res && res?.redirect) {
const absoluteUrl = new URL(res.redirect, request.url);
return NextResponse.redirect(absoluteUrl.toString());
}

View File

@@ -1,6 +1,6 @@
"use client";
import { createNewSessionForIdp } from "@/lib/server/session";
import { createNewSessionFromIdpIntent } from "@/lib/server/idp";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Alert } from "./alert";
@@ -27,7 +27,7 @@ export function IdpSignin({
const router = useRouter();
useEffect(() => {
createNewSessionForIdp({
createNewSessionFromIdpIntent({
userId,
idpIntent: {
idpIntentId,

View File

@@ -1,9 +1,9 @@
"use client";
import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
import { getNextUrl } from "@/lib/client";
import { sendPasskey } from "@/lib/server/passkeys";
import { updateSession } from "@/lib/server/session";
import { create } from "@zitadel/client";
import { create, JsonObject } from "@zitadel/client";
import {
RequestChallengesSchema,
UserVerificationRequirement,
@@ -118,9 +118,9 @@ export function LoginPasskey({
return session;
}
async function submitLogin(data: any) {
async function submitLogin(data: JsonObject) {
setLoading(true);
const response = await updateSession({
const response = await sendPasskey({
loginName,
sessionId,
organization,
@@ -142,7 +142,9 @@ export function LoginPasskey({
return;
}
return response;
if (response && "redirect" in response && response.redirect) {
return router.push(response.redirect);
}
}
async function submitLoginAndContinue(
@@ -192,31 +194,7 @@ export function LoginPasskey({
},
};
return submitLogin(data).then(async (resp) => {
const url =
authRequestId && resp?.sessionId
? await getNextUrl(
{
sessionId: resp.sessionId,
authRequestId: authRequestId,
organization: organization,
},
loginSettings?.defaultRedirectUri,
)
: resp?.factors?.user?.loginName
? await getNextUrl(
{
loginName: resp.factors.user.loginName,
organization: organization,
},
loginSettings?.defaultRedirectUri,
)
: null;
if (url) {
router.push(url);
}
});
return submitLogin(data);
})
.finally(() => {
setLoading(false);

View File

@@ -1,7 +1,10 @@
"use client";
import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
import { registerPasskeyLink, verifyPasskey } from "@/lib/server/passkeys";
import {
registerPasskeyLink,
verifyPasskeyRegistration,
} from "@/lib/server/passkeys";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
@@ -45,7 +48,7 @@ export function RegisterPasskey({
sessionId: string,
) {
setLoading(true);
const response = await verifyPasskey({
const response = await verifyPasskeyRegistration({
passkeyId,
passkeyName,
publicKeyCredential,

View File

@@ -88,11 +88,11 @@ export function SessionItem({
setLoading(false);
});
if (res?.redirect) {
if (res && "redirect" in res && res.redirect) {
return router.push(res.redirect);
}
if (res?.error) {
if (res && "error" in res && res.error) {
setError(res.error);
return;
}

View File

@@ -61,11 +61,11 @@ export function UsernameForm({
setLoading(false);
});
if (res?.redirect) {
if (res && "redirect" in res && res.redirect) {
return router.push(res.redirect);
}
if (res?.error) {
if (res && "error" in res && res.error) {
setError(res.error);
return;
}

View File

@@ -1,7 +1,7 @@
"use client";
import { Alert, AlertType } from "@/components/alert";
import { resendVerification, sendVerification } from "@/lib/server/email";
import { resendVerification, sendVerification } from "@/lib/server/verify";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
@@ -17,12 +17,21 @@ type Inputs = {
type Props = {
userId: string;
loginName?: string;
organization?: string;
code?: string;
isInvite: boolean;
params: URLSearchParams;
authRequestId?: string;
};
export function VerifyForm({ userId, code, isInvite, params }: Props) {
export function VerifyForm({
userId,
loginName,
organization,
authRequestId,
code,
isInvite,
}: Props) {
const t = useTranslations("verify");
const router = useRouter();
@@ -67,6 +76,9 @@ export function VerifyForm({ userId, code, isInvite, params }: Props) {
code: value.code,
userId,
isInvite: isInvite,
loginName: loginName,
organization: organization,
authRequestId: authRequestId,
})
.catch(() => {
setError("Could not verify user");
@@ -76,12 +88,12 @@ export function VerifyForm({ userId, code, isInvite, params }: Props) {
setLoading(false);
});
if (response?.error) {
if (response && "error" in response && response?.error) {
setError(response.error);
return;
}
if (response?.redirect) {
if (response && "redirect" in response && response?.redirect) {
return router.push(response?.redirect);
}
},

View File

@@ -1,6 +1,9 @@
"use client";
import { sendVerificationRedirectWithoutCheck } from "@/lib/server/email";
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";
@@ -11,12 +14,16 @@ import { Spinner } from "./spinner";
export function VerifyRedirectButton({
userId,
loginName,
authRequestId,
authMethods,
organization,
}: {
userId: string;
userId?: string;
loginName?: string;
authRequestId: string;
authMethods: AuthenticationMethodType[] | null;
organization?: string;
}) {
const t = useTranslations("verify");
const [error, setError] = useState<string>("");
@@ -26,12 +33,26 @@ export function VerifyRedirectButton({
async function submitAndContinue(): Promise<boolean | void> {
setLoading(true);
await sendVerificationRedirectWithoutCheck({
userId,
let command = {
organization,
authRequestId,
})
.catch((error) => {
setError("Could not verify user");
} 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(() => {

View File

@@ -1,139 +0,0 @@
"use server";
import {
getUserByID,
listAuthenticationMethodTypes,
resendEmailCode,
resendInviteCode,
verifyEmail,
verifyInviteCode,
} from "@/lib/zitadel";
import { create } from "@zitadel/client";
import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { createSessionAndUpdateCookie } from "./cookie";
type VerifyUserByEmailCommand = {
userId: string;
code: string;
isInvite: boolean;
authRequestId?: string;
};
export async function sendVerification(command: VerifyUserByEmailCommand) {
const verifyResponse = command.isInvite
? await verifyInviteCode(command.userId, command.code).catch(() => {
return { error: "Could not verify invite" };
})
: await verifyEmail(command.userId, command.code).catch(() => {
return { error: "Could not verify email" };
});
if (!verifyResponse) {
return { error: "Could not verify user" };
}
const userResponse = await getUserByID(command.userId);
if (!userResponse || !userResponse.user) {
return { error: "Could not load user" };
}
const checks = create(ChecksSchema, {
user: {
search: {
case: "loginName",
value: userResponse.user.preferredLoginName,
},
},
});
const session = await createSessionAndUpdateCookie(
checks,
undefined,
command.authRequestId,
);
const authMethodResponse = await listAuthenticationMethodTypes(
command.userId,
);
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}` };
}
}
type resendVerifyEmailCommand = {
userId: string;
isInvite: boolean;
};
export async function resendVerification(command: resendVerifyEmailCommand) {
return command.isInvite
? resendInviteCode(command.userId)
: resendEmailCode(command.userId);
}
export async function sendVerificationRedirectWithoutCheck(command: {
userId: string;
authRequestId?: string;
}) {
const userResponse = await getUserByID(command.userId);
if (!userResponse || !userResponse.user) {
return { error: "Could not load user" };
}
const checks = create(ChecksSchema, {
user: {
search: {
case: "loginName",
value: userResponse.user.preferredLoginName,
},
},
});
const session = await createSessionAndUpdateCookie(
checks,
undefined,
command.authRequestId,
);
const authMethodResponse = await listAuthenticationMethodTypes(
command.userId,
);
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}` };
}
}

View File

@@ -1,7 +1,14 @@
"use server";
import { startIdentityProviderFlow } from "@/lib/zitadel";
import {
getLoginSettings,
getUserByID,
startIdentityProviderFlow,
} from "@/lib/zitadel";
import { headers } from "next/headers";
import { getNextUrl } from "../client";
import { checkEmailVerification } from "../verify-helper";
import { createSessionForIdpAndUpdateCookie } from "./cookie";
export type StartIDPFlowCommand = {
idpId: string;
@@ -32,3 +39,85 @@ export async function startIDPFlow(command: StartIDPFlowCommand) {
}
});
}
type CreateNewSessionCommand = {
userId: string;
idpIntent: {
idpIntentId: string;
idpIntentToken: string;
};
loginName?: string;
password?: string;
organization?: string;
authRequestId?: string;
};
export async function createNewSessionFromIdpIntent(
command: CreateNewSessionCommand,
) {
if (!command.userId || !command.idpIntent) {
throw new Error("No userId or loginName provided");
}
const userResponse = await getUserByID(command.userId);
if (!userResponse || !userResponse.user) {
return { error: "User not found in the system" };
}
const loginSettings = await getLoginSettings(
userResponse.user.details?.resourceOwner,
);
const session = await createSessionForIdpAndUpdateCookie(
command.userId,
command.idpIntent,
command.authRequestId,
loginSettings?.externalLoginCheckLifetime,
);
if (!session || !session.factors?.user) {
return { error: "Could not create session" };
}
const humanUser =
userResponse.user.type.case === "human"
? userResponse.user.type.value
: undefined;
// check to see if user was verified
const emailVerificationCheck = checkEmailVerification(
session,
humanUser,
command.organization,
command.authRequestId,
);
if (emailVerificationCheck?.redirect) {
return emailVerificationCheck;
}
// TODO: check if user has MFA methods
// const mfaFactorCheck = checkMFAFactors(session, loginSettings, authMethods, organization, authRequestId);
// if (mfaFactorCheck?.redirect) {
// return mfaFactorCheck;
// }
const url = await getNextUrl(
command.authRequestId && session.id
? {
sessionId: session.id,
authRequestId: command.authRequestId,
organization: session.factors.user.organizationId,
}
: {
loginName: session.factors.user.loginName,
organization: session.factors.user.organizationId,
},
loginSettings?.defaultRedirectUri,
);
if (url) {
return { redirect: url };
}
}

View File

@@ -8,6 +8,7 @@ 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 { checkInvite } from "../verify-helper";
import {
getActiveIdentityProviders,
getIDPByID,
@@ -170,30 +171,23 @@ export async function sendLoginname(command: SendLoginnameCommand) {
session.factors?.user?.id,
);
// this can be expected to be an invite as users created in console have a password set.
if (!methods.authMethodTypes || !methods.authMethodTypes.length) {
if (
potentialUsers[0].type.case === "human" &&
potentialUsers[0].type.value.email &&
!potentialUsers[0].type.value.email.isVerified
) {
const paramsVerify = new URLSearchParams({
loginName: session.factors?.user?.loginName,
userId: session.factors?.user?.id, // verify needs user id
invite: "true", // TODO: check - set this to true as we dont expect old email verification method here
});
const humanUser =
potentialUsers[0].type.case === "human"
? potentialUsers[0].type.value
: undefined;
if (command.organization || session.factors?.user?.organizationId) {
paramsVerify.append(
"organization",
command.organization ?? session.factors?.user?.organizationId,
);
}
// 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.authRequestId,
);
if (command.authRequestId) {
paramsVerify.append("authRequestId", command.authRequestId);
}
return { redirect: "/verify?" + paramsVerify };
if (inviteCheck?.redirect) {
return inviteCheck;
}
const paramsAuthenticatorSetup = new URLSearchParams({
@@ -316,7 +310,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
if (resp) {
return resp;
}
return { error: "Could not find user" };
return { error: "User not found in the system" };
} else if (
loginSettings?.allowRegister &&
loginSettings?.allowUsernamePassword
@@ -350,8 +344,9 @@ export async function sendLoginname(command: SendLoginnameCommand) {
if (command.authRequestId) {
params.set("authRequestId", command.authRequestId);
}
if (command.loginName) {
params.set("loginName", command.loginName);
params.set("email", command.loginName);
}
return { redirect: "/register?" + params };
@@ -376,5 +371,5 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// fallbackToPassword
return { error: "Could not find user" };
return { error: "User not found in the system" };
}

View File

@@ -2,18 +2,28 @@
import {
createPasskeyRegistrationLink,
getLoginSettings,
getSession,
getUserByID,
registerPasskey,
verifyPasskeyRegistration,
verifyPasskeyRegistration as zitadelVerifyPasskeyRegistration,
} from "@/lib/zitadel";
import { create } from "@zitadel/client";
import { create, Duration } from "@zitadel/client";
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import {
RegisterPasskeyResponse,
VerifyPasskeyRegistrationRequestSchema,
} from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { headers } from "next/headers";
import { userAgent } from "next/server";
import { getSessionCookieById } from "../cookies";
import { getNextUrl } from "../client";
import {
getMostRecentSessionCookie,
getSessionCookieById,
getSessionCookieByLoginName,
} from "../cookies";
import { checkEmailVerification } from "../verify-helper";
import { setSessionAndUpdateCookie } from "./cookie";
type VerifyPasskeyCommand = {
passkeyId: string;
@@ -69,7 +79,7 @@ export async function registerPasskeyLink(
return registerPasskey(userId, registerLink.code, hostname);
}
export async function verifyPasskey(command: VerifyPasskeyCommand) {
export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) {
// if no name is provided, try to generate one from the user agent
let passkeyName = command.passkeyName;
if (!!!passkeyName) {
@@ -95,7 +105,7 @@ export async function verifyPasskey(command: VerifyPasskeyCommand) {
throw new Error("Could not get session");
}
return verifyPasskeyRegistration(
return zitadelVerifyPasskeyRegistration(
create(VerifyPasskeyRegistrationRequestSchema, {
passkeyId: command.passkeyId,
publicKeyCredential: command.publicKeyCredential,
@@ -104,3 +114,97 @@ export async function verifyPasskey(command: VerifyPasskeyCommand) {
}),
);
}
type SendPasskeyCommand = {
loginName?: string;
sessionId?: string;
organization?: string;
checks?: Checks;
authRequestId?: string;
lifetime?: Duration;
};
export async function sendPasskey(command: SendPasskeyCommand) {
let { loginName, sessionId, organization, checks, authRequestId } = command;
const recentSession = sessionId
? await getSessionCookieById({ sessionId })
: loginName
? await getSessionCookieByLoginName({ loginName, organization })
: await getMostRecentSessionCookie();
if (!recentSession) {
return {
error: "Could not find session",
};
}
const host = (await headers()).get("host");
if (!host) {
return { error: "Could not get host" };
}
const loginSettings = await getLoginSettings(organization);
const lifetime = checks?.webAuthN
? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey
: checks?.otpEmail || checks?.otpSms
? loginSettings?.secondFactorCheckLifetime
: undefined;
const session = await setSessionAndUpdateCookie(
recentSession,
checks,
undefined,
authRequestId,
lifetime,
);
if (!session || !session?.factors?.user?.id) {
return { error: "Could not update session" };
}
const userResponse = await getUserByID(session?.factors?.user?.id);
if (!userResponse.user) {
return { error: "User not found in the system" };
}
const humanUser =
userResponse.user.type.case === "human"
? userResponse.user.type.value
: undefined;
const emailVerificationCheck = checkEmailVerification(
session,
humanUser,
organization,
authRequestId,
);
if (emailVerificationCheck?.redirect) {
return emailVerificationCheck;
}
const url =
authRequestId && session.id
? await getNextUrl(
{
sessionId: session.id,
authRequestId: authRequestId,
organization: organization,
},
loginSettings?.defaultRedirectUri,
)
: session?.factors?.user?.loginName
? await getNextUrl(
{
loginName: session.factors.user.loginName,
organization: organization,
},
loginSettings?.defaultRedirectUri,
)
: null;
return { redirect: url };
}

View File

@@ -30,6 +30,11 @@ import {
import { headers } from "next/headers";
import { getNextUrl } from "../client";
import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies";
import {
checkEmailVerification,
checkMFAFactors,
checkPasswordChangeRequired,
} from "../verify-helper";
type ResetPasswordCommand = {
loginName: string;
@@ -118,7 +123,7 @@ export async function sendPassword(command: UpdateSessionCommand) {
const userResponse = await getUserByID(session?.factors?.user?.id);
if (!userResponse.user) {
return { error: "Could not find user" };
return { error: "User not found in the system" };
}
user = userResponse.user;
@@ -134,6 +139,37 @@ export async function sendPassword(command: UpdateSessionCommand) {
return { error: "Could not create session for user" };
}
const humanUser = user.type.case === "human" ? user.type.value : undefined;
// check if the user has to change password first
const passwordChangedCheck = checkPasswordChangeRequired(
session,
humanUser,
command.organization,
command.authRequestId,
);
if (passwordChangedCheck?.redirect) {
return passwordChangedCheck;
}
// throw error if user is in initial state here and do not continue
if (user.state === UserState.INITIAL) {
return { error: "Initial User not supported" };
}
// check to see if user was verified
const emailVerificationCheck = checkEmailVerification(
session,
humanUser,
command.organization,
command.authRequestId,
);
if (emailVerificationCheck?.redirect) {
return emailVerificationCheck;
}
// if password, check if user has MFA methods
let authMethods;
if (command.checks && command.checks.password && session.factors?.user?.id) {
@@ -145,131 +181,23 @@ export async function sendPassword(command: UpdateSessionCommand) {
}
}
if (!authMethods || !session.factors?.user?.loginName) {
if (!authMethods) {
return { error: "Could not verify password!" };
}
const humanUser = user.type.case === "human" ? user.type.value : undefined;
// check if the user has to change password first
if (humanUser?.passwordChangeRequired) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName,
});
if (command.organization || session.factors?.user?.organizationId) {
params.append("organization", session.factors?.user?.organizationId);
}
if (command.authRequestId) {
params.append("authRequestId", command.authRequestId);
}
return { redirect: "/password/change?" + params };
}
const availableMultiFactors = authMethods?.filter(
(m: AuthenticationMethodType) =>
m !== AuthenticationMethodType.PASSWORD &&
m !== AuthenticationMethodType.PASSKEY,
const mfaFactorCheck = checkMFAFactors(
session,
loginSettings,
authMethods,
command.organization,
command.authRequestId,
);
if (availableMultiFactors?.length == 1) {
const params = new URLSearchParams({
loginName: session.factors?.user.loginName,
});
if (command.authRequestId) {
params.append("authRequestId", command.authRequestId);
}
if (command.organization || session.factors?.user?.organizationId) {
params.append(
"organization",
command.organization ?? session.factors?.user?.organizationId,
);
}
const factor = availableMultiFactors[0];
// if passwordless is other method, but user selected password as alternative, perform a login
if (factor === AuthenticationMethodType.TOTP) {
return { redirect: `/otp/time-based?` + params };
} else if (factor === AuthenticationMethodType.OTP_SMS) {
return { redirect: `/otp/sms?` + params };
} else if (factor === AuthenticationMethodType.OTP_EMAIL) {
return { redirect: `/otp/email?` + params };
} else if (factor === AuthenticationMethodType.U2F) {
return { redirect: `/u2f?` + params };
}
} else if (availableMultiFactors?.length >= 1) {
const params = new URLSearchParams({
loginName: session.factors.user.loginName,
});
if (command.authRequestId) {
params.append("authRequestId", command.authRequestId);
}
if (command.organization || session.factors?.user?.organizationId) {
params.append(
"organization",
command.organization ?? session.factors?.user?.organizationId,
);
}
return { redirect: `/mfa?` + params };
if (mfaFactorCheck?.redirect) {
return mfaFactorCheck;
}
// TODO: check if handling of userstate INITIAL is needed
else if (user.state === UserState.INITIAL) {
return { error: "Initial User not supported" };
} else if (
(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) &&
!availableMultiFactors.length
) {
const params = new URLSearchParams({
loginName: session.factors.user.loginName,
force: "true", // this defines if the mfa is forced in the settings
checkAfter: "true", // this defines if the check is directly made after the setup
});
if (command.authRequestId) {
params.append("authRequestId", command.authRequestId);
}
if (command.organization || session.factors?.user?.organizationId) {
params.append(
"organization",
command.organization ?? session.factors?.user?.organizationId,
);
}
// TODO: provide a way to setup passkeys on mfa page?
return { redirect: `/mfa/set?` + params };
}
// TODO: implement passkey setup
// else if (
// submitted.factors &&
// !submitted.factors.webAuthN && // if session was not verified with a passkey
// promptPasswordless && // if explicitly prompted due policy
// !isAlternative // escaped if password was used as an alternative method
// ) {
// const params = new URLSearchParams({
// loginName: submitted.factors.user.loginName,
// prompt: "true",
// });
// if (authRequestId) {
// params.append("authRequestId", authRequestId);
// }
// if (organization) {
// params.append("organization", organization);
// }
// return router.push(`/passkey/set?` + params);
// }
else if (command.authRequestId && session.id) {
if (command.authRequestId && session.id) {
const nextUrl = await getNextUrl(
{
sessionId: session.id,

View File

@@ -1,7 +1,7 @@
"use server";
import { createSessionAndUpdateCookie } from "@/lib/server/cookie";
import { addHumanUser, getLoginSettings } from "@/lib/zitadel";
import { addHumanUser, getLoginSettings, getUserByID } from "@/lib/zitadel";
import { create } from "@zitadel/client";
import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb";
import {
@@ -9,6 +9,7 @@ import {
ChecksSchema,
} from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { getNextUrl } from "../client";
import { checkEmailVerification } from "../verify-helper";
type RegisterUserCommand = {
email: string;
@@ -25,7 +26,7 @@ export type RegisterUserResponse = {
factors: Factors | undefined;
};
export async function registerUser(command: RegisterUserCommand) {
const human = await addHumanUser({
const addResponse = await addHumanUser({
email: command.email,
firstName: command.firstName,
lastName: command.lastName,
@@ -33,14 +34,14 @@ export async function registerUser(command: RegisterUserCommand) {
organization: command.organization,
});
if (!human) {
if (!addResponse) {
return { error: "Could not create user" };
}
const loginSettings = await getLoginSettings(command.organization);
let checkPayload: any = {
user: { search: { case: "userId", value: human.userId } },
user: { search: { case: "userId", value: addResponse.userId } },
};
if (command.password) {
@@ -75,6 +76,28 @@ export async function registerUser(command: RegisterUserCommand) {
return { redirect: "/passkey/set?" + params };
} else {
const userResponse = await getUserByID(session?.factors?.user?.id);
if (!userResponse.user) {
return { error: "User not found in the system" };
}
const humanUser =
userResponse.user.type.case === "human"
? userResponse.user.type.value
: undefined;
const emailVerificationCheck = checkEmailVerification(
session,
humanUser,
session.factors.user.organizationId,
command.authRequestId,
);
if (emailVerificationCheck?.redirect) {
return emailVerificationCheck;
}
const url = await getNextUrl(
command.authRequestId && session.id
? {

View File

@@ -1,13 +1,9 @@
"use server";
import {
createSessionForIdpAndUpdateCookie,
setSessionAndUpdateCookie,
} from "@/lib/server/cookie";
import { setSessionAndUpdateCookie } from "@/lib/server/cookie";
import {
deleteSession,
getLoginSettings,
getUserByID,
listAuthenticationMethodTypes,
} from "@/lib/zitadel";
import { Duration } from "@zitadel/client";
@@ -23,62 +19,6 @@ import {
removeSessionFromCookie,
} from "../cookies";
type CreateNewSessionCommand = {
userId: string;
idpIntent: {
idpIntentId: string;
idpIntentToken: string;
};
loginName?: string;
password?: string;
authRequestId?: string;
};
export async function createNewSessionForIdp(options: CreateNewSessionCommand) {
const { userId, idpIntent, authRequestId } = options;
if (!userId || !idpIntent) {
throw new Error("No userId or loginName provided");
}
const user = await getUserByID(userId);
if (!user) {
return { error: "Could not find user" };
}
const loginSettings = await getLoginSettings(user.details?.resourceOwner);
const session = await createSessionForIdpAndUpdateCookie(
userId,
idpIntent,
authRequestId,
loginSettings?.externalLoginCheckLifetime,
);
if (!session || !session.factors?.user) {
return { error: "Could not create session" };
}
const url = await getNextUrl(
authRequestId && session.id
? {
sessionId: session.id,
authRequestId: authRequestId,
organization: session.factors.user.organizationId,
}
: {
loginName: session.factors.user.loginName,
organization: session.factors.user.organizationId,
},
loginSettings?.defaultRedirectUri,
);
if (url) {
return { redirect: url };
}
}
export async function continueWithSession({
authRequestId,
...session

View File

@@ -0,0 +1,357 @@
"use server";
import {
getLoginSettings,
getSession,
getUserByID,
listAuthenticationMethodTypes,
resendEmailCode,
resendInviteCode,
verifyEmail,
verifyInviteCode,
sendEmailCode as zitadelSendEmailCode,
} from "@/lib/zitadel";
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 { getNextUrl } from "../client";
import { getSessionCookieByLoginName } from "../cookies";
import { checkMFAFactors } from "../verify-helper";
import { createSessionAndUpdateCookie } from "./cookie";
type VerifyUserByEmailCommand = {
userId: string;
loginName?: string; // to determine already existing session
organization?: string;
code: string;
isInvite: boolean;
authRequestId?: string;
};
export async function sendVerification(command: VerifyUserByEmailCommand) {
const verifyResponse = command.isInvite
? await verifyInviteCode(command.userId, command.code).catch(() => {
return { error: "Could not verify invite" };
})
: await verifyEmail(command.userId, command.code).catch(() => {
return { error: "Could not verify email" };
});
if ("error" in verifyResponse) {
return verifyResponse;
}
if (!verifyResponse) {
return { error: "Could not verify" };
}
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({
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(session?.factors?.user?.id);
if (!userResponse?.user) {
return { error: "Could not load user" };
}
user = userResponse.user;
} else {
const userResponse = await getUserByID(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,
undefined,
command.authRequestId,
);
}
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(user.details?.resourceOwner);
const authMethodResponse = await listAuthenticationMethodTypes(user.userId);
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}` };
}
// redirect to mfa factor if user has one, or redirect to set one up
const mfaFactorCheck = checkMFAFactors(
session,
loginSettings,
authMethodResponse.authMethodTypes,
command.organization,
command.authRequestId,
);
if (mfaFactorCheck?.redirect) {
return mfaFactorCheck;
}
// login user if no additional steps are required
if (command.authRequestId && session.id) {
const nextUrl = await getNextUrl(
{
sessionId: session.id,
authRequestId: command.authRequestId,
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 };
}
type resendVerifyEmailCommand = {
userId: string;
isInvite: boolean;
authRequestId?: string;
};
export async function resendVerification(command: resendVerifyEmailCommand) {
const host = (await headers()).get("host");
return command.isInvite
? resendInviteCode(command.userId)
: resendEmailCode(command.userId, host, command.authRequestId);
}
type sendEmailCommand = {
userId: string;
authRequestId?: string;
};
export async function sendEmailCode(command: sendEmailCommand) {
const host = (await headers()).get("host");
return zitadelSendEmailCode(command.userId, host, command.authRequestId);
}
export type SendVerificationRedirectWithoutCheckCommand = {
organization?: string;
authRequestId?: string;
} & (
| { userId: string; loginName?: never }
| { userId?: never; loginName: string }
);
export async function sendVerificationRedirectWithoutCheck(
command: SendVerificationRedirectWithoutCheckCommand,
) {
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({
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(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(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,
undefined,
command.authRequestId,
);
}
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(user.userId);
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(user.details?.resourceOwner);
// redirect to mfa factor if user has one, or redirect to set one up
const mfaFactorCheck = checkMFAFactors(
session,
loginSettings,
authMethodResponse.authMethodTypes,
command.organization,
command.authRequestId,
);
if (mfaFactorCheck?.redirect) {
return mfaFactorCheck;
}
// login user if no additional steps are required
if (command.authRequestId && session.id) {
const nextUrl = await getNextUrl(
{
sessionId: session.id,
authRequestId: command.authRequestId,
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

@@ -0,0 +1,204 @@
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
export function checkPasswordChangeRequired(
session: Session,
humanUser: HumanUser | undefined,
organization?: string,
authRequestId?: string,
) {
if (humanUser?.passwordChangeRequired) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
});
if (organization || session.factors?.user?.organizationId) {
params.append(
"organization",
session.factors?.user?.organizationId as string,
);
}
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
return { redirect: "/password/change?" + params };
}
}
export function checkInvite(
session: Session,
humanUser?: HumanUser,
organization?: string,
authRequestId?: string,
) {
if (!humanUser?.email?.isVerified) {
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
});
if (organization || session.factors?.user?.organizationId) {
paramsVerify.append(
"organization",
organization ?? (session.factors?.user?.organizationId as string),
);
}
if (authRequestId) {
paramsVerify.append("authRequestId", authRequestId);
}
return { redirect: "/verify?" + paramsVerify };
}
}
export function checkEmailVerification(
session: Session,
humanUser?: HumanUser,
organization?: string,
authRequestId?: string,
) {
if (
!humanUser?.email?.isVerified &&
process.env.EMAIL_VERIFICATION === "true"
) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization || session.factors?.user?.organizationId) {
params.append(
"organization",
organization ?? (session.factors?.user?.organizationId as string),
);
}
return { redirect: `/verify?` + params };
}
}
export function checkMFAFactors(
session: Session,
loginSettings: LoginSettings | undefined,
authMethods: AuthenticationMethodType[],
organization?: string,
authRequestId?: string,
) {
const availableMultiFactors = authMethods?.filter(
(m: AuthenticationMethodType) =>
m !== AuthenticationMethodType.PASSWORD &&
m !== AuthenticationMethodType.PASSKEY,
);
const hasAuthenticatedWithPasskey =
session.factors?.webAuthN?.verifiedAt &&
session.factors?.webAuthN?.userVerified;
// escape further checks if user has authenticated with passkey
if (hasAuthenticatedWithPasskey) {
return;
}
// if user has not authenticated with passkey and has only one additional mfa factor, redirect to that
if (availableMultiFactors?.length == 1) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization || session.factors?.user?.organizationId) {
params.append(
"organization",
organization ?? (session.factors?.user?.organizationId as string),
);
}
const factor = availableMultiFactors[0];
// if passwordless is other method, but user selected password as alternative, perform a login
if (factor === AuthenticationMethodType.TOTP) {
return { redirect: `/otp/time-based?` + params };
} else if (factor === AuthenticationMethodType.OTP_SMS) {
return { redirect: `/otp/sms?` + params };
} else if (factor === AuthenticationMethodType.OTP_EMAIL) {
return { redirect: `/otp/email?` + params };
} else if (factor === AuthenticationMethodType.U2F) {
return { redirect: `/u2f?` + params };
}
} else if (availableMultiFactors?.length > 1) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization || session.factors?.user?.organizationId) {
params.append(
"organization",
organization ?? (session.factors?.user?.organizationId as string),
);
}
return { redirect: `/mfa?` + params };
} else if (
(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) &&
!availableMultiFactors.length
) {
const params = new URLSearchParams({
loginName: session.factors?.user?.loginName as string,
force: "true", // this defines if the mfa is forced in the settings
checkAfter: "true", // this defines if the check is directly made after the setup
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization || session.factors?.user?.organizationId) {
params.append(
"organization",
organization ?? (session.factors?.user?.organizationId as string),
);
}
// TODO: provide a way to setup passkeys on mfa page?
return { redirect: `/mfa/set?` + params };
}
// TODO: implement passkey setup
// else if (
// submitted.factors &&
// !submitted.factors.webAuthN && // if session was not verified with a passkey
// promptPasswordless && // if explicitly prompted due policy
// !isAlternative // escaped if password was used as an alternative method
// ) {
// const params = new URLSearchParams({
// loginName: submitted.factors.user.loginName,
// prompt: "true",
// });
// if (authRequestId) {
// params.append("authRequestId", authRequestId);
// }
// if (organization) {
// params.append("organization", organization);
// }
// return router.push(`/passkey/set?` + params);
// }
}

View File

@@ -12,7 +12,10 @@ import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_p
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import {
AddHumanUserRequest,
ResendEmailCodeRequest,
ResendEmailCodeRequestSchema,
RetrieveIdentityProviderIntentRequest,
SendEmailCodeRequestSchema,
SetPasswordRequest,
SetPasswordRequestSchema,
VerifyPasskeyRegistrationRequest,
@@ -23,6 +26,7 @@ import { create, Duration } from "@zitadel/client";
import { TextQueryMethod } from "@zitadel/proto/zitadel/object/v2/object_pb";
import { CreateCallbackRequest } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
import { SendEmailVerificationCodeSchema } from "@zitadel/proto/zitadel/user/v2/email_pb";
import type { RedirectURLsJson } from "@zitadel/proto/zitadel/user/v2/idp_pb";
import {
NotificationType,
@@ -270,6 +274,32 @@ export async function resendInviteCode(userId: string) {
return userService.resendInviteCode({ userId }, {});
}
export async function sendEmailCode(
userId: string,
host: string | null,
authRequestId?: string,
) {
let medium = create(SendEmailCodeRequestSchema, {
userId,
});
if (host) {
medium = create(SendEmailCodeRequestSchema, {
...medium,
verification: {
case: "sendCode",
value: create(SendEmailVerificationCodeSchema, {
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` +
(authRequestId ? `&authRequestId=${authRequestId}` : ""),
}),
},
});
}
return userService.sendEmailCode(medium, {});
}
export async function createInviteCode(userId: string, host: string | null) {
let medium = create(SendInviteCodeSchema, {
applicationName: "Typescript Login",
@@ -448,13 +478,26 @@ export async function verifyEmail(userId: string, verificationCode: string) {
);
}
export async function resendEmailCode(userId: string) {
return userService.resendEmailCode(
{
userId,
},
{},
);
export async function resendEmailCode(
userId: string,
host: string | null,
authRequestId?: string,
) {
let request: ResendEmailCodeRequest = create(ResendEmailCodeRequestSchema, {
userId,
});
if (host) {
const medium = create(SendEmailVerificationCodeSchema, {
urlTemplate:
`${host.includes("localhost") ? "http://" : "https://"}${host}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
(authRequestId ? `&authRequestId=${authRequestId}` : ""),
});
request = { ...request, verification: { case: "sendCode", value: medium } };
}
return userService.resendEmailCode(request, {});
}
export function retrieveIDPIntent(id: string, token: string) {

View File

@@ -3,5 +3,6 @@ export { NewAuthorizationBearerInterceptor } from "./interceptors";
// TODO: Move this to `./protobuf.ts` and export it from there
export { create, fromJson, toJson } from "@bufbuild/protobuf";
export type { JsonObject } from "@bufbuild/protobuf";
export { TimestampSchema, timestampDate, timestampFromDate, timestampFromMs, timestampMs } from "@bufbuild/protobuf/wkt";
export type { Duration, Timestamp } from "@bufbuild/protobuf/wkt";

View File

@@ -2,7 +2,6 @@
"name": "@zitadel/tsconfig",
"version": "0.0.0",
"private": true,
"type": "module",
"license": "MIT",
"publishConfig": {
"access": "public"

View File

@@ -12,7 +12,7 @@
"ZITADEL_SYSTEM_API_KEY",
"ZITADEL_ISSUER",
"ZITADEL_ADMIN_TOKEN",
"CACHE_REVALIDATION_INTERVAL_IN_SECONDS",
"EMAIL_VERIFICATION",
"VERCEL_URL"
],
"tasks": {