mirror of
https://github.com/zitadel/zitadel.git
synced 2025-11-01 00:46:23 +00:00
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:
committed by
Livio Spring
parent
d9a4ae114e
commit
e114c3d670
9145
apps/login/pnpm-lock.yaml
generated
9145
apps/login/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -687,7 +687,7 @@ export async function searchUsers({ serviceUrl, searchValue, loginSettings, orga
|
||||
}
|
||||
|
||||
if (organizationId) {
|
||||
queries.push(
|
||||
emailAndPhoneQueries.push(
|
||||
create(SearchQuerySchema, {
|
||||
query: {
|
||||
case: "organizationIdQuery",
|
||||
|
||||
Reference in New Issue
Block a user