How to Add Biometric Authentication Login in Next.js (WebAuthn Nextjs in App Router)

How to Add Biometric Authentication Login in Next.js (WebAuthn Nextjs in App Router)

Posted By

kamlesh paul

on

Dec 12, 2024

Table of contents

Introduction

WebAuthn Nextjs is a modern authentication method that enhances security by using biometrics (fingerprints, facial recognition), security keys, or other devices, making it harder for hackers to gain access to your accounts. However, it’s often beneficial to use WebAuthn alongside traditional passwords, especially when users access your app from multiple devices.

In this guide, we’ll show you how to integrate WebAuthn into your Next.js application using the App Router. By combining both password-based and WebAuthn authentication, you’ll provide a robust, multi-layered security system that accommodates various user needs. Whether you’re setting up a new project or enhancing an existing one, this approach will ensure your Next.js app is both secure and user-friendly.

Building on Existing Role and Permission Management

Before diving into WebAuthn, it’s important to note that we’ll be starting from an existing Next.js project that already handles roles and permissions. This foundation will make it easier to integrate WebAuthn without having to rebuild the entire security structure from scratch.

If you’re unfamiliar with managing roles and permissions in Next.js, check out the article Next.js 14 Roles and Permissions Step-by-Step Guide. Throughout this guide, we’ll reference relevant code and concepts from that article to ensure you have a solid understanding of how everything ties together.

Prerequisites

Before we get started with integrating WebAuthn into your Next.js project, ensure you have the following setup:

Database Structure

  • Here’s the database structure for storing WebAuthn credentials using Drizzle ORM:
export const passKeysTable = pgTable("pass_keys", {
  id: serial("id").primaryKey(),
  credentialID: text('credential_id').notNull(),
  credentialPublicKey: text('credential_public_key').notNull(),
  counter: text("counter").notNull(),
  credentialDeviceType: text("credential_device_type").notNull(),
  credentialBackedUp: text("credential_backed_up").notNull(),
  transports: json('transports').$type<string[]>().notNull(),
  user_id: integer('user_id').references(() => usersTable.id).notNull(),
});
 
export const passKeysRelations = relations(passKeysTable, ({ one }) => ({
  user: one(usersTable, {
    fields: [passKeysTable.user_id],
    references: [usersTable.id],
  }),
}));

Dependencies

  • To integrate WebAuthn into your Next.js application, you’ll need the following dependencies:
npm i @simplewebauthn/browser @simplewebauthn/server
npm i @simplewebauthn/types --save-dev

Starting Point: Existing Roles and Permissions Setup

To build upon your Next.js WebAuthn implementation, start with an existing project that handles roles and permissions. You can find the relevant code in this repository, which includes the codebase from our previous guide on roles and permissions.

Clone the repository and use this as the starting point for adding WebAuthn to your Next.js application. Follow along as we extend the existing setup to integrate WebAuthn, combining enhanced security with our role and permission management system.

For detailed Overview on roles and permissions, refer to the Implementing Roles and Permissions in your Next js 14: An Overview, which explains how to set up and manage user roles and permissions in a Next.js application.

Actions

  • Let’s create the actions needed for WebAuthn functionalities, such as registering and authenticating devices.
server/actions/webauth.action.ts
'use server'
 
import { auth, signIn } from "@/auth"
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from "@simplewebauthn/server";
import type { RegistrationResponseJSON, AuthenticationResponseJSON, AuthenticatorTransportFuture } from '@simplewebauthn/types'
import { db, IUserWithPassKeys, passKeysTable, usersTable } from "../database";
import { eq } from "drizzle-orm";
import { AuthError } from "next-auth";
import { isRedirectError } from "next/dist/client/components/redirect";
import { isoBase64URL } from '@simplewebauthn/server/helpers';
 
const RP_ID = "localhost";
const rpName = "Coding Tricks";
const CLIENT_URL = "http://localhost:3000";
 
 
export const getRegistrationOptions = async () => {
  const session = await auth();
 
  const user = await db.query.usersTable.findFirst({
    where: eq(usersTable.email, session?.user.email),
  })
  if (!user) return null;
 
  const options = await generateRegistrationOptions({
    rpID: RP_ID,
    rpName: rpName,
    userName: user.email,
    userID: user.id.toString(),
  });
 
  return options
 
}
 
