import { db } from ':src/db/mod.ts' import type { SessionHandlers } from ':src/session/mod.ts' import { respondApi } from ':src/utils.ts' import { getRelyingParty } from ':src/webauthn/mod.ts' import { Credential, Passkey, Ref, User, } from '@cohabit/resources-manager/models' import { generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server' import { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON, } from '@simplewebauthn/types' import { decodeBase64 } from '@std/encoding' type Params = { step: 'start' | 'finish' } export type WebAuthnLoginStartPayload = { email: string } export type WebAuthnLoginFinishPayload = AuthenticationResponseJSON export const handler: SessionHandlers = { async POST(req, ctx) { const relyingParty = getRelyingParty(ctx.url) const { step } = ctx.params as Params if (step === 'start') { const { email } = await req.json() as WebAuthnLoginStartPayload // Get user credentials const [user] = await db.resource.user.list((user) => user.mail === email) .take(1).toArray() // Resolve refs to credentials const resolver = Ref.dbResolver(db) const credentials = await Promise.all(user.credentials.map(resolver)) // Get user passkeys const passkeys = credentials .filter((credential): credential is Credential<'passkey'> => credential.category === 'passkey' ) .map((credential) => credential.store) // Flash current user and passkeys ctx.state.session.flash('user-request', user) ctx.state.session.flash('passkeys-request', passkeys) if (passkeys.length === 0) { return respondApi( 'error', new Error('no passkey found for requested user'), 301, ) } const options = await generateAuthenticationOptions({ rpID: relyingParty.id, allowCredentials: passkeys, }) ctx.state.session.flash('webauthn-login', options) return respondApi('success', options) } if (step === 'finish') { const authentication = await req .json() as WebAuthnLoginFinishPayload const options = ctx.state.session.get< PublicKeyCredentialRequestOptionsJSON >('webauthn-login') if (options === undefined) { return respondApi( 'error', new Error('no authentication options registered'), 301, ) } const user = ctx.state.session.get('user-request') const passkey = ctx.state.session .get('passkeys-request') ?.filter((passkey) => passkey.id === authentication.id) .at(0) if (passkey === undefined) { return respondApi( 'error', new Error('no passkey found for requested user'), 301, ) } try { const verification = await verifyAuthenticationResponse({ response: authentication, expectedChallenge: options.challenge, expectedOrigin: relyingParty.origin, expectedRPID: relyingParty.id, requireUserVerification: true, authenticator: { credentialID: passkey.id, credentialPublicKey: decodeBase64(passkey.publicKey), counter: passkey.counter, transports: passkey.transports, }, }) const { authenticationInfo, verified } = verification if (authenticationInfo === undefined) { throw new Error('no authentication info found from verification') } const { newCounter } = authenticationInfo const newPasskey = { ...passkey, counter: newCounter } // Update credential store const [credential] = await db.resource.credential.list( (credential) => { if (credential.category !== 'passkey') { return false } return (credential as Credential<'passkey'>).store.id === passkey.id }, ).toArray() // Save credential to db await db.resource.credential.set([ credential.update({ store: newPasskey }), ]) // log user ctx.state.session.set('user', user) return respondApi('success', { verified }) } catch (error) { console.error(error) return respondApi('error', error, 400) } } return respondApi('error', new Error('unknown step'), 400) }, }