feat: add login form and passkey register form

This commit is contained in:
Julien Oculi 2024-06-17 13:26:27 +02:00
parent 09bab92bca
commit e590767b29
3 changed files with 194 additions and 0 deletions

View file

@ -34,6 +34,9 @@
"@jotsr/smart-css-bundler": "jsr:@jotsr/smart-css-bundler@^0.3.0", "@jotsr/smart-css-bundler": "jsr:@jotsr/smart-css-bundler@^0.3.0",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1", "@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", "@std/http": "jsr:@std/http@^0.224.4",
"@univoq/": "https://deno.land/x/univoq@0.2.0/", "@univoq/": "https://deno.land/x/univoq@0.2.0/",
"gfm": "https://deno.land/x/gfm@0.6.0/mod.ts", "gfm": "https://deno.land/x/gfm@0.6.0/mod.ts",

97
islands/LoginForm.tsx Normal file
View file

@ -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 (
<form onSubmit={connect} method='POST' action=''>
<Input
label='Email'
name='email'
autoComplete='email'
type='email'
required
/>
<Button label='Connection' variant='primary'>Connection</Button>
</form>
)
}
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<LoginFormFields>(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<T extends Record<string, unknown>>(form: HTMLFormElement): T {
const formData = new FormData(form)
return Object.fromEntries(formData) as unknown as T
}

View file

@ -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 (
<Button
label='Enregistrer une PassKey'
variant='primary'
onClick={register}
disabled={disabled}
>
Enregistrer une PassKey
</Button>
)
}
export default function PassKeyRegister() {
if (!isWebAuthnSupported()) {
return (
<label>
<span>Erreur: WebAuthn n'est pas supporté par votre navigateur</span>
<RegisterButton disabled />
</label>
)
}
return (
<label>
<span>Enregistrer une PassKey pour cet appareil</span>
<RegisterButton />
</label>
)
}
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)
}
}