Merge pull request #29 from zitadel/passkey-registration

feat(passkeys): register
This commit is contained in:
Max Peintner
2023-07-04 08:10:47 +02:00
committed by GitHub
36 changed files with 3076 additions and 2031 deletions

View File

@@ -51,7 +51,7 @@ docker compose --file ./acceptance/docker-compose.yaml pull
docker compose --file ./acceptance/docker-compose.yaml run setup
# Configure your shell to use the environment variables written to ./apps/login/.env.acceptance
source ./apps/login/.env.acceptance
export $(cat ./apps/login/.env.acceptance | xargs)
```
### Developing Against Your ZITADEL Cloud Instance

View File

@@ -1,49 +1,54 @@
version: '3.8'
version: "3.8"
services:
zitadel:
user: '${ZITADEL_DEV_UID}'
image: '${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:latest}'
user: "${ZITADEL_DEV_UID}"
image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:latest}"
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml'
ports:
- "8080:8080"
- "8080:8080"
volumes:
- ./machinekey:/machinekey
- ./zitadel.yaml:/zitadel.yaml
depends_on:
db:
condition: 'service_healthy'
condition: "service_healthy"
db:
image: 'cockroachdb/cockroach:v22.2.2'
command: 'start-single-node --insecure --http-addr :9090'
image: "cockroachdb/cockroach:v22.2.2"
command: "start-single-node --insecure --http-addr :9090"
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:9090/health?ready=1']
interval: '10s'
timeout: '30s'
test: ["CMD", "curl", "-f", "http://localhost:9090/health?ready=1"]
interval: "10s"
timeout: "30s"
retries: 5
start_period: '20s'
start_period: "20s"
ports:
- "26257:26257"
- "9090:9090"
wait_for_zitadel:
image: curlimages/curl:8.00.1
command: [ "/bin/sh", "-c", "i=0; while ! curl http://zitadel:8080/debug/ready && [ $$i -lt 30 ]; do sleep 1; i=$$((i+1)); done; [ $$i -eq 30 ] && exit 1 || exit 0" ]
command:
[
"/bin/sh",
"-c",
"i=0; while ! curl http://zitadel:8080/debug/ready && [ $$i -lt 30 ]; do sleep 1; i=$$((i+1)); done; [ $$i -eq 30 ] && exit 1 || exit 0",
]
depends_on:
- zitadel
setup:
user: '${ZITADEL_DEV_UID}'
user: "${ZITADEL_DEV_UID}"
container_name: setup
build: .
environment:
KEY: /key/zitadel-admin-sa.json
SERVICE: http://zitadel:8080
WRITE_ENVIRONMENT_FILE: /apps/login/.env.local
WRITE_ENVIRONMENT_FILE: /apps/login/.env.acceptance
volumes:
- "./machinekey:/key"
- "../apps/login:/apps/login"
- "./machinekey:/key"
- "../apps/login:/apps/login"
depends_on:
wait_for_zitadel:
condition: 'service_completed_successfully'
condition: "service_completed_successfully"

View File

@@ -22,7 +22,6 @@ async function loadSessions(): Promise<Session[]> {
export default async function Page() {
let sessions = await loadSessions();
return (
<div className="flex flex-col items-center space-y-4">
<h1>Accounts</h1>

View File

@@ -10,7 +10,7 @@ export default function Error({ error, reset }: any) {
}, [error]);
return (
<Boundary labels={["Home page Error UI"]} color="red">
<Boundary labels={["Login Error"]} color="red">
<div className="space-y-4">
<div className="text-sm text-red-500 dark:text-red-500">
<strong className="font-bold">Error:</strong> {error?.message}

View File

@@ -0,0 +1,72 @@
import { getSession, server } from "#/lib/zitadel";
import Alert, { AlertType } from "#/ui/Alert";
import RegisterPasskey from "#/ui/RegisterPasskey";
import UserAvatar from "#/ui/UserAvatar";
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { loginName, prompt } = searchParams;
const sessionFactors = await loadSession(loginName);
async function loadSession(loginName?: string) {
const recent = await getMostRecentCookieWithLoginname(loginName);
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session) {
return response.session;
}
});
}
const title = !!prompt
? "Authenticate with a passkey"
: "Use your passkey to confirm it's really you";
const description = !!prompt
? "When set up, you will be able to authenticate without a password."
: "Your device will ask for your fingerprint, face, or screen lock";
return (
<div className="flex flex-col items-center space-y-4">
<h1>{title}</h1>
{sessionFactors && (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
></UserAvatar>
)}
<p className="ztdl-p mb-6 block">{description}</p>
<Alert type={AlertType.INFO}>
<span>
A passkey is an authentication method on a device like your
fingerprint, Apple FaceID or similar.
<a
className="text-primary-light-500 dark:text-primary-dark-500 hover:text-primary-light-300 hover:dark:text-primary-dark-300"
target="_blank"
href="https://zitadel.com/docs/guides/manage/user/reg-create-user#with-passwordless"
>
Passwordless Authentication
</a>
</span>
</Alert>
{!sessionFactors && (
<div className="py-4">
<Alert>
Could not get the context of the user. Make sure to enter the
username first or provide a loginName as searchParam.
</Alert>
</div>
)}
{sessionFactors?.id && (
<RegisterPasskey sessionId={sessionFactors.id} isPrompt={!!prompt} />
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { getSession, server } from "#/lib/zitadel";
import Alert from "#/ui/Alert";
import UserAvatar from "#/ui/UserAvatar";
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { loginName } = searchParams;
const sessionFactors = await loadSession(loginName);
async function loadSession(loginName?: string) {
const recent = await getMostRecentCookieWithLoginname(loginName);
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session) {
return response.session;
}
});
}
return (
<div className="flex flex-col items-center space-y-4">
<h1>Login with Passkey</h1>
<p className="ztdl-p mb-6 block">Authenticate with your passkey device</p>
{!sessionFactors && (
<div className="py-4">
<Alert>
Could not get the context of the user. Make sure to enter the
username first or provide a loginName as searchParam.
</Alert>
</div>
)}
{sessionFactors && (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
></UserAvatar>
)}
</div>
);
}

View File

@@ -1,35 +0,0 @@
"use client";
import { Button, ButtonVariants } from "#/ui/Button";
import { TextInput } from "#/ui/Input";
import UserAvatar from "#/ui/UserAvatar";
import { useRouter } from "next/navigation";
export default function Page() {
const router = useRouter();
return (
<div className="flex flex-col items-center space-y-4">
<h1>Password</h1>
<p className="ztdl-p mb-6 block">Enter your password.</p>
<UserAvatar
showDropdown
displayName="Max Peintner"
loginName="max@zitadel.com"
></UserAvatar>
<div className="w-full">
<TextInput type="password" label="Password" />
</div>
<div className="flex w-full flex-row items-center justify-between">
<Button
onClick={() => router.back()}
variant={ButtonVariants.Secondary}
>
back
</Button>
<Button variant={ButtonVariants.Primary}>continue</Button>
</div>
</div>
);
}

View File

@@ -38,7 +38,7 @@ export default async function Page({
{sessionFactors && (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName ?? ""}
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
></UserAvatar>

View File

@@ -1,133 +0,0 @@
"use client";
import { TextInput } from "#/ui/Input";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { ClientError } from "nice-grpc";
type Props = {
userId?: string;
isMe?: boolean;
userState?: any; // UserState;
};
export default function Page() {
const [passwordLoading, setPasswordLoading] = useState<boolean>(false);
const [policyValid, setPolicyValid] = useState<boolean>(false);
type Inputs = {
password?: string;
newPassword: string;
confirmPassword: string;
};
const { register, handleSubmit, watch, reset, formState } = useForm<Inputs>({
mode: "onChange",
reValidateMode: "onChange",
shouldUseNativeValidation: true,
});
const { errors, isValid } = formState;
const watchNewPassword = watch("newPassword", "");
const watchConfirmPassword = watch("confirmPassword", "");
async function updatePassword(value: Inputs) {
setPasswordLoading(true);
// const authData: UpdateMyPasswordRequest = {
// oldPassword: value.password ?? '',
// newPassword: value.newPassword,
// };
const response = await fetch(
`/api/user/password/me` +
`?${new URLSearchParams({
resend: `false`,
})}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
}
);
if (response.ok) {
setPasswordLoading(false);
// toast('password.set');
// TODO: success info
reset();
} else {
const error = (await response.json()) as ClientError;
// toast.error(error.details);
// TODO: show error
}
setPasswordLoading(false);
}
async function sendHumanResetPasswordNotification(userId: string) {
// const mgmtData: SendHumanResetPasswordNotificationRequest = {
// type: SendHumanResetPasswordNotificationRequest_Type.TYPE_EMAIL,
// userId: userId,
// };
const response = await fetch(`/api/user/password/resetlink/${userId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
if (response.ok) {
// TODO: success info
// toast(t('sendPasswordResetLinkSent'));
} else {
const error = await response.json();
// TODO: show error
// toast.error((error as ClientError).details);
}
}
return (
<>
<h1 className="text-center">Set Password</h1>
<p className="text-center my-4 mb-6 text-14px text-input-light-label dark:text-input-dark-label">
Enter your new Password according to the requirements listed.
</p>
<form>
<div>
<TextInput
type="password"
required
{...register("password", { required: true })}
label="Password"
error={errors.password?.message}
/>
</div>
<div className="mt-3">
<TextInput
type="password"
required
{...register("newPassword", { required: true })}
label="New Password"
error={errors.newPassword?.message}
/>
</div>
<div className="mt-3 mb-4">
<TextInput
type="password"
required
{...register("confirmPassword", {
required: true,
})}
label="Confirm Password"
error={errors.confirmPassword?.message}
/>
</div>
</form>
</>
);
}

View File

@@ -1,35 +0,0 @@
"use client";
import { Button, ButtonVariants } from "#/ui/Button";
import { TextInput } from "#/ui/Input";
import UserAvatar from "#/ui/UserAvatar";
import { useRouter } from "next/navigation";
export default function Page() {
const router = useRouter();
return (
<div className="flex flex-col items-center space-y-4">
<h1>Password</h1>
<p className="ztdl-p mb-6 block">Enter your password.</p>
<UserAvatar
showDropdown
displayName="Max Peintner"
loginName="max@zitadel.com"
></UserAvatar>
<div className="w-full">
<TextInput type="password" label="Password" />
</div>
<div className="flex w-full flex-row items-center justify-between">
<Button
onClick={() => router.back()}
variant={ButtonVariants.Secondary}
>
back
</Button>
<Button variant={ButtonVariants.Primary}>continue</Button>
</div>
</div>
);
}

View File

@@ -1,35 +0,0 @@
"use client";
import { Button, ButtonVariants } from "#/ui/Button";
import { TextInput } from "#/ui/Input";
import UserAvatar from "#/ui/UserAvatar";
import { useRouter } from "next/navigation";
export default function Page() {
const router = useRouter();
return (
<div className="flex flex-col items-center space-y-4">
<h1>Password</h1>
<p className="ztdl-p mb-6 block">Enter your password.</p>
<UserAvatar
showDropdown
displayName="Max Peintner"
loginName="max@zitadel.com"
></UserAvatar>
<div className="w-full">
<TextInput type="password" label="Password" />
</div>
<div className="flex w-full flex-row items-center justify-between">
<Button
onClick={() => router.back()}
variant={ButtonVariants.Secondary}
>
back
</Button>
<Button variant={ButtonVariants.Primary}>continue</Button>
</div>
</div>
);
}

View File

@@ -3,24 +3,46 @@ import {
getPasswordComplexitySettings,
server,
} from "#/lib/zitadel";
import RegisterForm from "#/ui/RegisterForm";
import RegisterFormWithoutPassword from "#/ui/RegisterFormWithoutPassword";
import SetPasswordForm from "#/ui/SetPasswordForm";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { firstname, lastname, email } = searchParams;
const setPassword = !!(firstname && lastname && email);
export default async function Page() {
const legal = await getLegalAndSupportSettings(server);
const passwordComplexitySettings = await getPasswordComplexitySettings(
server
);
return (
return setPassword ? (
<div className="flex flex-col items-center space-y-4">
<h1>Set Password</h1>
<p className="ztdl-p">Set the password for your account</p>
{legal && passwordComplexitySettings && (
<SetPasswordForm
passwordComplexitySettings={passwordComplexitySettings}
email={email}
firstname={firstname}
lastname={lastname}
></SetPasswordForm>
)}
</div>
) : (
<div className="flex flex-col items-center space-y-4">
<h1>Register</h1>
<p className="ztdl-p">Create your ZITADEL account.</p>
{legal && passwordComplexitySettings && (
<RegisterForm
<RegisterFormWithoutPassword
legal={legal}
passwordComplexitySettings={passwordComplexitySettings}
></RegisterForm>
></RegisterFormWithoutPassword>
)}
</div>
);

View File

@@ -1,19 +0,0 @@
import { Button, ButtonVariants } from "#/ui/Button";
import Link from "next/link";
type Props = {
searchParams: { [key: string]: string | string[] | undefined };
};
export default async function Page({ searchParams }: Props) {
return (
<div className="flex flex-col items-center space-y-4">
<h1>Registration successful</h1>
<p className="ztdl-p">You are registered.</p>
{`userId: ${searchParams["userid"]}`}
<Link href="/register">
<Button variant={ButtonVariants.Primary}>back</Button>
</Link>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import {
getLegalAndSupportSettings,
getPasswordComplexitySettings,
server,
} from "#/lib/zitadel";
import RegisterForm from "#/ui/RegisterForm";
export default async function Page() {
const legal = await getLegalAndSupportSettings(server);
const passwordComplexitySettings = await getPasswordComplexitySettings(
server
);
return (
<div className="flex flex-col items-center space-y-4">
<h1>Register</h1>
<p className="ztdl-p">Create your ZITADEL account.</p>
{legal && passwordComplexitySettings && (
<RegisterForm
legal={legal}
passwordComplexitySettings={passwordComplexitySettings}
></RegisterForm>
)}
</div>
);
}

View File

@@ -0,0 +1,48 @@
import {
createPasskeyRegistrationLink,
getSession,
registerPasskey,
server,
} from "#/lib/zitadel";
import { getSessionCookieById } from "#/utils/cookies";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
if (body) {
const { sessionId } = body;
const sessionCookie = await getSessionCookieById(sessionId);
const session = await getSession(
server,
sessionCookie.id,
sessionCookie.token
);
const domain: string = request.nextUrl.hostname;
const userId = session?.session?.factors?.user?.id;
if (userId) {
return createPasskeyRegistrationLink(userId)
.then((resp) => {
const code = resp.code;
return registerPasskey(userId, code, domain).then((resp) => {
return NextResponse.json(resp);
});
})
.catch((error) => {
console.error("error on creating passkey registration link");
return NextResponse.json(error, { status: 500 });
});
} else {
return NextResponse.json(
{ details: "could not get session" },
{ status: 500 }
);
}
} else {
return NextResponse.json({}, { status: 400 });
}
}

View File

@@ -0,0 +1,49 @@
import { getSession, server, verifyPasskeyRegistration } from "#/lib/zitadel";
import { getSessionCookieById } from "#/utils/cookies";
import { NextRequest, NextResponse, userAgent } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
if (body) {
let { passkeyId, passkeyName, publicKeyCredential, sessionId } = body;
if (!!!passkeyName) {
const { browser, device, os } = userAgent(request);
passkeyName = `${device.vendor ?? ""} ${device.model ?? ""}${
device.vendor || device.model ? ", " : ""
}${os.name}${os.name ? ", " : ""}${browser.name}`;
}
const sessionCookie = await getSessionCookieById(sessionId);
const session = await getSession(
server,
sessionCookie.id,
sessionCookie.token
);
const userId = session?.session?.factors?.user?.id;
if (userId) {
return verifyPasskeyRegistration(
server,
passkeyId,
passkeyName,
publicKeyCredential,
userId
)
.then((resp) => {
return NextResponse.json(resp);
})
.catch((error) => {
return NextResponse.json(error, { status: 500 });
});
} else {
return NextResponse.json(
{ details: "could not get session" },
{ status: 500 }
);
}
} else {
return NextResponse.json({}, { status: 400 });
}
}

View File

@@ -6,13 +6,18 @@ export async function POST(request: NextRequest) {
if (body) {
const { email, password, firstName, lastName } = body;
const userId = await addHumanUser(server, {
return addHumanUser(server, {
email: email,
firstName,
lastName,
password: password,
});
return NextResponse.json({ userId });
password: password ? password : undefined,
})
.then((userId) => {
return NextResponse.json({ userId });
})
.catch((error) => {
return NextResponse.json(error, { status: 500 });
});
} else {
return NextResponse.error();
}

View File

@@ -18,9 +18,17 @@ import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
if (body) {
const { loginName } = body;
const { loginName, password } = body;
const domain: string = request.nextUrl.hostname;
const createdSession = await createSession(
server,
loginName,
password,
domain
);
const createdSession = await createSession(server, loginName);
if (createdSession) {
return getSession(
server,
@@ -35,7 +43,10 @@ export async function POST(request: NextRequest) {
loginName: response.session?.factors?.user?.loginName ?? "",
};
return addSessionToCookie(sessionCookie).then(() => {
return NextResponse.json({ factors: response?.session?.factors });
return NextResponse.json({
sessionId: createdSession.sessionId,
factors: response?.session?.factors,
});
});
} else {
return NextResponse.json(

View File

@@ -23,36 +23,11 @@ export const demos: { name: string; items: Item[] }[] = [
slug: "accounts",
description: "List active and inactive sessions",
},
// {
// name: "Set Password",
// slug: "password/set",
// description: "The page to set a users password",
// },
// {
// name: "MFA",
// slug: "mfa",
// description: "The page to request a users mfa method",
// },
// {
// name: "MFA Set",
// slug: "mfa/set",
// description: "The page to set a users mfa method",
// },
// {
// name: "MFA Create",
// slug: "mfa/create",
// description: "The page to create a users mfa method",
// },
// {
// name: "Passwordless",
// slug: "passwordless",
// description: "The page to login a user with his passwordless device",
// },
// {
// name: "Passwordless Create",
// slug: "passwordless/create",
// description: "The page to add a users passwordless device",
// },
{
name: "Passkey Registration",
slug: "passkey/add",
description: "The page to add a users passkey device",
},
],
},
{

View File

@@ -20,6 +20,7 @@ import {
VerifyEmailResponse,
SetSessionResponse,
DeleteSessionResponse,
VerifyPasskeyRegistrationResponse,
} from "@zitadel/server";
export const zitadelConfig: ZitadelServerOptions = {
@@ -35,7 +36,7 @@ if (!getServers().length) {
server = initializeServer(zitadelConfig);
}
export function getBrandingSettings(
export async function getBrandingSettings(
server: ZitadelServer
): Promise<BrandingSettings | undefined> {
const settingsService = settings.getSettings(server);
@@ -44,7 +45,7 @@ export function getBrandingSettings(
.then((resp: GetBrandingSettingsResponse) => resp.settings);
}
export function getGeneralSettings(
export async function getGeneralSettings(
server: ZitadelServer
): Promise<string[] | undefined> {
const settingsService = settings.getSettings(server);
@@ -53,7 +54,7 @@ export function getGeneralSettings(
.then((resp: GetGeneralSettingsResponse) => resp.supportedLanguages);
}
export function getLegalAndSupportSettings(
export async function getLegalAndSupportSettings(
server: ZitadelServer
): Promise<LegalAndSupportSettings | undefined> {
const settingsService = settings.getSettings(server);
@@ -64,7 +65,7 @@ export function getLegalAndSupportSettings(
});
}
export function getPasswordComplexitySettings(
export async function getPasswordComplexitySettings(
server: ZitadelServer
): Promise<PasswordComplexitySettings | undefined> {
const settingsService = settings.getSettings(server);
@@ -74,15 +75,22 @@ export function getPasswordComplexitySettings(
.then((resp: GetPasswordComplexitySettingsResponse) => resp.settings);
}
export function createSession(
export async function createSession(
server: ZitadelServer,
loginName: string
loginName: string,
password: string | undefined,
domain: string
): Promise<CreateSessionResponse | undefined> {
const sessionService = session.getSession(server);
return sessionService.createSession({ checks: { user: { loginName } } }, {});
return password
? sessionService.createSession(
{ checks: { user: { loginName }, password: { password } }, domain },
{}
)
: sessionService.createSession({ checks: { user: { loginName } } }, {});
}
export function setSession(
export async function setSession(
server: ZitadelServer,
sessionId: string,
sessionToken: string,
@@ -95,7 +103,7 @@ export function setSession(
);
}
export function getSession(
export async function getSession(
server: ZitadelServer,
sessionId: string,
sessionToken: string
@@ -104,7 +112,7 @@ export function getSession(
return sessionService.getSession({ sessionId, sessionToken }, {});
}
export function deleteSession(
export async function deleteSession(
server: ZitadelServer,
sessionId: string,
sessionToken: string
@@ -113,7 +121,7 @@ export function deleteSession(
return sessionService.deleteSession({ sessionId, sessionToken }, {});
}
export function listSessions(
export async function listSessions(
server: ZitadelServer,
ids: string[]
): Promise<ListSessionsResponse | undefined> {
@@ -127,22 +135,28 @@ export type AddHumanUserData = {
firstName: string;
lastName: string;
email: string;
password: string;
password: string | undefined;
};
export function addHumanUser(
export async function addHumanUser(
server: ZitadelServer,
{ email, firstName, lastName, password }: AddHumanUserData
): Promise<string> {
const mgmt = user.getUser(server);
const payload = {
email: { email },
username: email,
profile: { firstName, lastName },
};
return mgmt
.addHumanUser(
{
email: { email },
username: email,
profile: { firstName, lastName },
password: { password },
},
password
? {
...payload,
password: { password },
}
: payload,
{}
)
.then((resp: AddHumanUserResponse) => {
@@ -150,7 +164,7 @@ export function addHumanUser(
});
}
export function verifyEmail(
export async function verifyEmail(
server: ZitadelServer,
userId: string,
verificationCode: string
@@ -171,7 +185,10 @@ export function verifyEmail(
* @param userId the id of the user where the email should be set
* @returns the newly set email
*/
export function setEmail(server: ZitadelServer, userId: string): Promise<any> {
export async function setEmail(
server: ZitadelServer,
userId: string
): Promise<any> {
const userservice = user.getUser(server);
return userservice.setEmail(
{
@@ -181,4 +198,71 @@ export function setEmail(server: ZitadelServer, userId: string): Promise<any> {
);
}
/**
*
* @param server
* @param userId the id of the user where the email should be set
* @returns the newly set email
*/
export async function createPasskeyRegistrationLink(
userId: string
): Promise<any> {
const userservice = user.getUser(server);
return userservice.createPasskeyRegistrationLink({
userId,
returnCode: {},
});
}
/**
*
* @param server
* @param userId the id of the user where the email should be set
* @returns the newly set email
*/
export async function verifyPasskeyRegistration(
server: ZitadelServer,
passkeyId: string,
passkeyName: string,
publicKeyCredential:
| {
[key: string]: any;
}
| undefined,
userId: string
): Promise<VerifyPasskeyRegistrationResponse> {
const userservice = user.getUser(server);
return userservice.verifyPasskeyRegistration(
{
passkeyId,
passkeyName,
publicKeyCredential,
userId,
},
{}
);
}
/**
*
* @param server
* @param userId the id of the user where the email should be set
* @returns the newly set email
*/
export async function registerPasskey(
userId: string,
code: { id: string; code: string },
domain: string
): Promise<any> {
const userservice = user.getUser(server);
return userservice.registerPasskey({
userId,
code,
domain,
// authenticator:
});
}
export { server };

View File

@@ -9,7 +9,7 @@
@layer base {
h1,
.ztdl-h1 {
@apply text-2xl;
@apply text-2xl text-center;
}
.ztdl-p {

View File

@@ -1,14 +1,44 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import {
ExclamationTriangleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
import clsx from "clsx";
type Props = {
children: React.ReactNode;
type?: AlertType;
};
export default function Alert({ children }: Props) {
export enum AlertType {
ALERT,
INFO,
}
const yellow =
"border-yellow-600/40 dark:border-yellow-500/20 bg-yellow-200/30 text-yellow-600 dark:bg-yellow-700/20 dark:text-yellow-200";
const red =
"border-red-600/40 dark:border-red-500/20 bg-red-200/30 text-red-600 dark:bg-red-700/20 dark:text-red-200";
const neutral =
"border-divider-light dark:border-divider-dark bg-black/5 text-gray-600 dark:bg-white/10 dark:text-gray-200";
export default function Alert({ children, type = AlertType.ALERT }: Props) {
return (
<div className="flex flex-row items-center justify-center border border-yellow-600/40 dark:border-yellow-500/20 bg-yellow-200/30 text-yellow-600 dark:bg-yellow-700/20 dark:text-yellow-200 rounded-md py-2 scroll-px-40">
<ExclamationTriangleIcon className="flex-shrink-0 h-5 w-5 mr-2 ml-2" />
<span className="text-center text-sm">{children}</span>
<div
className={clsx(
"flex flex-row items-center justify-center border rounded-md py-2 pr-2 scroll-px-40",
{
[yellow]: type === AlertType.ALERT,
[neutral]: type === AlertType.INFO,
}
)}
>
{type === AlertType.ALERT && (
<ExclamationTriangleIcon className="flex-shrink-0 h-5 w-5 mr-2 ml-2" />
)}
{type === AlertType.INFO && (
<InformationCircleIcon className="flex-shrink-0 h-5 w-5 mr-2 ml-2" />
)}
<span className="text-sm">{children}</span>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { RadioGroup } from "@headlessui/react";
export const methods = [
{
name: "Passkeys",
description: "Authenticate with your device.",
},
{
name: "Password",
description: "Authenticate with a password",
},
];
export default function AuthenticationMethodRadio({
selected,
selectionChanged,
}: {
selected: any;
selectionChanged: (value: any) => void;
}) {
return (
<div className="w-full">
<div className="mx-auto w-full max-w-md">
<RadioGroup value={selected} onChange={selectionChanged}>
<RadioGroup.Label className="sr-only">Server size</RadioGroup.Label>
<div className="grid grid-cols-2 space-x-2">
{methods.map((method) => (
<RadioGroup.Option
key={method.name}
value={method}
className={({ active, checked }) =>
`${
active
? "h-full ring-2 ring-opacity-60 ring-primary-light-500 dark:ring-white/20"
: "h-full "
}
${
checked
? "bg-background-light-400 dark:bg-background-dark-400"
: "bg-background-light-400 dark:bg-background-dark-400"
}
relative border boder-divider-light dark:border-divider-dark flex cursor-pointer rounded-lg px-5 py-4 focus:outline-none hover:shadow-lg dark:hover:bg-white/10`
}
>
{({ active, checked }) => (
<>
<div className="flex w-full items-center justify-between">
<div className="flex items-center">
<div className="text-sm">
<RadioGroup.Label
as="p"
className={`font-medium ${checked ? "" : ""}`}
>
{method.name}
</RadioGroup.Label>
<RadioGroup.Description
as="span"
className={`text-xs text-opacity-80 dark:text-opacity-80 inline ${
checked ? "" : ""
}`}
>
{method.description}
<span aria-hidden="true">&middot;</span>{" "}
</RadioGroup.Description>
</div>
</div>
{checked && (
<div className="shrink-0 text-white">
<CheckIcon className="h-6 w-6" />
</div>
)}
</div>
</>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
</div>
);
}
function CheckIcon(props: any) {
return (
<svg viewBox="0 0 24 24" fill="none" {...props}>
<circle
className="fill-current text-black/50 dark:text-white/50"
cx={12}
cy={12}
r={12}
opacity="0.2"
/>
<path
d="M7 13l3 3 7-7"
className="stroke-black dark:stroke-white"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@@ -52,7 +52,17 @@ export default function PasswordForm({ loginName }: Props) {
function submitPasswordAndContinue(value: Inputs): Promise<boolean | void> {
return submitPassword(value).then((resp: any) => {
return router.push(`/accounts`);
if (resp.factors && !resp.factors.passwordless) {
return router.push(
`/passkey/add?` +
new URLSearchParams({
loginName: resp.factors.user.loginName,
prompt: "true",
})
);
} else {
return router.push(`/accounts`);
}
});
}

View File

@@ -0,0 +1,171 @@
"use client";
import { LegalAndSupportSettings } from "@zitadel/server";
import { useState } from "react";
import { Button, ButtonVariants } from "./Button";
import { TextInput } from "./Input";
import { PrivacyPolicyCheckboxes } from "./PrivacyPolicyCheckboxes";
import { FieldValues, useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { Spinner } from "./Spinner";
import AuthenticationMethodRadio, {
methods,
} from "./AuthenticationMethodRadio";
type Inputs =
| {
firstname: string;
lastname: string;
email: string;
}
| FieldValues;
type Props = {
legal: LegalAndSupportSettings;
};
export default function RegisterFormWithoutPassword({ legal }: Props) {
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
});
const [loading, setLoading] = useState<boolean>(false);
const [selected, setSelected] = useState(methods[0]);
const router = useRouter();
async function submitAndRegister(values: Inputs) {
setLoading(true);
const res = await fetch("/registeruser", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: values.email,
firstName: values.firstname,
lastName: values.lastname,
}),
});
setLoading(false);
if (!res.ok) {
throw new Error("Failed to register user");
}
return res.json();
}
async function createSessionWithLoginName(loginName: string) {
setLoading(true);
const res = await fetch("/session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName: loginName,
}),
});
setLoading(false);
if (!res.ok) {
throw new Error("Failed to set user");
}
return res.json();
}
async function submitAndContinue(
value: Inputs,
withPassword: boolean = false
) {
return withPassword
? router.push(`/register?` + new URLSearchParams(value))
: submitAndRegister(value).then((resp: any) => {
createSessionWithLoginName(value.email).then(({ factors }) => {
return router.push(
`/passkey/add?` +
new URLSearchParams({ loginName: factors.user.loginName })
);
});
});
}
const { errors } = formState;
const [tosAndPolicyAccepted, setTosAndPolicyAccepted] = useState(false);
return (
<form className="w-full">
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="">
<TextInput
type="firstname"
autoComplete="firstname"
required
{...register("firstname", { required: "This field is required" })}
label="First name"
error={errors.firstname?.message as string}
/>
</div>
<div className="">
<TextInput
type="lastname"
autoComplete="lastname"
required
{...register("lastname", { required: "This field is required" })}
label="Last name"
error={errors.lastname?.message as string}
/>
</div>
<div className="col-span-2">
<TextInput
type="email"
autoComplete="email"
required
{...register("email", { required: "This field is required" })}
label="E-mail"
error={errors.email?.message as string}
/>
</div>
</div>
{legal && (
<PrivacyPolicyCheckboxes
legal={legal}
onChange={setTosAndPolicyAccepted}
/>
)}
<p className="mt-4 ztdl-p mb-6 block text-text-light-secondary-500 dark:text-text-dark-secondary-500">
Select the method you would like to authenticate
</p>
<div className="pb-4">
<AuthenticationMethodRadio
selected={selected}
selectionChanged={setSelected}
/>
</div>
<div className="mt-8 flex w-full flex-row items-center justify-between">
<Button
type="button"
variant={ButtonVariants.Secondary}
onClick={() => router.back()}
>
back
</Button>
<Button
type="submit"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid || !tosAndPolicyAccepted}
onClick={handleSubmit((values) =>
submitAndContinue(values, selected === methods[0] ? false : true)
)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,206 @@
"use client";
import { useState } from "react";
import { Button, ButtonVariants } from "./Button";
import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { Spinner } from "./Spinner";
import Alert from "./Alert";
import { RegisterPasskeyResponse } from "@zitadel/server";
import { coerceToArrayBuffer, coerceToBase64Url } from "#/utils/base64";
type Inputs = {};
type Props = {
sessionId: string;
isPrompt: boolean;
};
export default function RegisterPasskey({ sessionId, isPrompt }: Props) {
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
});
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
async function submitRegister() {
setError("");
setLoading(true);
const res = await fetch("/passkeys", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
sessionId,
}),
});
const response = await res.json();
setLoading(false);
if (!res.ok) {
setError(response.details);
return Promise.reject(response.details);
}
return response;
}
async function submitVerify(
passkeyId: string,
passkeyName: string,
publicKeyCredential: any,
sessionId: string
) {
setLoading(true);
const res = await fetch("/passkeys/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
passkeyId,
passkeyName,
publicKeyCredential,
sessionId,
}),
});
const response = await res.json();
setLoading(false);
if (!res.ok) {
setError(response.details);
return Promise.reject(response.details);
}
return response;
}
function submitRegisterAndContinue(value: Inputs): Promise<boolean | void> {
return submitRegister().then((resp: RegisterPasskeyResponse) => {
const passkeyId = resp.passkeyId;
if (
resp.publicKeyCredentialCreationOptions &&
resp.publicKeyCredentialCreationOptions.publicKey
) {
resp.publicKeyCredentialCreationOptions.publicKey.challenge =
coerceToArrayBuffer(
resp.publicKeyCredentialCreationOptions.publicKey.challenge,
"challenge"
);
resp.publicKeyCredentialCreationOptions.publicKey.user.id =
coerceToArrayBuffer(
resp.publicKeyCredentialCreationOptions.publicKey.user.id,
"userid"
);
if (
resp.publicKeyCredentialCreationOptions.publicKey.excludeCredentials
) {
resp.publicKeyCredentialCreationOptions.publicKey.excludeCredentials.map(
(cred: any) => {
cred.id = coerceToArrayBuffer(
cred.id as string,
"excludeCredentials.id"
);
return cred;
}
);
}
navigator.credentials
.create(resp.publicKeyCredentialCreationOptions)
.then((resp) => {
if (
resp &&
(resp as any).response.attestationObject &&
(resp as any).response.clientDataJSON &&
(resp as any).rawId
) {
const attestationObject = (resp as any).response
.attestationObject;
const clientDataJSON = (resp as any).response.clientDataJSON;
const rawId = (resp as any).rawId;
const data = {
id: resp.id,
rawId: coerceToBase64Url(rawId, "rawId"),
type: resp.type,
response: {
attestationObject: coerceToBase64Url(
attestationObject,
"attestationObject"
),
clientDataJSON: coerceToBase64Url(
clientDataJSON,
"clientDataJSON"
),
},
};
return submitVerify(passkeyId, "", data, sessionId).then(() => {
router.push("/accounts");
});
} else {
setLoading(false);
setError("An error on registering passkey");
return null;
}
})
.catch((error) => {
console.error(error);
setLoading(false);
setError(error);
return null;
});
}
});
}
const { errors } = formState;
return (
<form className="w-full">
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
{isPrompt ? (
<Button
type="button"
variant={ButtonVariants.Secondary}
onClick={() => router.push("/accounts")}
>
skip
</Button>
) : (
<Button
type="button"
variant={ButtonVariants.Secondary}
onClick={() => router.back()}
>
back
</Button>
)}
<span className="flex-grow"></span>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit(submitRegisterAndContinue)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue
</Button>
</div>
</form>
);
}

View File

@@ -13,7 +13,7 @@ export default function SessionsList({ sessions }: Props) {
const [list, setList] = useState<Session[]>(sessions);
return sessions ? (
<div className="flex flex-col">
<div className="flex flex-col space-y-2">
{list
.filter((session) => session?.factors?.user?.loginName)
.map((session, index) => {

View File

@@ -0,0 +1,190 @@
"use client";
import { PasswordComplexitySettings } from "@zitadel/server";
import PasswordComplexity from "./PasswordComplexity";
import { useState } from "react";
import { Button, ButtonVariants } from "./Button";
import { TextInput } from "./Input";
import { FieldValues, useForm } from "react-hook-form";
import {
lowerCaseValidator,
numberValidator,
symbolValidator,
upperCaseValidator,
} from "#/utils/validators";
import { useRouter } from "next/navigation";
import { Spinner } from "./Spinner";
import Alert from "./Alert";
type Inputs =
| {
password: string;
confirmPassword: string;
}
| FieldValues;
type Props = {
passwordComplexitySettings: PasswordComplexitySettings;
email: string;
firstname: string;
lastname: string;
};
export default function SetPasswordForm({
passwordComplexitySettings,
email,
firstname,
lastname,
}: Props) {
const { register, handleSubmit, watch, formState } = useForm<Inputs>({
mode: "onBlur",
});
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const router = useRouter();
async function submitRegister(values: Inputs) {
setLoading(true);
const res = await fetch("/registeruser", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: email,
firstName: firstname,
lastName: lastname,
password: values.password,
}),
});
setLoading(false);
if (!res.ok) {
const error = await res.json();
throw new Error(error.details);
}
return res.json();
}
async function createSessionWithLoginNameAndPassword(
loginName: string,
password: string
) {
const res = await fetch("/session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName: loginName,
password: password,
}),
});
if (!res.ok) {
throw new Error("Failed to set user");
}
return res.json();
}
function submitAndLink(value: Inputs): Promise<boolean | void> {
return submitRegister(value)
.then((humanResponse: any) => {
setError("");
return createSessionWithLoginNameAndPassword(
email,
value.password
).then(() => {
setLoading(false);
return router.push(`/verify?userID=${humanResponse.userId}`);
});
})
.catch((errorDetails: Error) => {
setLoading(false);
setError(errorDetails.message);
});
}
const { errors } = formState;
const watchPassword = watch("password", "");
const watchConfirmPassword = watch("confirmPassword", "");
const hasMinLength =
passwordComplexitySettings &&
watchPassword?.length >= passwordComplexitySettings.minLength;
const hasSymbol = symbolValidator(watchPassword);
const hasNumber = numberValidator(watchPassword);
const hasUppercase = upperCaseValidator(watchPassword);
const hasLowercase = lowerCaseValidator(watchPassword);
const policyIsValid =
passwordComplexitySettings &&
(passwordComplexitySettings.requiresLowercase ? hasLowercase : true) &&
(passwordComplexitySettings.requiresNumber ? hasNumber : true) &&
(passwordComplexitySettings.requiresUppercase ? hasUppercase : true) &&
(passwordComplexitySettings.requiresSymbol ? hasSymbol : true) &&
hasMinLength;
return (
<form className="w-full">
<div className="pt-4 grid grid-cols-1 gap-4 mb-4">
<div className="">
<TextInput
type="password"
autoComplete="new-password"
required
{...register("password", {
required: "You have to provide a password!",
})}
label="Password"
error={errors.password?.message as string}
/>
</div>
<div className="">
<TextInput
type="password"
required
autoComplete="new-password"
{...register("confirmPassword", {
required: "This field is required",
})}
label="Confirm Password"
error={errors.confirmPassword?.message as string}
/>
</div>
</div>
{passwordComplexitySettings && (
<PasswordComplexity
passwordComplexitySettings={passwordComplexitySettings}
password={watchPassword}
equals={!!watchPassword && watchPassword === watchConfirmPassword}
/>
)}
{error && <Alert>{error}</Alert>}
<div className="mt-8 flex w-full flex-row items-center justify-between">
<Button type="button" variant={ButtonVariants.Secondary}>
back
</Button>
<Button
type="submit"
variant={ButtonVariants.Primary}
disabled={
loading ||
!policyIsValid ||
!formState.isValid ||
watchPassword !== watchConfirmPassword
}
onClick={handleSubmit(submitAndLink)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue
</Button>
</div>
</form>
);
}

View File

@@ -3,7 +3,7 @@ import { ChevronDownIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
type Props = {
loginName: string;
loginName?: string;
displayName?: string;
showDropdown: boolean;
};
@@ -14,12 +14,12 @@ export default function UserAvatar({
showDropdown,
}: Props) {
return (
<div className="flex h-full w-full flex-row items-center rounded-full border p-[1px] dark:border-white/20">
<div className="flex h-full flex-row items-center rounded-full border p-[1px] dark:border-white/20">
<div>
<Avatar
size="small"
name={displayName ?? loginName}
loginName={loginName}
name={displayName ?? loginName ?? ""}
loginName={loginName ?? ""}
/>
</div>
<span className="ml-4 text-14px">{loginName}</span>
@@ -27,7 +27,7 @@ export default function UserAvatar({
{showDropdown && (
<Link
href="/accounts"
className="flex items-center justify-center p-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full mr-1 transition-all"
className="ml-4 flex items-center justify-center p-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full mr-1 transition-all"
>
<ChevronDownIcon className="h-4 w-4" />
</Link>

View File

@@ -0,0 +1,63 @@
export function coerceToBase64Url(thing: any, name: string) {
// Array or ArrayBuffer to Uint8Array
if (Array.isArray(thing)) {
thing = Uint8Array.from(thing);
}
if (thing instanceof ArrayBuffer) {
thing = new Uint8Array(thing);
}
// Uint8Array to base64
if (thing instanceof Uint8Array) {
var str = "";
var len = thing.byteLength;
for (var i = 0; i < len; i++) {
str += String.fromCharCode(thing[i]);
}
thing = window.btoa(str);
}
if (typeof thing !== "string") {
throw new Error("could not coerce '" + name + "' to string");
}
// base64 to base64url
// NOTE: "=" at the end of challenge is optional, strip it off here
thing = thing.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, "");
return thing;
}
export function coerceToArrayBuffer(thing: any, name: string) {
if (typeof thing === "string") {
// base64url to base64
thing = thing.replace(/-/g, "+").replace(/_/g, "/");
// base64 to Uint8Array
var str = window.atob(thing);
var bytes = new Uint8Array(str.length);
for (var i = 0; i < str.length; i++) {
bytes[i] = str.charCodeAt(i);
}
thing = bytes;
}
// Array to Uint8Array
if (Array.isArray(thing)) {
thing = new Uint8Array(thing);
}
// Uint8Array to ArrayBuffer
if (thing instanceof Uint8Array) {
thing = thing.buffer;
}
// error if none of the above worked
if (!(thing instanceof ArrayBuffer)) {
throw new TypeError("could not coerce '" + name + "' to ArrayBuffer");
}
return thing;
}

View File

@@ -135,7 +135,6 @@ export async function getMostRecentCookieWithLoginname(
if (stringifiedCookie?.value) {
const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value);
const filtered = sessions.filter((cookie) => {
return !!loginName ? cookie.loginName === loginName : true;
});
@@ -153,10 +152,10 @@ export async function getMostRecentCookieWithLoginname(
if (latest) {
return latest;
} else {
return Promise.reject();
return Promise.reject("Could not get the context or retrieve a session");
}
} else {
return Promise.reject();
return Promise.reject("Could not read session cookie");
}
}

View File

@@ -21,7 +21,7 @@
"eslint": "^7.32.0",
"eslint-config-zitadel": "workspace:*",
"prettier": "^2.5.1",
"turbo": "^1.9.8"
"turbo": "^1.10.3"
},
"packageManager": "pnpm@7.15.0"
}

View File

@@ -1,6 +1,7 @@
import * as settings from "./v2/settings";
import * as session from "./v2/session";
import * as user from "./v2/user";
import * as management from "./management";
import * as login from "./proto/server/zitadel/settings/v2alpha/login_settings";
import * as password from "./proto/server/zitadel/settings/v2alpha/password_settings";
@@ -28,8 +29,17 @@ export {
export {
AddHumanUserResponse,
VerifyEmailResponse,
VerifyPasskeyRegistrationRequest,
VerifyPasskeyRegistrationResponse,
RegisterPasskeyRequest,
RegisterPasskeyResponse,
CreatePasskeyRegistrationLinkResponse,
CreatePasskeyRegistrationLinkRequest,
} from "./proto/server/zitadel/user/v2alpha/user_service";
export {
SetHumanPasswordResponse,
SetHumanPasswordRequest,
} from "./proto/server/zitadel/management";
export { type LegalAndSupportSettings } from "./proto/server/zitadel/settings/v2alpha/legal_settings";
export { type PasswordComplexitySettings } from "./proto/server/zitadel/settings/v2alpha/password_settings";
export { type ResourceOwnerType } from "./proto/server/zitadel/settings/v2alpha/settings";
@@ -48,6 +58,7 @@ export {
type ZitadelServerOptions,
initializeServer,
user,
management,
session,
settings,
login,

View File

@@ -25,7 +25,6 @@ const createClient = <Client>(
};
export const getManagement = (app?: string | ZitadelServer) => {
console.log("init management");
let config;
if (app && typeof app === "string") {
const apps = getServers();

View File

@@ -1,8 +1,4 @@
import { createChannel, createClientFactory } from "nice-grpc";
import {
SettingsServiceClient,
SettingsServiceDefinition,
} from "./proto/server/zitadel/settings/v2alpha/settings_service";
import { authMiddleware } from "./middleware";
import { CompatServiceDefinition } from "nice-grpc/lib/service-definitions";

3511
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff