mirror of
https://github.com/zitadel/zitadel.git
synced 2025-11-03 12:42:17 +00:00
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)
121 lines
3.8 KiB
TypeScript
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>
|
|
);
|
|
}
|