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.
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.
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.
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.
mkdir passkeys-integration
cd passkeys-integration
npm init -y
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.
Set up the server using Express.
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')));
Define the registration endpoint ‘register/start’.
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);
});
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.
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('=', '');
}
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('=', '');
}
Set up the front end of your application by initializing Angular and creating the main component using webathn.
npm i -g @angular/cli
ng new passkey-frontend
cd passkey-frontend
npm install @ownid/webauthn
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';
'app-root'
so we can reference it from HTML (inside <app-root></app-root>
tags).<h1>
element displaying "Passkeys Example".<input>
element bound to the username
property using Angular's two-way data binding ([(ngModel)]
).registerStart()
and loginStart()
methods. 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]
})
export class AppComponent {
username: string = '';
constructor(private http: HttpClient) { }
}
Implement methods to start the registration and login processes. These methods call the backend and handle the FIDO2 registration and authentication processes.
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');
}
ng build --configuration production
node index.js
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.