feat: ✨ add login form and passkey register form
This commit is contained in:
parent
09bab92bca
commit
e590767b29
|
@ -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
97
islands/LoginForm.tsx
Normal 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
|
||||||
|
}
|
94
islands/PassKeyRegister.tsx
Normal file
94
islands/PassKeyRegister.tsx
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue