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') 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) }, }