From e590767b29fe5329371d2d58b0f2c89a2ceea6d5 Mon Sep 17 00:00:00 2001 From: Julien Oculi Date: Mon, 17 Jun 2024 13:26:27 +0200 Subject: [PATCH] feat: :sparkles: add login form and passkey register form --- deno.json | 3 ++ islands/LoginForm.tsx | 97 +++++++++++++++++++++++++++++++++++++ islands/PassKeyRegister.tsx | 94 +++++++++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 islands/LoginForm.tsx create mode 100644 islands/PassKeyRegister.tsx diff --git a/deno.json b/deno.json index 8db921d..d211c29 100644 --- a/deno.json +++ b/deno.json @@ -34,6 +34,9 @@ "@jotsr/smart-css-bundler": "jsr:@jotsr/smart-css-bundler@^0.3.0", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", "@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", + "@simplewebauthn/browser": "npm:@simplewebauthn/browser@^10.0.0", + "@simplewebauthn/server": "npm:@simplewebauthn/server@^10.0.0", + "@simplewebauthn/types": "npm:@simplewebauthn/types@^10.0.0", "@std/http": "jsr:@std/http@^0.224.4", "@univoq/": "https://deno.land/x/univoq@0.2.0/", "gfm": "https://deno.land/x/gfm@0.6.0/mod.ts", diff --git a/islands/LoginForm.tsx b/islands/LoginForm.tsx new file mode 100644 index 0000000..207b104 --- /dev/null +++ b/islands/LoginForm.tsx @@ -0,0 +1,97 @@ +import { startAuthentication } from '@simplewebauthn/browser' +import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types' +import { Button, Input } from 'univoq' +import type { + WebAuthnLoginFinishPayload, + WebAuthnLoginStartPayload, +} from '../routes/api/webauthn/login/[step].ts' +import { requestApi } from '../src/utils.ts' + +export default function LoginForm() { + return ( +
+ + +
+ ) +} + +type LoginFormFields = { + email: string +} + +async function connect(event: Event) { + if (!(event instanceof SubmitEvent)) return false + event.preventDefault() + const form = event.target as HTMLFormElement + const fields = formJSON(form) + + try { + // Try PassKey connection + if (!isWebAuthnSupported()) { + throw new Error('WebAuthn is not supported by your browser') + } + + await webAuthnLogin(fields) + + // Reload UI + location.reload() + } catch (error) { + // Check is user stop passkey connection + if (error instanceof Error && error.name === 'NotAllowedError') { + form.reset() + return + } + // Else use magic link + await magicLinkLogin(fields) + + // Reload UI + location.reload() + } + + form.reset() +} + +async function magicLinkLogin(fields: LoginFormFields) { + await requestApi('magiclink', 'POST', fields) + console.log('Un lien de connection vous a été envoyé par mail !') +} + +async function webAuthnLogin(fields: LoginFormFields) { + // Send WebAuthn authentication options + const authenticationOptions = await requestApi< + WebAuthnLoginStartPayload, + PublicKeyCredentialRequestOptionsJSON + >('webauthn/login/start', 'POST', fields) + + // Pass the options to the authenticator and wait for a response + const authentication = await startAuthentication(authenticationOptions) + + // Verify authentication + const verification = await requestApi< + WebAuthnLoginFinishPayload, + { verified: boolean } + >('webauthn/login/finish', 'POST', { ...fields, authentication }) + + // Show UI appropriate for the `verified` status + if (verification.verified) { + console.log('Success!') + } else { + console.error('PassKey was not verified!') + } +} + +function isWebAuthnSupported(): boolean { + return 'credentials' in navigator +} + +function formJSON>(form: HTMLFormElement): T { + const formData = new FormData(form) + return Object.fromEntries(formData) as unknown as T +} diff --git a/islands/PassKeyRegister.tsx b/islands/PassKeyRegister.tsx new file mode 100644 index 0000000..621c8a8 --- /dev/null +++ b/islands/PassKeyRegister.tsx @@ -0,0 +1,94 @@ +import { startRegistration } from '@simplewebauthn/browser' +import { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types' +import { Button } from 'univoq' +import type { + WebAuthnRegisterFinishPayload, + WebAuthnRegisterStartPayload, +} from '../routes/api/webauthn/register/[step].ts' +import { requestApi } from '../src/utils.ts' + +function isWebAuthnSupported(): boolean { + return 'credentials' in navigator +} + +function RegisterButton({ disabled }: { disabled?: boolean }) { + return ( + + ) +} + +export default function PassKeyRegister() { + if (!isWebAuthnSupported()) { + return ( + + ) + } + + return ( + + ) +} + +async function register() { + try { + await webAuthnRegister() + } catch (cause) { + console.error( + new Error('passkey register failed', { + cause, + }), + ) + } +} + +async function webAuthnRegister() { + if (localStorage.getItem('webauthn-registered')) { + return + } + + const registrationOptions = await requestApi< + WebAuthnRegisterStartPayload, + PublicKeyCredentialCreationOptionsJSON + >('webauthn/register/start', 'POST') + + // Pass the options to the authenticator and wait for a response + const registration = await startRegistration(registrationOptions) + .catch((error) => { + // Some basic error handling + if (error.name === 'InvalidStateError') { + console.error( + 'Authenticator was probably already registered by user', + ) + return null + } else { + throw error + } + }) + + if (registration === null) return + + // Verify the registration + try { + await requestApi< + WebAuthnRegisterFinishPayload, + { verified: boolean } + >('webauthn/register/finish', 'POST', registration) + console.log('Success!') + localStorage.setItem('webauthn-registered', 'true') + } catch (error) { + console.error('Oh no, something went wrong! Response:', error) + } +}