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)
+ }
+}