152 lines
4.1 KiB
TypeScript
152 lines
4.1 KiB
TypeScript
|
import {
|
||
|
generateAuthenticationOptions,
|
||
|
verifyAuthenticationResponse,
|
||
|
} from '@simplewebauthn/server'
|
||
|
import { getRelyingParty } from '../../../../src/webauthn/mod.ts'
|
||
|
import {
|
||
|
AuthenticationResponseJSON,
|
||
|
PublicKeyCredentialRequestOptionsJSON,
|
||
|
} from '@simplewebauthn/types'
|
||
|
import { respondApi } from '../../../../src/utils.ts'
|
||
|
import type { SessionHandlers } from '../../../../src/session/mod.ts'
|
||
|
import { db } from '../../../../src/db/mod.ts'
|
||
|
import { Credential, Ref, User } from '@cohabit/ressources_manager/mod.ts'
|
||
|
import { Passkey } from '@cohabit/ressources_manager/src/models/src/credential.ts'
|
||
|
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.ressource.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
|
||
|
|
||
|
passkey.counter = newCounter
|
||
|
|
||
|
// Update credential store
|
||
|
const [credential] = await db.ressource.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.ressource.credential.set([
|
||
|
credential.update({ store: passkey }),
|
||
|
])
|
||
|
|
||
|
// 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)
|
||
|
},
|
||
|
}
|