Skip to content

Commit

Permalink
feat(webauthn): add event to validateUser to track authenticated users (
Browse files Browse the repository at this point in the history
  • Loading branch information
atinux authored Nov 15, 2024
1 parent fc0d991 commit 5392da9
Show file tree
Hide file tree
Showing 5 changed files with 49 additions and 25 deletions.
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,21 @@ The following code does not include the actual database queries, but shows the g
import { z } from 'zod'
export default defineWebAuthnRegisterEventHandler({
// optional
validateUser: z.object({
// we want the userName to be a valid email
userName: z.string().email()
}).parse,
async validateUser(userBody, event) {
// bonus: check if the user is already authenticated to link a credential to his account
// We first check if the user is already authenticated by getting the session
// And verify that the email is the same as the one in session
const session = await getUserSession(event)
if (session.user?.email && session.user.email !== body.userName) {
throw createError({ statusCode: 400, message: 'Email not matching curent session' })
}

// If he registers a new account with credentials
return z.object({
// we want the userName to be a valid email
userName: z.string().email()
}).parse(userBody)
},
async onSuccess(event, { credential, user }) {
// The credential creation has been successful
// We need to create a user if it does not exist
Expand Down
24 changes: 17 additions & 7 deletions playground/server/api/webauthn/register.post.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import { z } from 'zod'

export default defineWebAuthnRegisterEventHandler({
validateUser: z.object({
userName: z.string().email().trim(),
displayName: z.string().trim().optional(),
company: z.string().trim().optional(),
}).parse,
async validateUser(userBody, event) {
const session = await getUserSession(event)
if (session.user?.email && session.user.email !== userBody.userName) {
throw createError({ statusCode: 400, message: 'Email not matching curent session' })
}

return z.object({
userName: z.string().email().trim(),
displayName: z.string().trim().optional(),
company: z.string().trim().optional(),
}).parse(userBody)
},
async onSuccess(event, { credential, user }) {
const db = useDatabase()
try {
await db.sql`BEGIN TRANSACTION`
await db.sql`INSERT INTO users (email) VALUES (${user.userName})`
const { rows: [dbUser] } = await db.sql`SELECT * FROM users WHERE email = ${user.userName}`
let { rows: [dbUser] } = await db.sql`SELECT * FROM users WHERE email = ${user.userName}`
if (!dbUser) {
await db.sql`INSERT INTO users (email) VALUES (${user.userName})`
dbUser = (await db.sql`SELECT * FROM users WHERE email = ${user.userName}`).rows?.[0]
}
await db.sql`
INSERT INTO credentials (
userId,
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/app/composables/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import type { VerifiedAuthenticationResponse, VerifiedRegistrationResponse } from '@simplewebauthn/server'
import type { PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types'
import { ref, onMounted } from '#imports'
import type { WebAuthnComposable } from '#auth-utils'
import type { WebAuthnComposable, WebAuthnUser } from '#auth-utils'

interface RegistrationInitResponse {
creationOptions: PublicKeyCredentialCreationOptionsJSON
Expand Down Expand Up @@ -43,7 +43,7 @@ export function useWebAuthn(options: {
useBrowserAutofill = false,
} = options

async function register(user: { userName: string, displayName?: string }) {
async function register(user: WebAuthnUser) {
const { creationOptions, attemptId } = await $fetch<RegistrationInitResponse>(registerEndpoint, {
method: 'POST',
body: {
Expand Down
21 changes: 11 additions & 10 deletions src/runtime/server/lib/webauthn/register.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { eventHandler, H3Error, createError, getRequestURL, readBody } from 'h3'
import type { ValidateFunction } from 'h3'
import type { H3Event } from 'h3'
import type { GenerateRegistrationOptionsOpts } from '@simplewebauthn/server'
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server'
import defu from 'defu'
import { bufferToBase64URLString } from '@simplewebauthn/browser'
import { getRandomValues } from 'uncrypto'
import { useRuntimeConfig } from '#imports'
import type { WebAuthnUser, WebAuthnRegisterEventHandlerOptions } from '#auth-utils'
import type { RegistrationBody } from '~/src/runtime/types/webauthn'
import type { RegistrationBody, ValidateUserFunction } from '~/src/runtime/types/webauthn'

export function defineWebAuthnRegisterEventHandler<T extends WebAuthnUser>({
storeChallenge,
Expand All @@ -29,7 +29,7 @@ export function defineWebAuthnRegisterEventHandler<T extends WebAuthnUser>({

let user = body.user
if (validateUser) {
user = await validateUserData(body.user, validateUser)
user = await validateUserData(body.user, event, validateUser)
}

const _config = defu(await getOptions?.(event, body) ?? {}, useRuntimeConfig(event).webauthn.register, {
Expand Down Expand Up @@ -118,18 +118,19 @@ export function defineWebAuthnRegisterEventHandler<T extends WebAuthnUser>({

// Taken from h3
export async function validateUserData<T>(
data: unknown,
fn: ValidateFunction<T>,
userBody: WebAuthnUser,
event: H3Event,
fn: ValidateUserFunction<T>,
): Promise<T> {
try {
const res = await fn(data)
const res = await fn(userBody, event)
if (res === false) {
throw createUserValidationError()
}
if (res === true) {
return data as T
return userBody as T
}
return res ?? (data as T)
return res ?? (userBody as T)
}
catch (error) {
throw createUserValidationError(error as Error)
Expand All @@ -138,8 +139,8 @@ export async function validateUserData<T>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function createUserValidationError(validateError?: any) {
throw createError({
status: 400,
message: 'User Validation Error',
status: validateError?.statusCode || 400,
message: validateError?.message || 'User Validation Error',
data: validateError,
})
}
6 changes: 4 additions & 2 deletions src/runtime/types/webauthn.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AuthenticationResponseJSON, AuthenticatorTransportFuture, RegistrationResponseJSON } from '@simplewebauthn/types'
import type { Ref } from 'vue'
import type { H3Event, H3Error, ValidateFunction } from 'h3'
import type { H3Event, H3Error, ValidateResult } from 'h3'
import type {
GenerateAuthenticationOptionsOpts,
GenerateRegistrationOptionsOpts,
Expand Down Expand Up @@ -48,13 +48,15 @@ export type RegistrationBody<T extends WebAuthnUser> = {
response: RegistrationResponseJSON
}

export type ValidateUserFunction<T> = (userBody: WebAuthnUser, event: H3Event) => ValidateResult<T> | Promise<ValidateResult<T>>

export type WebAuthnRegisterEventHandlerOptions<T extends WebAuthnUser> = WebAuthnEventHandlerBase<{
user: T
credential: WebAuthnCredential
registrationInfo: Exclude<VerifiedRegistrationResponse['registrationInfo'], undefined>
}> & {
getOptions?: (event: H3Event, body: RegistrationBody<T>) => Partial<GenerateRegistrationOptionsOpts> | Promise<Partial<GenerateRegistrationOptionsOpts>>
validateUser?: ValidateFunction<T>
validateUser?: ValidateUserFunction<T>
excludeCredentials?: (event: H3Event, userName: string) => CredentialsList | Promise<CredentialsList>
}

Expand Down

0 comments on commit 5392da9

Please sign in to comment.