Passkeys Authentication: Integration and Implementation Guide

Passkeys Integration with SimpleWebAuthn for Angular and Node.js

This tutorial guides you step-by-step through a straightforward passkey integration using Angular and Node.js.

The simplewebauthn library is used to simplify the integration process by providing tools and functions for both the client side (browser) and server side (backend) of an application, making it easier to create a secure authentication system that leverages the benefits of WebAuthn.

What are passkeys?

Passkeys are gaining traction as a more secure alternative to traditional passwords, designed to simplify and strengthen user authentication. They are part of the FIDO (Fast Identity Online) Alliance standards, specifically leveraging WebAuthn and CTAP (Client to Authenticator Protocol). Read more about what are passkeys here.

Backend

For the backend, we’ll use simplewebauthn, an open-source library for implementing WebAuthn, which is a web standard for secure user authentication using biometrics, hardware tokens, and other cryptographic techniques. It provides a straightforward way for developers to add WebAuthn support to their web applications, handling the complex details of the WebAuthn protocol.

Frontend

For the front end, we’ll use Angular along with the OwnID webauthn library, an easy to use front end library that handles some of the tedious integration details for you. 

Prerequisites

  • Basic knowledge of Node.js and Angular
  • Node.js and npm installed
  • Angular CLI installed

Backend Setup

Step 1: Initialize the Project

  1. Create a new folder for your project and open it.
mkdir passkeys-integration
cd passkeys-integration
  1. Initialize a new Node.js project using npm.
npm init -y

  1. Install the necessary library dependencies:
npm install express body-parser cors dotenv @simplewebauthn/server base64url

(Be sure to keep `cors` current as updates become available.) 

This command installs these packages and their dependencies into the node_modules directory of your project, and adds them as dependencies in your package.json file.

Step 2: Create the Server

Set up the server using Express. 

  1. Create a file called index.js in your new folder.
  2. In index.js, add the lines below to import and initialize the modules.
const express = require('express');
const path = require('path'); 
const app = express();
const bodyParser = require('body-parser');
const cors = require('cors');
require('dotenv').config();
const SimpleWebAuthnServer = require('@simplewebauthn/server');
const base64url = require('base64url');
app.use(cors({ origin: '*' }));
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
  1. In index.js, add the lines below to define in-memory storage and configuration values such as the relying party ID and expected origins.
let users = {};
let challenges = {};
const rpId = 'localhost';
const expectedOrigin = ['http://localhost:3000'];
  1. Add the lines below to index.js to configure the server and the frontend build directory (we’ll create the frontend project later in this tutorial).
app.listen(process.env.PORT || 3000, err => {
    if (err) throw err;
    console.log('Server started on port', process.env.PORT || 3000);
});
app.use(express.static(path.join(__dirname, 'passkey-frontend/dist/passkey-frontend/browser')));

Step 3: Registration Endpoints

Define the registration endpoint ‘register/start’. 

  1. Add the lines below to index.js to create the registration endpoint. Calling this first endpoint initiates the registration process by generating a challenge and returning public key credential creation options.
app.post('/register/start', (req, res) => {
    let username = req.body.username;
    let challenge = getNewChallenge();
    challenges[username] = convertChallenge(challenge);
    const pubKey = {
        challenge: challenge,
        rp: {id: rpId, name: 'webauthn-app'},
        user: {id: username, name: username, displayName: username},
        pubKeyCredParams: [
            {type: 'public-key', alg: -7},
            {type: 'public-key', alg: -257},
        ],
        authenticatorSelection: {
            authenticatorAttachment: 'platform',
            userVerification: 'required',
            residentKey: 'preferred',
            requireResidentKey: false,
        }
    };
    res.json(pubKey);
});
  1. Add the lines below to index.js to create the verification endpoint. Calling this endpoint handles completion of the registration process by verifying the attestation response using SimpleWebAuthnServer.
