@passwordless-id/webauthn
: Handle the server side and client side of the WebAuthn protocol@vercel/kv
: Handle the registration and login sessionsfirebase-admin
: Handle the user database and store the credentials generated by `@passwordless-id/webauthnuuid
: Generate a unique challenge for each registration and login requestzod
: Validate the data sent by the client
First, we need to create an api route that will generate a challenge for each registration and login request, and it will also store the challenge in the KV store.
import {
KvStoreTypeSchema,
getKey
} from '@/service/firebase/webauthn/kv.utils';
import { v4 } from 'uuid';
import { kv } from '@vercel/kv';
import { z } from 'zod';
import { NextResponse } from 'next/server';
import { config } from '@/config';
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
try {
// get challenge type and user id from query params
const storeType = KvStoreTypeSchema.parse(searchParams.get('type'));
const userId = z.string().parse(searchParams.get('userId'));
// generate a challenge
const challenge = v4();
// get the key this challenge
const kvKey = getKey(storeType, userId);
if (await kv.exists(kvKey)) {
const data = await kv.get(kvKey);
return NextResponse.json(data);
}
// store the challenge in the kv store
await kv.set(kvKey, JSON.stringify({ challenge }));
await kv.expire(kvKey, config.defaultKvExpiration);
return NextResponse.json({ challenge });
} catch (err) {
// check if the error is a zod error
if (err instanceof z.ZodError) {
return NextResponse.json({ error: err.errors }, { status: 400 });
}
throw err;
}
}
Before creating the route, we need to define the firestore data structure.
users
└── username
└── credential
├── id
├── publicKey
├── algorithm
Then we can create the route. This route will verify the registration request.
This also writes the credential to the firebase database and creates a user in the firebase auth.
import { getAdmin } from '@/api/firebase';
import { getKey } from '@/service/firebase/webauthn/kv.utils';
import { server } from '@passwordless-id/webauthn';
import { kv } from '@vercel/kv';
import { NextResponse } from 'next/server';
import { z } from 'zod';
const RequestSchema = z.object({
username: z.string(),
credential: z.object({
id: z.string(),
publicKey: z.string(),
algorithm: z.enum(['RS256', 'ES256'])
}),
authenticatorData: z.string(),
clientData: z.string()
});
export async function POST(request: Request) {
const registration = RequestSchema.parse(await request.json());
const key = getKey('registration', registration.username);
if (!kv.exists(key)) {
return NextResponse.json(
{ error: 'No registration session found' },
{ status: 400 }
);
}
const session = z
.object({
challenge: z.string()
})
.parse(await kv.get(key));
await server.verifyRegistration(registration, {
challenge: session.challenge,
origin: () => true
});
// store the credential in the firebase
const admin = getAdmin();
// check if the user already exists
const user = await admin
.firestore()
.collection('users')
.doc(registration.username)
.get();
if (user.exists) {
return NextResponse.json(
{ message: 'User already exists' },
{ status: 400 }
);
}
await admin.firestore().collection('users').doc(registration.username).set({
credential: registration.credential
});
// create a user in the firebase auth
await admin.auth().createUser({
uid: registration.credential.id,
displayName: registration.username
});
return NextResponse.json({ id: registration.username });
}
First, we need to write a function to get the challenge from the api route we created earlier.
static async getChallenge(
type: KvStoreType,
userId: string
): Promise<string> {
const response = await axios.get(
`/api/challenge?type=${type}&userId=${userId}`
);
const data = ChallengeSchema.parse(response.data);
return data.challenge;
}
Then we can use the challenge to create a credential request and send it to the server.
export class PasswordlessAuthenticationService {
static async getChallenge(
type: KvStoreType,
userId: string
): Promise<string> {
const response = await axios.get(
`/api/challenge?type=${type}&userId=${userId}`
);
const data = ChallengeSchema.parse(response.data);
return data.challenge;
}
static async registration(username: string) {
// check if username greater than 3 characters
if (username.length < 3) {
throw new Error('Username must be greater than 3 characters');
}
if (!client.isAvailable()) {
throw new Error('WebAuthn is not available');
}
// since we don't have the user id yet, we'll use the username as the user id
const challenge = await this.getChallenge('registration', username);
const credential = await client.register(username, challenge);
await axios.post('/api/auth/registration', credential);
}
}