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

155 lines
4 KiB
TypeScript

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>('user-request')
const passkey = ctx.state.session
.get<Passkey[]>('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)
},
}