fix(login): Organization domain scope, Support for External Passkey Registration (#10729)

Closes #10727
Closes #10577

# Which Problems Are Solved

This PR fixes the organization domain scope when provided and introduces
a deep-link feature for external applications, that sends users directly
into passkey registration flow using either session-based or sessionless
flows. Previously, the `/passkey/set` page only supported session-based
registration, limiting external application integration scenarios.

The `/passkey/set` page now supports:
- `code` search parameter for automatic passkey registration
- `userId` parameter for sessionless flows (similar to `/verify` and
`/password/set` pages)
- Auto-submit functionality when verification codes are provided

# How the Problems Are Solved

The organization scope is fixed by the backend handler for OIDC flows,
now correctly submitting a `suffix` queryparam to the /loginname url
which is used to show in the input field.

The passkey code support is implemented by support multiple integration
patterns:
- **Session-based**: `/passkey/set?sessionId=123&code=abc123` (existing
flow)
- **Sessionless**: `/passkey/set?userId=123456&code=abc123` (new flow)

External Application Integration Flow
1. External app triggers passkey register and obtains code
2. User verification link containing `userId`, `code` and `id`
parameters
3. User clicks link → `/passkey/set?userId=123&code=abc&id=123`
4. Page loads user information using `userId` parameter
5. Auto-submit triggers passkey registration when `code` and `id` is
present
6. User completes WebAuthn request
7. Passkey is registered and user continues authentication flow

This enables external applications to seamlessly integrate passkey
registration into their user onboarding

(cherry picked from commit 28db24fa67)
This commit is contained in:
Max Peintner
2025-09-30 17:58:32 +02:00
committed by Livio Spring
parent d9a4ae114e
commit e114c3d670
9 changed files with 319 additions and 9310 deletions

9145
apps/login/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,39 +5,57 @@ import { Translated } from "@/components/translated";
import { UserAvatar } from "@/components/user-avatar";
import { getServiceUrlFromHeaders } from "@/lib/service-url";
import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings } from "@/lib/zitadel";
import { getBrandingSettings, getUserByID } from "@/lib/zitadel";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { HumanUser, User } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("passkey");
return { title: t('set.title')};
return { title: t("set.title") };
}
export default async function Page(props: {
searchParams: Promise<Record<string | number | symbol, string | undefined>>;
}) {
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
const searchParams = await props.searchParams;
const { loginName, prompt, organization, requestId } = searchParams;
const { userId, loginName, prompt, organization, requestId, code, id } = searchParams;
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const session = await loadMostRecentSession({
serviceUrl,
sessionParams: {
loginName,
organization,
},
});
// also allow no session to be found for userId-based flows
let session: Session | undefined;
if (loginName) {
session = await loadMostRecentSession({
serviceUrl,
sessionParams: {
loginName,
organization,
},
});
}
const branding = await getBrandingSettings({
serviceUrl,
organization,
});
let user: User | undefined;
let displayName: string | undefined;
if (userId) {
const userResponse = await getUserByID({
serviceUrl,
userId,
});
user = userResponse.user;
if (user?.type.case === "human") {
displayName = (user.type.value as HumanUser).profile?.displayName;
}
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
@@ -45,14 +63,21 @@ export default async function Page(props: {
<Translated i18nKey="set.title" namespace="passkey" />
</h1>
{session && (
{session ? (
<UserAvatar
loginName={loginName ?? session.factors?.user?.loginName}
displayName={session.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
) : user ? (
<UserAvatar
loginName={user?.preferredLoginName}
displayName={displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
) : null}
<p className="ztdl-p mb-6 block">
<Translated i18nKey="set.description" namespace="passkey" />
</p>
@@ -70,7 +95,7 @@ export default async function Page(props: {
</span>
</Alert>
{!session && (
{!session && !user && (
<div className="py-4">
<Alert>
<Translated i18nKey="unknownContext" namespace="error" />
@@ -78,12 +103,15 @@ export default async function Page(props: {
</div>
)}
{session?.id && (
{(session?.id || userId) && (
<RegisterPasskey
sessionId={session.id}
sessionId={session?.id}
userId={userId}
isPrompt={!!prompt}
organization={organization}
requestId={requestId}
code={code}
codeId={id}
/>
)}
</div>

View File

@@ -1,12 +1,9 @@
"use client";
import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
import {
registerPasskeyLink,
verifyPasskeyRegistration,
} from "@/lib/server/passkeys";
import { registerPasskeyLink, verifyPasskeyRegistration } from "@/lib/server/passkeys";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { useForm } from "react-hook-form";
import { Alert } from "./alert";
import { BackButton } from "./back-button";
@@ -17,18 +14,16 @@ import { Translated } from "./translated";
type Inputs = {};
type Props = {
sessionId: string;
sessionId?: string;
userId?: string;
isPrompt: boolean;
requestId?: string;
organization?: string;
code?: string;
codeId?: string;
};
export function RegisterPasskey({
sessionId,
isPrompt,
organization,
requestId,
}: Props) {
export function RegisterPasskey({ sessionId, userId, isPrompt, organization, requestId, code, codeId }: Props) {
const { handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
});
@@ -43,14 +38,16 @@ export function RegisterPasskey({
passkeyId: string,
passkeyName: string,
publicKeyCredential: any,
sessionId: string,
currentSessionId?: string,
currentUserId?: string,
) {
setLoading(true);
const response = await verifyPasskeyRegistration({
passkeyId,
passkeyName,
publicKeyCredential,
sessionId,
sessionId: currentSessionId,
userId: currentUserId,
})
.catch(() => {
setError("Could not verify Passkey");
@@ -63,11 +60,28 @@ export function RegisterPasskey({
return response;
}
async function submitRegisterAndContinue(): Promise<boolean | void> {
const submitRegisterAndContinue = useCallback(async (): Promise<boolean | void> => {
// Require either sessionId or userId
if (!sessionId && !userId) {
setError("Missing session or user information");
return;
}
setLoading(true);
const resp = await registerPasskeyLink({
sessionId,
})
let regReq;
if (sessionId) {
regReq = { sessionId };
} else if (userId && code && codeId) {
regReq = { userId, code, codeId };
} else {
setError("Missing code for user-based registration");
setLoading(false);
return;
}
const resp = await registerPasskeyLink(regReq)
.catch(() => {
setError("Could not register passkey");
return;
@@ -92,29 +106,18 @@ export function RegisterPasskey({
}
const passkeyId = resp.passkeyId;
const options: CredentialCreationOptions =
(resp.publicKeyCredentialCreationOptions as CredentialCreationOptions) ??
{};
const options: CredentialCreationOptions = (resp.publicKeyCredentialCreationOptions as CredentialCreationOptions) ?? {};
if (!options.publicKey) {
setError("An error on registering passkey");
return;
}
options.publicKey.challenge = coerceToArrayBuffer(
options.publicKey.challenge,
"challenge",
);
options.publicKey.user.id = coerceToArrayBuffer(
options.publicKey.user.id,
"userid",
);
options.publicKey.challenge = coerceToArrayBuffer(options.publicKey.challenge, "challenge");
options.publicKey.user.id = coerceToArrayBuffer(options.publicKey.user.id, "userid");
if (options.publicKey.excludeCredentials) {
options.publicKey.excludeCredentials.map((cred: any) => {
cred.id = coerceToArrayBuffer(
cred.id as string,
"excludeCredentials.id",
);
cred.id = coerceToArrayBuffer(cred.id as string, "excludeCredentials.id");
return cred;
});
}
@@ -140,20 +143,12 @@ export function RegisterPasskey({
rawId: coerceToBase64Url(rawId, "rawId"),
type: credentials.type,
response: {
attestationObject: coerceToBase64Url(
attestationObject,
"attestationObject",
),
attestationObject: coerceToBase64Url(attestationObject, "attestationObject"),
clientDataJSON: coerceToBase64Url(clientDataJSON, "clientDataJSON"),
},
};
const verificationResponse = await submitVerify(
passkeyId,
"",
data,
sessionId,
);
const verificationResponse = await submitVerify(passkeyId, "", data, sessionId, userId);
if (!verificationResponse) {
setError("Could not verify Passkey!");
@@ -161,7 +156,14 @@ export function RegisterPasskey({
}
continueAndLogin();
}
}, [sessionId, userId, code]);
// Auto-submit when code is provided (similar to VerifyForm)
useEffect(() => {
if (code) {
submitRegisterAndContinue();
}
}, [code, submitRegisterAndContinue]);
function continueAndLogin() {
const params = new URLSearchParams();
@@ -174,7 +176,13 @@ export function RegisterPasskey({
params.set("requestId", requestId);
}
params.set("sessionId", sessionId);
if (sessionId) {
params.set("sessionId", sessionId);
}
if (userId) {
params.set("userId", userId);
}
router.push("/passkey?" + params);
}
@@ -211,8 +219,7 @@ export function RegisterPasskey({
onClick={handleSubmit(submitRegisterAndContinue)}
data-testid="submit-button"
>
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
<Translated i18nKey="set.submit" namespace="passkey" />
{loading && <Spinner className="mr-2 h-5 w-5" />} <Translated i18nKey="set.submit" namespace="passkey" />
</Button>
</div>
</form>

View File

@@ -23,15 +23,15 @@ type SessionCookie<T> = Cookie & T;
async function setSessionHttpOnlyCookie<T>(sessions: SessionCookie<T>[], iFrameEnabled: boolean = false) {
const cookiesList = await cookies();
// Use "none" for iframe compatibility, otherwise "strict" as default
// "none" is required for iframe embedding (with secure flag)
let resolvedSameSite: "lax" | "strict" | "none";
if (iFrameEnabled) {
// When embedded in iframe, must use "none" with secure flag
resolvedSameSite = "none";
} else {
// Production and other environments: use strict for better security
resolvedSameSite = "strict";
// This allows cookies during top-level navigation while blocking cross-origin requests
resolvedSameSite = "lax";
}
return cookiesList.set({
@@ -273,6 +273,7 @@ export async function getAllSessions<T>(cleanup: boolean = false): Promise<Sessi
return sessions;
}
} else {
console.log("getAllSessions: No session cookie found, returning empty array");
return [];
}
}

View File

@@ -14,10 +14,7 @@ import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
import { idpTypeToSlug } from "@/lib/idp";
import { create } from "@zitadel/client";
import { Prompt } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb";
import {
CreateCallbackRequestSchema,
SessionSchema,
} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { CreateCallbackRequestSchema, SessionSchema } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
@@ -62,7 +59,7 @@ export interface FlowInitiationParams {
*/
export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Promise<NextResponse> {
const { serviceUrl, requestId, sessions, sessionCookies, request } = params;
const { authRequest } = await getAuthRequest({
serviceUrl,
authRequestId: requestId.replace("oidc_", ""),
@@ -85,11 +82,14 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
if (orgDomainScope) {
const matched = ORG_DOMAIN_SCOPE_REGEX.exec(orgDomainScope);
const orgDomain = matched?.[1] ?? "";
console.log("Extracted org domain:", orgDomain);
if (orgDomain) {
const orgs = await getOrgsByDomain({
serviceUrl,
domain: orgDomain,
});
if (orgs.result && orgs.result.length === 1) {
organization = orgs.result[0].id ?? "";
suffix = orgDomain;
@@ -347,6 +347,10 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
loginNameUrl.searchParams.append("organization", organization);
}
if (suffix) {
loginNameUrl.searchParams.append("suffix", suffix);
}
return NextResponse.redirect(loginNameUrl);
}
}
@@ -356,7 +360,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr
*/
export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Promise<NextResponse> {
const { serviceUrl, requestId, sessions, sessionCookies, request } = params;
const { samlRequest } = await getSAMLRequest({
serviceUrl,
samlRequestId: requestId.replace("saml_", ""),
@@ -416,7 +420,7 @@ export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Pr
},
}),
});
if (url && binding.case === "redirect") {
return NextResponse.redirect(url);
} else if (url && binding.case === "post") {
@@ -447,4 +451,4 @@ export async function handleSAMLFlowInitiation(params: FlowInitiationParams): Pr
request,
requestId,
});
}
}

View File

@@ -11,29 +11,33 @@ import {
} from "@/lib/zitadel";
import { create, Duration, Timestamp, timestampDate } from "@zitadel/client";
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { Checks, ChecksSchema, GetSessionResponse } 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 { completeFlowOrGetUrl } from "../client";
import { getMostRecentSessionCookie, getSessionCookieById, getSessionCookieByLoginName } from "../cookies";
import { getServiceUrlFromHeaders } from "../service-url";
import { checkEmailVerification, checkUserVerification } from "../verify-helper";
import { setSessionAndUpdateCookie } from "./cookie";
import { createSessionAndUpdateCookie, setSessionAndUpdateCookie } from "./cookie";
import { getOriginalHost } from "./host";
import { completeFlowOrGetUrl } from "../client";
type VerifyPasskeyCommand = {
passkeyId: string;
passkeyName?: string;
publicKeyCredential: any;
sessionId: string;
sessionId?: string;
userId?: string;
};
type RegisterPasskeyCommand = {
sessionId: string;
sessionId?: string;
userId?: string;
code?: string;
codeId?: string;
};
function isSessionValid(session: Partial<Session>): {
@@ -53,44 +57,110 @@ function isSessionValid(session: Partial<Session>): {
export async function registerPasskeyLink(
command: RegisterPasskeyCommand,
): Promise<RegisterPasskeyResponse | { error: string }> {
const { sessionId } = command;
if (!command.sessionId && !command.userId) {
return { error: "Either sessionId or userId must be provided" };
}
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const host = await getOriginalHost();
const sessionCookie = await getSessionCookieById({ sessionId });
const session = await getSession({
serviceUrl,
sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
});
let session: GetSessionResponse | undefined;
let createdSession: Session | undefined;
let currentUserId: string | undefined = undefined;
let registerCode: { id: string; code: string } | undefined = undefined;
if (!session?.session?.factors?.user?.id) {
return { error: "Could not determine user from session" };
}
const sessionValid = isSessionValid(session.session);
if (!sessionValid) {
const authmethods = await listAuthenticationMethodTypes({
if (command.sessionId) {
// Session-based flow (existing logic)
const sessionCookie = await getSessionCookieById({ sessionId: command.sessionId });
session = await getSession({
serviceUrl,
userId: session.session.factors.user.id,
sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
});
// if the user has no authmethods set, we need to check if the user was verified
if (authmethods.authMethodTypes.length !== 0) {
return {
error: "You have to authenticate or have a valid User Verification Check",
};
if (!session?.session?.factors?.user?.id) {
return { error: "Could not determine user from session" };
}
// check if a verification was done earlier
const hasValidUserVerificationCheck = await checkUserVerification(session.session.factors.user.id);
currentUserId = session.session.factors.user.id;
if (!hasValidUserVerificationCheck) {
return { error: "User Verification Check has to be done" };
const sessionValid = isSessionValid(session.session);
if (!sessionValid.valid) {
const authmethods = await listAuthenticationMethodTypes({
serviceUrl,
userId: currentUserId,
});
// if the user has no authmethods set, we need to check if the user was verified
if (authmethods.authMethodTypes.length !== 0) {
return {
error: "You have to authenticate or have a valid User Verification Check",
};
}
// check if a verification was done earlier
const hasValidUserVerificationCheck = await checkUserVerification(currentUserId);
console.log("hasValidUserVerificationCheck", hasValidUserVerificationCheck);
if (!hasValidUserVerificationCheck) {
return { error: "User Verification Check has to be done" };
}
if (!command.code) {
// request a new code if no code is provided
const codeResponse = await createPasskeyRegistrationLink({
serviceUrl,
userId: currentUserId,
});
if (!codeResponse?.code?.code) {
return { error: "Could not create registration link" };
}
registerCode = codeResponse.code;
}
}
} else if (command.userId && command.code && command.codeId) {
currentUserId = command.userId;
registerCode = {
id: command.codeId,
code: command.code,
};
// Check if user exists
const userResponse = await getUserByID({
serviceUrl,
userId: currentUserId,
});
if (!userResponse || !userResponse.user) {
return { error: "User not found" };
}
// Create a session for the user to continue the flow after passkey registration
const checks = create(ChecksSchema, {
user: {
search: {
case: "loginName",
value: userResponse.user.preferredLoginName,
},
},
});
createdSession = await createSessionAndUpdateCookie({
checks,
requestId: undefined, // No requestId in passkey registration context, TODO: consider if needed
});
if (!createdSession) {
return { error: "Could not create session" };
}
}
if (!registerCode) {
throw new Error("Missing code in response");
}
const [hostname] = host.split(":");
@@ -99,27 +169,14 @@ export async function registerPasskeyLink(
throw new Error("Could not get hostname");
}
const userId = session?.session?.factors?.user?.id;
if (!userId) {
throw new Error("Could not get session");
}
// TODO: add org context
// use session token to add the passkey
const registerLink = await createPasskeyRegistrationLink({
serviceUrl,
userId,
});
if (!registerLink.code) {
throw new Error("Missing code in response");
if (!currentUserId) {
throw new Error("Could not determine user");
}
return registerPasskey({
serviceUrl,
userId,
code: registerLink.code,
userId: currentUserId,
code: registerCode,
domain: hostname,
});
}
@@ -128,6 +185,10 @@ export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
if (!command.sessionId && !command.userId) {
throw new Error("Either sessionId or userId must be provided");
}
// if no name is provided, try to generate one from the user agent
let passkeyName = command.passkeyName;
if (!passkeyName) {
@@ -140,18 +201,38 @@ export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) {
}${os.name}${os.name ? ", " : ""}${browser.name}`;
}
const sessionCookie = await getSessionCookieById({
sessionId: command.sessionId,
});
const session = await getSession({
serviceUrl,
sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
});
const userId = session?.session?.factors?.user?.id;
let currentUserId: string;
if (!userId) {
throw new Error("Could not get session");
if (command.sessionId) {
// Session-based flow
const sessionCookie = await getSessionCookieById({
sessionId: command.sessionId,
});
const session = await getSession({
serviceUrl,
sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
});
const userId = session?.session?.factors?.user?.id;
if (!userId) {
throw new Error("Could not get session");
}
currentUserId = userId;
} else {
// UserId-based flow
currentUserId = command.userId!;
// Verify user exists
const userResponse = await getUserByID({
serviceUrl,
userId: currentUserId,
});
if (!userResponse || !userResponse.user) {
throw new Error("User not found");
}
}
return zitadelVerifyPasskeyRegistration({
@@ -160,7 +241,7 @@ export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) {
passkeyId: command.passkeyId,
publicKeyCredential: command.publicKeyCredential,
passkeyName,
userId,
userId: currentUserId,
}),
});
}

View File

@@ -18,7 +18,7 @@ import { createUserServiceClient } from "@zitadel/client/v2";
import { Checks, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb";
import { AuthenticationMethodType, SetPasswordRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { SetPasswordRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import { headers } from "next/headers";
import { completeFlowOrGetUrl } from "../client";
import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies";
@@ -370,13 +370,26 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const sessionCookie = await getSessionCookieById({ sessionId });
let sessionCookie;
try {
sessionCookie = await getSessionCookieById({ sessionId });
} catch (error) {
console.error("Error getting session cookie:", error);
return { error: "Could not load session cookie" };
}
const { session } = await getSession({
serviceUrl,
sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
});
let session;
try {
const sessionResponse = await getSession({
serviceUrl,
sessionId: sessionCookie.id,
sessionToken: sessionCookie.token,
});
session = sessionResponse.session;
} catch (error) {
console.error("Error getting session:", error);
return { error: "Could not load session" };
}
if (!session || !session.factors?.user?.id) {
return { error: "Could not load session" };
@@ -390,40 +403,43 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS
});
// check if the user has no password set in order to set a password
const authmethods = await listAuthenticationMethodTypes({
serviceUrl,
userId: session.factors.user.id,
});
let authmethods;
try {
authmethods = await listAuthenticationMethodTypes({
serviceUrl,
userId: session.factors.user.id,
});
} catch (error) {
console.error("Error getting auth methods:", error);
return { error: "Could not load auth methods" };
}
if (!authmethods) {
return { error: "Could not load auth methods" };
}
const requiredAuthMethodsForForceMFA = [
AuthenticationMethodType.OTP_EMAIL,
AuthenticationMethodType.OTP_SMS,
AuthenticationMethodType.TOTP,
AuthenticationMethodType.U2F,
];
const hasNoMFAMethods = requiredAuthMethodsForForceMFA.every((method) => !authmethods.authMethodTypes.includes(method));
const loginSettings = await getLoginSettings({
serviceUrl,
organization: session.factors.user.organizationId,
});
let loginSettings;
try {
loginSettings = await getLoginSettings({
serviceUrl,
organization: session.factors.user.organizationId,
});
} catch (error) {
console.error("Error getting login settings:", error);
return { error: "Could not load login settings" };
}
const forceMfa = !!(loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly);
// if the user has no MFA but MFA is enforced, we can set a password otherwise we use the token of the user
if (forceMfa && hasNoMFAMethods) {
if (forceMfa) {
console.log("Set password using service account due to enforced MFA without existing MFA methods");
return setPassword({ serviceUrl, payload }).catch((error) => {
// throw error if failed precondition (ex. User is not yet initialized)
if (error.code === 9 && error.message) {
return { error: "Failed precondition" };
} else {
throw error;
}
return { error: "Could not set password" };
});
} else {
const transport = async (serviceUrl: string, token: string) => {
@@ -450,7 +466,7 @@ export async function checkSessionAndSetPassword({ sessionId, password }: CheckS
if (error.code === 7) {
return { error: "Session is not valid." };
}
throw error;
return { error: "Could not set the password" };
});
}
}

View File

@@ -5,10 +5,12 @@ import { addHumanUser, addIDPLink, getLoginSettings, getUserByID, listAuthentica
import { create } from "@zitadel/client";
import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb";
import { ChecksJson, ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
import { headers } from "next/headers";
import { cookies, headers } from "next/headers";
import crypto from "crypto";
import { completeFlowOrGetUrl } from "../client";
import { getServiceUrlFromHeaders } from "../service-url";
import { checkEmailVerification, checkMFAFactors } from "../verify-helper";
import { getOrSetFingerprintId } from "../fingerprint";
type RegisterUserCommand = {
email: string;
@@ -79,6 +81,21 @@ export async function registerUser(command: RegisterUserCommand) {
params.append("requestId", command.requestId);
}
// Set verification cookie for users registering with passkey (no password)
// This allows them to proceed with passkey registration without additional verification
const cookiesList = await cookies();
const userAgentId = await getOrSetFingerprintId();
const verificationCheck = crypto.createHash("sha256").update(`${session.factors.user.id}:${userAgentId}`).digest("hex");
await cookiesList.set({
name: "verificationCheck",
value: verificationCheck,
httpOnly: true,
path: "/",
maxAge: 300, // 5 minutes
});
return { redirect: "/passkey/set?" + params };
} else {
const userResponse = await getUserByID({

View File

@@ -687,7 +687,7 @@ export async function searchUsers({ serviceUrl, searchValue, loginSettings, orga
}
if (organizationId) {
queries.push(
emailAndPhoneQueries.push(
create(SearchQuerySchema, {
query: {
case: "organizationIdQuery",