app.post('/register/finish', async (req, res) => {
    const username = req.body.username;
    // Verify the attestation response
    let verification;
    try {
        verification = await SimpleWebAuthnServer.verifyRegistrationResponse({
            response: req.body.data,
            expectedChallenge: challenges[username],
            expectedOrigin:expectedOrigin
        });
    } catch (error) {
        console.error(error);
        return res.status(400).send({error: error.message});
    }
    const {verified, registrationInfo} = verification;
    if (verified) {
        users[username] = registrationInfo;
        return res.status(200).send(true);
    }
    res.status(500).send(false);
});

Step 4: Login Endpoints

We will now define the login endpoints. The first endpoint handles generating a challenge and returning public key credential creation options.

app.post('/login/start', (req, res) => {
    let username = req.body.username;
    if (!users[username]) {
        return res.status(404).send(false);
    }
    let challenge = getNewChallenge();
    challenges[username] = convertChallenge(challenge);
    res.json({
        challenge,
        rpId,
        allowCredentials: [{
            type: 'public-key',
            id: users[username].credentialID,
            transports: ['internal'],
        }],
        userVerification: 'preferred',
    });
});

The second endpoint handles completion of the login process by verifying the attestation response using SimpleWebAuthnServer.

app.post('/login/finish', async (req, res) => {
    let username = req.body.username;
    if (!users[username]) {
       return res.status(404).send(false);
    }
    let verification;
    try {
        const user = users[username];
        verification = await SimpleWebAuthnServer.verifyAuthenticationResponse({
            expectedChallenge: challenges[username],
            response: req.body.data,
            authenticator: user,
            expectedRPID: rpId,
            expectedOrigin,
            requireUserVerification: false
        });
    } catch (error) {
        console.error(error);
        return res.status(400).send({error: error.message});
    }
    const {verified} = verification;
    return res.status(200).send({
        res: verified
    });
});

Those are the required endpoints, and we can add a couple helper functions to finish off the backend.

Step 5: Helper Functions

Helper functions are used to generate a new challenge and convert it for storage and verification.

function getNewChallenge() {
    return Math.random().toString(36).substring(2);
}
function convertChallenge(challenge) {
    return btoa(challenge).replaceAll('=', '');
}

Full index.js

Your finished index.js file should look like this:

const express = require('express');
const path = require('path'); 
const app = express();
const bodyParser = require('body-parser');
const cors = require('cors');
require('dotenv').config();
const SimpleWebAuthnServer = require('@simplewebauthn/server');
const base64url = require('base64url');
app.use(cors({ origin: '*' }));
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
let users = {};
let challenges = {};
const rpId = 'localhost';
const expectedOrigin = ['http://localhost:3000'];
app.listen(process.env.PORT || 3000, err => {
    if (err) throw err;
    console.log('Server started on port', process.env.PORT || 3000);
});
app.use(express.static(path.join(__dirname, 'passkey-frontend/dist/passkey-frontend/browser')));


app.post('/register/start', (req, res) => {
    let username = req.body.username;
    let challenge = getNewChallenge();
    challenges[username] = convertChallenge(challenge);
    const pubKey = {
        challenge: challenge,
        rp: {id: rpId, name: 'webauthn-app'},
        user: {id: username, name: username, displayName: username},
        pubKeyCredParams: [
            {type: 'public-key', alg: -7},
            {type: 'public-key', alg: -257},
        ],
        authenticatorSelection: {
            authenticatorAttachment: 'platform',
            userVerification: 'required',
            residentKey: 'preferred',
            requireResidentKey: false,
        }
    };
    res.json(pubKey);
});


