Files
zitadel/apps/login/src/app/(login)/passkey/set/page.tsx
Max Peintner e114c3d670 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)
2025-10-07 06:25:19 +02:00

121 lines
3.8 KiB
TypeScript

import { Alert, AlertType } from "@/components/alert";
import { DynamicTheme } from "@/components/dynamic-theme";
import { RegisterPasskey } from "@/components/register-passkey";
import { Translated } from "@/components/translated";
import { UserAvatar } from "@/components/user-avatar";
import { getServiceUrlFromHeaders } from "@/lib/service-url";
import { loadMostRecentSession } from "@/lib/session";
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") };
}
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
const searchParams = await props.searchParams;
const { userId, loginName, prompt, organization, requestId, code, id } = searchParams;
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
// 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">
<h1>
<Translated i18nKey="set.title" namespace="passkey" />
</h1>
{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>
<Alert type={AlertType.INFO}>
<span>
<Translated i18nKey="set.info.description" namespace="passkey" />
<a
className="text-primary-light-500 hover:text-primary-light-300 dark:text-primary-dark-500 hover:dark:text-primary-dark-300"
target="_blank"
href="https://zitadel.com/docs/guides/manage/user/reg-create-user#with-passwordless"
>
<Translated i18nKey="set.info.link" namespace="passkey" />
</a>
</span>
</Alert>
{!session && !user && (
<div className="py-4">
<Alert>
<Translated i18nKey="unknownContext" namespace="error" />
</Alert>
</div>
)}
{(session?.id || userId) && (
<RegisterPasskey
sessionId={session?.id}
userId={userId}
isPrompt={!!prompt}
organization={organization}
requestId={requestId}
code={code}
codeId={id}
/>
)}
</div>
</DynamicTheme>
);
}