export const registerDevice = async (registration: RegistrationResponseJSON, challenge: string) => {
  const session = await auth();
 
  const user = await db.query.usersTable.findFirst({
    where: eq(usersTable.email, session?.user.email)
  })
  if (!user) {
    return {
      status: false,
      message: "User not found."
    }
  }
 
  const verification = await verifyRegistrationResponse({
    response: registration,
    expectedChallenge: challenge,
    expectedRPID: RP_ID,
    expectedOrigin: CLIENT_URL,
  })
 
 
 
  if (verification.verified && verification.registrationInfo) {
 
    await db.insert(passKeysTable).values({
      credentialID: isoBase64URL.fromBuffer(verification.registrationInfo.credentialID),
      credentialPublicKey: isoBase64URL.fromBuffer(verification.registrationInfo.credentialPublicKey),
      counter: String(verification.registrationInfo.counter),
      credentialDeviceType: verification.registrationInfo.credentialDeviceType,
      credentialBackedUp: verification.registrationInfo.credentialBackedUp ? "true" : "false",
      transports: registration.response.transports as AuthenticatorTransportFuture[],
      user_id: user.id,
    });
 
    return {
      status: true,
      message: "Device Registered successfully."
    }
  } else {
    return {
      status: false,
      message: "Something went wrong."
    }
  }
 
}
export const getAuthenticationOptions = async (email: string) => {
 
  const user = await db.query.usersTable.findFirst({
    where: eq(usersTable.email, email),
    with: {
      passKeys: true
    }
  })
  if (!user) return {
    status: false,
    message: "User not found."
  };
 
  const options = await generateAuthenticationOptions({
    rpID: 'localhost',
    allowCredentials: user.passKeys.map((passKey) => ({ // allow all the device for this user
      id: isoBase64URL.toBuffer(passKey.credentialID),
      type: 'public-key',
      transports: passKey.transports as AuthenticatorTransportFuture[],
    }))
  });
 
  if (options?.rpId) return {
    status: true,
    message: "Option generated",
    data: {
      options,
      userId: user.id,
    }
  };
 
  return {
    status: false,
    message: "Something went wrong."
  }
 
}
 
export const verifyAuthenticationResponseAction = async (
  response: AuthenticationResponseJSON,
  user_id: number,
  challenge: string
) => {
  const user = await db.query.usersTable.findFirst({
    where: eq(usersTable.id, user_id),
    with: {
      passKeys: true
    }
  }) as IUserWithPassKeys | undefined;
 
 
  if (!user) {
    return {
      status: false,
      message: "User not found."
    };
  }
 
  const matchingPassKey = user.passKeys.find((passKey) => passKey.credentialID == response.rawId);
 
  if (!matchingPassKey) {
    return {
      status: false,
      message: "Device not found."
    };
  }
 
  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge: challenge,
    expectedOrigin: CLIENT_URL,
    expectedRPID: RP_ID,
    authenticator: {
      credentialID: isoBase64URL.toBuffer(matchingPassKey.credentialID),
      credentialPublicKey: isoBase64URL.toBuffer(matchingPassKey.credentialPublicKey),
      counter: Number(matchingPassKey.counter),
      transports: matchingPassKey.transports as AuthenticatorTransportFuture[],
    },
  });
 
  if (verification.verified) {
    await db.update(passKeysTable)
      .set({ counter: String(verification.authenticationInfo.newCounter) })
      .where(eq(passKeysTable.credentialID, matchingPassKey.credentialID));
 
 
    try {
      await signIn("credentials", {
        email: user.email,
        credentialID: matchingPassKey.credentialID,
        redirectTo: "/"
      });
    } catch (error: any) {
      if (isRedirectError(error)) throw error; //https://github.com/nextauthjs/next-auth/discussions/9389
 
      if (error instanceof AuthError) {
        return {
          errors: undefined,
          message: error.cause?.err?.message
        }
      }
    }
 
  } else {
    return {
      status: false,
      message: "Authentication failed."
    };
  }
};

Adjust auth() to support passwordless login

auth.ts
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { comparePassword } from "@/utils/password"
import { getUserByEmailAndPassword } from "./server/queries/user"
import { User } from "next-auth";
 
