website/routes/api/webauthn/register/[step].ts

149 lines
4.1 KiB
TypeScript

import { SessionHandlers } from ':src/session/mod.ts'
import { respondApi } from ':src/utils.ts'
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from '@simplewebauthn/server'
import type {
PublicKeyCredentialCreationOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/types'
//TODO improve workspace imports
import { db } from ':src/db/mod.ts'
import { getRelyingParty } from ':src/webauthn/mod.ts'
import {
Credential,
Passkey,
Ref,
User,
} from '@cohabit/resources-manager/models'
import { encodeBase64 } from '@std/encoding'
type Params = { step: 'start' | 'finish' }
export type WebAuthnRegisterStartPayload = { name: string }
export type WebAuthnRegisterFinishPayload = RegistrationResponseJSON
export const handler: SessionHandlers = {
async POST(req, ctx) {
const relyingParty = getRelyingParty(ctx.url)
const { step } = ctx.params as Params
const user = ctx.state.session.get<User>('user')
if (user === undefined) {
return respondApi(
'error',
new Error('no logged user in current session'),
401,
)
}
if (step === 'start') {
const { name } = await req.json() as WebAuthnRegisterStartPayload
// Get user credentials
// Ensure latest user datas
const dbUser = await db.resource.user.get(user)
// Resolve refs to credentials
const resolver = Ref.dbResolver(db)
const credentials = await Promise.all(dbUser.credentials.map(resolver))
const excludeCredentials = credentials
.filter((credential): credential is Credential<'passkey'> =>
credential.category === 'passkey'
)
.map((credential) => credential.store)
const options = await generateRegistrationOptions({
rpName: relyingParty.name,
rpID: relyingParty.origin,
userName: user.login,
attestationType: 'none',
excludeCredentials,
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
})
ctx.state.session.flash('webauthn-registration', { name, options })
return respondApi('success', options)
}
if (step === 'finish') {
const registration = await req.json() as WebAuthnRegisterFinishPayload
const { name, options } = ctx.state.session.get<
{ name: string; options: PublicKeyCredentialCreationOptionsJSON }
>('webauthn-registration')!
try {
if (options === undefined) {
throw new Error(`no registration found for ${user.mail}`)
}
const verification = await verifyRegistrationResponse({
response: registration,
expectedChallenge: options.challenge,
expectedOrigin: relyingParty.origin,
expectedRPID: relyingParty.id,
requireUserVerification: true,
})
const { registrationInfo } = verification
if (registrationInfo === undefined) {
throw new Error('no registration info found from verification')
}
const {
credentialID,
credentialPublicKey,
counter,
credentialDeviceType,
credentialBackedUp,
} = registrationInfo
// Create new Passkey
const store: Passkey = {
user: user.uuid,
webAuthnUserID: options.user.id,
id: credentialID,
publicKey: encodeBase64(credentialPublicKey),
counter,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
transports: registration.response.transports,
}
// create and save new Credentials
const credential = Credential.load({ name, category: 'passkey', store })
await db.resource.credential.set([credential])
// Update user credentials
// Ensure latest user datas
const dbUser = await db.resource.user.get(user)
// Append new credentials
const credentials = [...dbUser.credentials, credential.toRef()]
const updatedUser = user.update({ credentials })
// Save user to db
await db.resource.user.set([updatedUser])
// Update session
ctx.state.session.set('user', updatedUser)
const { verified } = verification
return respondApi('success', { verified })
} catch (error) {
console.error(error)
return respondApi('error', error, 400)
}
}
return respondApi('error', new Error('unknown step'), 400)
},
}