app.post('/register/finish', async (req, res) => {
    const username = req.body.username;
    // Verify the attestation response
    let verification;
    try {
        verification = await SimpleWebAuthnServer.verifyRegistrationResponse({
            response: req.body.data,
            expectedChallenge: challenges[username],
            expectedOrigin:expectedOrigin
        });
    } catch (error) {
        console.error(error);
        return res.status(400).send({error: error.message});
    }
    const {verified, registrationInfo} = verification;
    if (verified) {
        users[username] = registrationInfo;
        return res.status(200).send(true);
    }
    res.status(500).send(false);
});
app.post('/login/start', (req, res) => {
    let username = req.body.username;
    if (!users[username]) {
        return res.status(404).send(false);
    }
    let challenge = getNewChallenge();
    challenges[username] = convertChallenge(challenge);
    res.json({
        challenge,
        rpId,
        allowCredentials: [{
            type: 'public-key',
            id: users[username].credentialID,
            transports: ['internal'],
        }],
        userVerification: 'preferred',
    });
});
app.post('/login/finish', async (req, res) => {
    let username = req.body.username;
    if (!users[username]) {
       return res.status(404).send(false);
    }
    let verification;
    try {
        const user = users[username];
        verification = await SimpleWebAuthnServer.verifyAuthenticationResponse({
            expectedChallenge: challenges[username],
            response: req.body.data,
            authenticator: user,
            expectedRPID: rpId,
            expectedOrigin,
            requireUserVerification: false
        });
    } catch (error) {
        console.error(error);
        return res.status(400).send({error: error.message});
    }
    const {verified} = verification;
    return res.status(200).send({
        res: verified
    });
});
function getNewChallenge() {
    return Math.random().toString(36).substring(2);
}
function convertChallenge(challenge) {
    return btoa(challenge).replaceAll('=', '');
}

Frontend Setup

Set up the front end of your application by initializing Angular and creating the main component using webathn

Step 1: Initialize the Angular Project

  1. Install Angular CLI
npm i -g @angular/cli
  1. Navigate to the project root and create a new Angular project:
ng new passkey-frontend
cd passkey-frontend
  1. Install the required OwnID webauthn library in the passkey-frontend folder:
npm install @ownid/webauthn

Step 2: Create the Main Component

Define the main component by importing necessary modules and libraries, and defining the component with a form for username input and buttons to register and login (app.component.ts)

In app.component.ts, import the modules shown below.

import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { fido2Get, fido2Create } from '@ownid/webauthn';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
  1. In app.component.ts, define the login form as an Angular component using the @Component decorator.
  2. Define the CSS selector as 'app-root' so we can reference it from HTML (inside <app-root></app-root>tags).
  3. Add an Inline HTML template for the component:some text
    1. An <h1> element displaying "Passkeys Example".
    2. An <input> element bound to the username property using Angular's two-way data binding ([(ngModel)]).
    3. Buttons to trigger the registerStart() and loginStart() methods.
  4. Set standalone to true. This indicates that this component doesn't need to be declared in an Angular module.
  5. Use imports to specify the required modules.
 selector: 'app-root',
  template: `
    <div>
      <h1>Passkeys Example</h1>
      <input [(ngModel)]="username" placeholder="Username" />
      <button (click)="registerStart()">Register</button>
      <button (click)="loginStart()">Login</button>
    </div>
  `,
  standalone: true,
  imports: [FormsModule, CommonModule, HttpClientModule]
})
  1. Declare the AppComponent class, and make it available to other modules. 
  2. Define a username property in the AppComponent class and initialize it to a null string.
  3. Define a constructor that injects the HttpClient service, which is used for making HTTP requests from the component.
export class AppComponent {
  username: string = '';
  constructor(private http: HttpClient) { }
}

Step 3: Trigger Registration and Login

Implement methods to start the registration and login processes. These methods call the backend and handle the FIDO2 registration and authentication processes.

  1. Add the registerStart() function as shown below.
 async registerStart() { 
    const publicKey = await this.http.post('/register/start', { username: this.username }).toPromise();
    const fidoData = await fido2Create(publicKey, this.username);
    const response = await this.http.post<boolean>('/register/finish', fidoData).toPromise();
    console.log(response);
  }

2. Add the loginStart() function as shown below.

  async loginStart() {
    const response = await this.http.post('/login/start', { username: this.username }).toPromise();
    const options = response as PublicKeyCredentialRequestOptions;
    const assertion = await fido2Get(options, this.username);
    await this.http.post('/login/finish', assertion).toPromise();
    console.log('Login successful');
  }

Running the Application

  1. Build and serve the frontend application:
ng build --configuration production
  1. Start the backend server:
node index.js
  1. Open your browser and navigate to http://localhost:3000 to test the passkeys integration.

Conclusion

This tutorial provided a step-by-step guide to building a passkeys integration using simplewebauthn for the backend and ownid webauthn for the frontend in an Angular application.