From 7cbc4677f0ae9ec284c334c13ebe11904ffa3467 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 22 Jun 2023 16:54:55 +0200 Subject: [PATCH] login with passkey --- apps/login/app/(login)/passkey/login/page.tsx | 58 ++++++ apps/login/ui/LoginPasskey.tsx | 185 ++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 apps/login/app/(login)/passkey/login/page.tsx create mode 100644 apps/login/ui/LoginPasskey.tsx diff --git a/apps/login/app/(login)/passkey/login/page.tsx b/apps/login/app/(login)/passkey/login/page.tsx new file mode 100644 index 00000000000..6a7124c720f --- /dev/null +++ b/apps/login/app/(login)/passkey/login/page.tsx @@ -0,0 +1,58 @@ +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; +}) { + 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 ( +
+

{title}

+ + {sessionFactors && ( + + )} +

{description}

+ + {!sessionFactors && ( +
+ + Could not get the context of the user. Make sure to enter the + username first or provide a loginName as searchParam. + +
+ )} + + {sessionFactors?.id && ( + + )} +
+ ); +} diff --git a/apps/login/ui/LoginPasskey.tsx b/apps/login/ui/LoginPasskey.tsx new file mode 100644 index 00000000000..c0e2b31e6a2 --- /dev/null +++ b/apps/login/ui/LoginPasskey.tsx @@ -0,0 +1,185 @@ +"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; +}; + +export default function LoginPasskey({ sessionId }: Props) { + const { login, handleSubmit, formState } = useForm({ + mode: "onBlur", + }); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + const router = useRouter(); + + async function submitLogin( + 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 submitLoginAndContinue(value: Inputs): Promise { + return submitLogin().then((resp: LoginPasskeyResponse) => { + 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, + "challenge" + ); + 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 + .get({ + publicKey: resp.publicKeyCredentialCreationOptions, + }) + .then((assertedCredential: any) => { + if (assertedCredential) { + let authData = new Uint8Array( + assertedCredential.response.authenticatorData + ); + let clientDataJSON = new Uint8Array( + assertedCredential.response.clientDataJSON + ); + let rawId = new Uint8Array(assertedCredential.rawId); + let sig = new Uint8Array(assertedCredential.response.signature); + let userHandle = new Uint8Array( + assertedCredential.response.userHandle + ); + + let data = JSON.stringify({ + id: assertedCredential.id, + rawId: coerceToBase64Url(rawId, "rawId"), + type: assertedCredential.type, + response: { + authenticatorData: coerceToBase64Url(authData, "authData"), + clientDataJSON: coerceToBase64Url( + clientDataJSON, + "clientDataJSON" + ), + signature: coerceToBase64Url(sig, "sig"), + userHandle: coerceToBase64Url(userHandle, "userHandle"), + }, + }); + + return submitVerify(passkeyId, "", data, sessionId); + } else { + setLoading(false); + setError("An error on retrieving passkey"); + return null; + } + }) + .catch((error) => { + console.error(error); + setLoading(false); + // setError(error); + + return null; + }); + } + // return router.push(`/accounts`); + }); + } + + const { errors } = formState; + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ {isPrompt ? ( + + ) : ( + + )} + + + +
+
+ ); +}