diff --git a/acceptance/tests/loginname-screen.ts b/acceptance/tests/loginname-screen.ts index 0a7e247f9f..be41a28eda 100644 --- a/acceptance/tests/loginname-screen.ts +++ b/acceptance/tests/loginname-screen.ts @@ -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"); } diff --git a/apps/login/.env.integration b/apps/login/.env.integration index 03bff984d9..90adb84eee 100644 --- a/apps/login/.env.integration +++ b/apps/login/.env.integration @@ -1,3 +1,3 @@ ZITADEL_API_URL=http://localhost:22222 -CACHE_REVALIDATION_INTERVAL_IN_SECONDS=3600 +EMAIL_VERIFICATION=true DEBUG=true \ No newline at end of file diff --git a/apps/login/cypress/integration/invite.cy.ts b/apps/login/cypress/integration/invite.cy.ts new file mode 100644 index 0000000000..3014f5a2e5 --- /dev/null +++ b/apps/login/cypress/integration/invite.cy.ts @@ -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 }); + }); +}); diff --git a/apps/login/cypress/integration/login.cy.ts b/apps/login/cypress/integration/login.cy.ts index a03863a9ce..3e74c0f7fe 100644 --- a/apps/login/cypress/integration/login.cy.ts +++ b/apps/login/cypress/integration/login.cy.ts @@ -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"); diff --git a/apps/login/cypress/integration/verify.cy.ts b/apps/login/cypress/integration/verify.cy.ts index 7fe4f4afb6..464bf02e59 100644 --- a/apps/login/cypress/integration/verify.cy.ts +++ b/apps/login/cypress/integration/verify.cy.ts @@ -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 }); }); }); diff --git a/apps/login/mock/initial-stubs/zitadel.settings.v2.SettingsService.json b/apps/login/mock/initial-stubs/zitadel.settings.v2.SettingsService.json index f62b5da077..07e9980f9b 100644 --- a/apps/login/mock/initial-stubs/zitadel.settings.v2.SettingsService.json +++ b/apps/login/mock/initial-stubs/zitadel.settings.v2.SettingsService.json @@ -3,13 +3,7 @@ "service": "zitadel.settings.v2.SettingsService", "method": "GetBrandingSettings", "out": { - "data": { - "settings": { - "darkTheme": { - "backgroundColor": "#ff0000" - } - } - } + "data": {} } }, { diff --git a/apps/login/package.json b/apps/login/package.json index e07d1601f8..c4031458ca 100644 --- a/apps/login/package.json +++ b/apps/login/package.json @@ -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", diff --git a/apps/login/src/app/(login)/accounts/page.tsx b/apps/login/src/app/(login)/accounts/page.tsx index 9d2e6a0b9f..49fbad6202 100644 --- a/apps/login/src/app/(login)/accounts/page.tsx +++ b/apps/login/src/app/(login)/accounts/page.tsx @@ -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 (
@@ -57,16 +67,7 @@ export default async function Page(props: {
- +
diff --git a/apps/login/src/app/error.tsx b/apps/login/src/app/(login)/error.tsx similarity index 100% rename from apps/login/src/app/error.tsx rename to apps/login/src/app/(login)/error.tsx diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index fc11f45921..128623963b 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -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 }) { 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 }) { } } + 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 }) {

{t("verify.title")}

{t("verify.description")}

- {!userId && ( + {!id && ( <>

{t("verify.title")}

{t("verify.description")}

@@ -77,29 +111,44 @@ export default async function Page(props: { searchParams: Promise }) { )} - {user && ( + {sessionFactors ? ( + loginName={loginName ?? sessionFactors.factors?.user?.loginName} + displayName={sessionFactors.factors?.user?.displayName} + showDropdown + searchParams={searchParams} + > + ) : ( + user && ( + + ) )} - {human?.email?.isVerified ? ( - - ) : ( - // check if auth methods are set - - )} + {id && + (human?.email?.isVerified ? ( + // show page for already verified users + + ) : ( + // check if auth methods are set + + ))}
); diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index bbeda736a9..e96326518f 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -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()); } diff --git a/apps/login/src/components/idp-signin.tsx b/apps/login/src/components/idp-signin.tsx index 543cd64b2c..c2f3fe40b3 100644 --- a/apps/login/src/components/idp-signin.tsx +++ b/apps/login/src/components/idp-signin.tsx @@ -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, diff --git a/apps/login/src/components/login-passkey.tsx b/apps/login/src/components/login-passkey.tsx index 2f1cd53363..5e05cdb6a8 100644 --- a/apps/login/src/components/login-passkey.tsx +++ b/apps/login/src/components/login-passkey.tsx @@ -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); diff --git a/apps/login/src/components/register-passkey.tsx b/apps/login/src/components/register-passkey.tsx index 9a71830261..e737168678 100644 --- a/apps/login/src/components/register-passkey.tsx +++ b/apps/login/src/components/register-passkey.tsx @@ -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, diff --git a/apps/login/src/components/session-item.tsx b/apps/login/src/components/session-item.tsx index 449f7dc955..c3c28a03fd 100644 --- a/apps/login/src/components/session-item.tsx +++ b/apps/login/src/components/session-item.tsx @@ -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; } diff --git a/apps/login/src/components/username-form.tsx b/apps/login/src/components/username-form.tsx index 7d18623aba..887c454333 100644 --- a/apps/login/src/components/username-form.tsx +++ b/apps/login/src/components/username-form.tsx @@ -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; } diff --git a/apps/login/src/components/verify-email-form.tsx b/apps/login/src/components/verify-email-form.tsx deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index 003b261b02..6b6189297e 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -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); } }, diff --git a/apps/login/src/components/verify-redirect-button.tsx b/apps/login/src/components/verify-redirect-button.tsx index 4fe313cd1d..552e787ebc 100644 --- a/apps/login/src/components/verify-redirect-button.tsx +++ b/apps/login/src/components/verify-redirect-button.tsx @@ -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(""); @@ -26,12 +33,26 @@ export function VerifyRedirectButton({ async function submitAndContinue(): Promise { 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(() => { diff --git a/apps/login/src/lib/server/email.ts b/apps/login/src/lib/server/email.ts deleted file mode 100644 index 1f16d86256..0000000000 --- a/apps/login/src/lib/server/email.ts +++ /dev/null @@ -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}` }; - } -} diff --git a/apps/login/src/lib/server/idp.ts b/apps/login/src/lib/server/idp.ts index ebb755987e..b48f796160 100644 --- a/apps/login/src/lib/server/idp.ts +++ b/apps/login/src/lib/server/idp.ts @@ -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 }; + } +} diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index 295f9b455f..f33e3577d0 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -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" }; } diff --git a/apps/login/src/lib/server/passkeys.ts b/apps/login/src/lib/server/passkeys.ts index 181962cae1..c21076265c 100644 --- a/apps/login/src/lib/server/passkeys.ts +++ b/apps/login/src/lib/server/passkeys.ts @@ -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 }; +} diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index 76bb1f4482..3b7a24a718 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -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, diff --git a/apps/login/src/lib/server/register.ts b/apps/login/src/lib/server/register.ts index 01ddb0d8c8..284689523a 100644 --- a/apps/login/src/lib/server/register.ts +++ b/apps/login/src/lib/server/register.ts @@ -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 ? { diff --git a/apps/login/src/lib/server/session.ts b/apps/login/src/lib/server/session.ts index 69aac95a10..70bc18f6d5 100644 --- a/apps/login/src/lib/server/session.ts +++ b/apps/login/src/lib/server/session.ts @@ -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 diff --git a/apps/login/src/lib/server/verify.ts b/apps/login/src/lib/server/verify.ts new file mode 100644 index 0000000000..2c0c78272a --- /dev/null +++ b/apps/login/src/lib/server/verify.ts @@ -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 }; +} diff --git a/apps/login/src/lib/verify-helper.ts b/apps/login/src/lib/verify-helper.ts new file mode 100644 index 0000000000..b37287a959 --- /dev/null +++ b/apps/login/src/lib/verify-helper.ts @@ -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); + // } +} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 687d277fc3..f415c8fb5c 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -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) { diff --git a/packages/zitadel-client/src/index.ts b/packages/zitadel-client/src/index.ts index 7cf14163bf..64c3af5050 100644 --- a/packages/zitadel-client/src/index.ts +++ b/packages/zitadel-client/src/index.ts @@ -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"; diff --git a/packages/zitadel-tsconfig/package.json b/packages/zitadel-tsconfig/package.json index 238d7fbd70..b20542f468 100644 --- a/packages/zitadel-tsconfig/package.json +++ b/packages/zitadel-tsconfig/package.json @@ -2,7 +2,6 @@ "name": "@zitadel/tsconfig", "version": "0.0.0", "private": true, - "type": "module", "license": "MIT", "publishConfig": { "access": "public" diff --git a/turbo.json b/turbo.json index a98ff8726d..2817c8c157 100644 --- a/turbo.json +++ b/turbo.json @@ -12,7 +12,7 @@ "ZITADEL_SYSTEM_API_KEY", "ZITADEL_ISSUER", "ZITADEL_ADMIN_TOKEN", - "CACHE_REVALIDATION_INTERVAL_IN_SECONDS", + "EMAIL_VERIFICATION", "VERCEL_URL" ], "tasks": {