export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Credentials({
      credentials: {
        email: {},
        password: {},
        credentialID: {}
      },
      authorize: async (credentials) => {
        const email = credentials.email as string;
        const password = credentials.password as string;
        const credentialID = credentials.credentialID as string;
 
        const user = await getUserByEmailAndPassword(email);
        if (!user) {
          throw new Error("User not found.");
        }
 
 
        if (!credentialID && user.passKeys.map(key => key.credentialID).includes(credentialID)) {
          const isPasswordValid = await comparePassword({ plainPassword: password, hashPassword: user.password });
          if (!isPasswordValid) {
            throw new Error("Invalid password.");
          }
 
        }
 
        return {
          id: user.id.toString(),
          name: user.name,
          email: user.email,
          role_id: user.role_id,
        } as User;
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user?.role_id) {
        token.role_id = user.role_id
      }
      return token;
    },
    session({ session, token }) {
      return {
        ...session,
        user: {
          ...session.user,
          role_id: token.role_id as string,
        },
      };
    },
  },
});

Register Device

  • Let’s create the UI for registering a device and saving it to the database.
app/(dashboard)/users/RegisterDevice.tsx
 
'use client';
import { Button } from '@/components/ui/button';
import { getRegistrationOptions, registerDevice } from '@/server/actions/webauth.action';
import { startRegistration } from '@simplewebauthn/browser';
import { useState } from 'react';
import { toast } from 'react-toastify';
 
export default function RegisterDevice() {
  const [isLoading, setIsLoading] = useState(false);
 
  const handleRegisterCurrentDevice = async () => {
    setIsLoading(true);
    try {
      const options = await getRegistrationOptions();
      if (!options) {
        return toast.error('Something went wrong.');
      }
 
      const registration = await startRegistration(options);
      const registerDeviceResponse = await registerDevice(registration, options.challenge);
 
      if (!registerDeviceResponse.status) {
        toast.error(registerDeviceResponse.message);
        return;
      }
      toast.success(registerDeviceResponse.message);
    } catch (error) {
      console.error(error);
    } finally {
      setIsLoading(false);
    }
  };
 
  return (
    <Button
      disabled={isLoading}
      onClick={handleRegisterCurrentDevice}>
      {isLoading ? 'Registering' : 'Register Current Device'}
    </Button>
  );
}

With this, you should be able to register a device like a fingerprint or face lock, managed by WebAuthn in your Next.js app.

Register-current-device.webp fingureprint-lock.webp

Login with Device

  • Now, let’s adjust the login form to support logging in with a device.
app/(auth)/login/form.tsx
"use client";
 
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { loginAction } from "@/server/actions/user.action";
import { getAuthenticationOptions, verifyAuthenticationResponseAction } from "@/server/actions/webauth.action";
import { startAuthentication } from "@simplewebauthn/browser";
import { LockOpen } from "lucide-react";
import { FormEvent, useState } from "react";
import { toast } from "react-toastify";
 
export default function LoginForm() {
  const [email, setEmail] = useState('');
  const { isPending, execute, error } = useServerAction(loginAction);
 
  const handleLogin = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const formData = new FormData(event.currentTarget);
    await execute(formData);
  };
 
  const handleLoginWithDevice = async () => {
    const res = await getAuthenticationOptions(email);
    if (!res.status) {
      toast.error(res.message);
      return;
    }
    if (!res.data) return toast.error('Something went wrong.');
 
    const options = res.data.options;
    const authJSON = await startAuthentication(options);
    const resVerify = await verifyAuthenticationResponseAction(authJSON, res.data.userId, options.challenge
 
);
    if (!resVerify.status) {
      toast.error(resVerify.message);
      return;
    }
    toast.success("Logged in successfully.");
  };
 
  return (
    <form
      onSubmit={handleLogin}
      className="space-y-4">
      <Input
        type="email"
        id="email"
        name="email"
        placeholder="email@example.com"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      <Input
        type="password"
        id="password"
        name="password"
        placeholder="**********"
        required
      />
      <Button
        disabled={isPending}
        type="submit">
        {isPending ? 'Please wait...' : 'Login'}
      </Button>
      <Button
        type="button"
        className="w-full"
        onClick={handleLoginWithDevice}>
        <LockOpen className="mr-2 h-4 w-4" />
        Login with Device
      </Button>
    </form>
  );
}

Login-Component.webp

Conclusion

With these steps, you’ve successfully integrated WebAuthn into your Next.js project, building on an existing role and permission management system. Users can now register devices for WebAuthn and use them to login, providing a strong layer of security on top of your traditional authentication methods.

If you encounter any issues or find that something is missing, you can refer to the code in the Next.js Role and Permission repository.

Share this article

47 views