The Complete Guide to Angular User Authentication with Passkeys
Andy Agarwal
Posted on February 22, 2023
Passwordless authentication is a form of authentication that verifies a user’s identity without the use of passwords. There are many forms of passwordless authentication including, Email, SMS, and more importantly, Passkeys.
What are Passkeys?
A passkey, also known as a password or passphrase, is a secret word or phrase that is used to gain access to a system or application. Passkeys are typically used as a form of authentication to confirm the identity of a user and to protect sensitive information from unauthorized access.
Passkeys can be a combination of letters, numbers, and special characters and should be kept confidential to ensure the security of the system or application. The integration of passkey login into existing systems can make the process more convenient for users, as it can be automatically filled in by the browser or a password manager.
Passkeys can also provide additional protection against phishing attacks compared to SMS or app-based one-time passwords. However, it’s important to note that while passkeys are based on widely adopted protocols, the compatibility of a single implementation across different browsers and operating systems can vary.
In recent years, the use and adoption of passkeys has been increasing as multiple authentication services and platforms have begun to incorporate it as a method of secure user login.
In this article we’ll be looking at how to implement authentication in an Angular app using passkeys, let’s dive in.
Prerequisites
To follow along in this article, you’ll need the following:
- Basic knowledge of TypeScript and Angular
- Node.js installed; I’ll be using v16.13.0
- For a text editor, I recommend using VSCode
Set up Angular
To set up an angular project, we’ll use the Angular CLI. To install the Angular CLI, open a terminal window and run the following command:
npm install -g @angular/cli
Once the CLI has been successfully installed, to create a new workspace and initial starter app:
ng new passkeys-app-angular
This will prompt us for information about the features to be included in the initial app. We’ll accept the defaults by pressing the Enter key.
Once the installation is complete, we’ll quickly set up TailwindCSS for styling:
cd passkeys-app-angular
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init
Add the paths to all of our template files in the tailwind.config.js file:
// ./tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{html,ts}",
],
theme: {
extend: {},
},
plugins: [],
}
Then we add the @tailwind directives for each of Tailwind’s layers to our ./src/styles.css file:
@tailwind base;
@tailwind components;
@tailwind utilities;
.cta {
@apply bg-slate-800 border border-slate-900 text-slate-50 font-medium py-1.5 px-3 rounded-md;
}
.link {
@apply underline;
}
.form-control {
@apply flex flex-col gap-2;
}
.input {
@apply p-3 border border-gray-300 rounded-md;
}
.input[type="checkbox"] {
@apply bg-gray-100;
}
.site-logo {
@apply text-lg font-bold;
}
.site-nav .links {
@apply flex gap-8 items-center;
}
.site-header {
@apply sticky top-0 p-4 z-10;
}
.site-header > .wrapper {
@apply flex gap-8 justify-between items-center bg-slate-800 text-slate-50 p-4 px-6 rounded-lg;
}
.site-section,
.todo-header {
@apply p-4;
}
.site-section > .wrapper,
.todo-header > .wrapper {
@apply max-w-4xl mx-auto;
}
.form-group {
@apply flex gap-4;
}
.form-group > * {
@apply grow;
}
.todo-form {
@apply p-4 bg-slate-50 border border-slate-200 rounded-lg;
}
.todo-list {
@apply flex flex-col gap-4 my-6;
}
.todo-item {
@apply flex gap-4 items-center justify-between p-3 bg-slate-100 border border-slate-200 rounded-xl;
}
.todo-item__content {
@apply flex gap-4;
}
Now we can run the application with:
ng serve --open
And we should have something like this:
Next, we’ll set up the routes for our project.
Set up Routes
To use the auth elements in our application, let’s create the components for our application. To generate components run:
ng generate component login
ng generate component register
ng generate component todo
Once the components have been generated, we can configure our routes in ./src/app/app-routing.module.ts:
// ./src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LoginComponent } from './login/login.component';
const routes: Routes = [
{
path: '',
redirectTo: '/todo',
pathMatch: 'full',
},
{
path: 'register',
component: RegisterComponent
},
{
path: 'login',
component: LoginComponent
},
{
path: 'todo',
component: TodoComponent
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
Add Todo Service
We’ll also need to create a service that will handle the data in our to-do application. To create a service, run:
ng generate service todo
For this article, we’ll be using the DummyJSON API for our todo API - https://dummyjson.com/todos. Let’s set it up in our environment variables.
Set up environmental variables
Let’s save our API URL in our ./src/environments/environment.ts file:
export const environment = {
production: false,
todo: {
api: 'https://dummyjson.com/todos',
},
};
Now, we can access the API URL as variables in this file from the rest of our application.
Set up todo service functions
Next, we’ll create the functions which will be interfacing with our todo API. In our .src/app/todo.service.ts file:
// .src/app/todo.service.ts
import { Injectable } from '@angular/core';
import { environment } from './environments/environment';
/**
* Define todo type
*/
export interface Todo {
id?: number;
todo?: string;
completed: boolean;
userId?: number;
}
/**
* Define todos type
*/
export type Todos = Todo[];
@Injectable({
providedIn: 'root',
})
export class TodoService {
api = environment.todo.api;
/**
* function to add a todo
* @param todo - todo to add
* @returns added todo
*/
async addTodo(todo: Todo): Promise<Todo> {
const res = await fetch(`${this.api}/add`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(todo),
});
return await res.json();
}
/**
* function to list todos
* @returns list of todos
*/
async listTodos(): Promise<Todos> {
const res = await fetch(`${this.api}/user/1`);
const { todos } = await res.json();
return todos;
}
/**
* function to update a todo
* @param todo - todo to update
* @returns updated todo
*/
async updateTodo(todo: Todo): Promise<Todo> {
const res = await fetch(`${this.api}/update`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(todo),
});
return await res.json();
}
/**
* function to delete a todo
* @param id - id of todo to delete
* @returns deleted todo
*/
async deleteTodo(id: number): Promise<Todo> {
const res = await fetch(`${this.api}/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
return await res.json();
}
constructor() {}
}
Here we have functions to add, edit, list, and update todos with the API, these functions will be used in our todo component.
To implement the functions created in the todo service into the todo component, we’ll add the following in ./src/app/todo.component.ts:
// ./src/app/todo/todo.component.ts
import { Component, Input, OnInit } from '@angular/core';
import { Todo, Todos, TodoService } from '../todo.service';
@Component({
selector: 'app-todo',
templateUrl: './todo.component.html',
styleUrls: ['./todo.component.css'],
})
export class TodoComponent implements OnInit {
todos: Todos = [];
todo = '';
constructor(private todoService: TodoService) {}
// list todos on init
ngOnInit(): void {
this.listTodos();
}
/**
* function to update the todo body
* @param event event
*/
updateBody(event: any) {
this.todo = event.target.value;
console.log({ body: this.todo });
}
/**
* function to update the todo completed state
*/
updateCompleted(event: any) {
const { currentTarget } = event;
this.updateTodo({ id: currentTarget.id, completed: currentTarget.checked });
console.log({ currentTarget });
}
/**
* function to list todos
*/
listTodos() {
this.todoService.listTodos().then((todos) => {
this.todos = todos;
console.log({ todos });
});
}
/**
* function to add a todo
* @param event event
*/
addTodo(event: any) {
console.log({ event });
event.preventDefault();
const todo = {
todo: this.todo,
completed: false,
userId: 1,
};
this.todoService.addTodo(todo).then((todo) => {
this.todos.push(todo);
this.todo = '';
console.log({ todo });
});
}
/**
* function to update a todo
* @param param0 todo to update
*/
updateTodo({ id, completed }: Todo) {
this.todoService.updateTodo({ id, completed }).then((todo) => {
this.todos.push(todo);
console.log({ todo });
console.log({ todos: this.todos });
});
}
/**
* function to delete a todo
* @param id - id of todo to delete
*/
deleteTodo(id: number) {
this.todoService.deleteTodo(id).then((todo) => {
this.todos = this.todos.filter((todo) => todo.id !== id);
console.log({ todo });
});
}
}
Finally, we can make use of all these functions in our template - ./src/app/todo/todo.component.html:
<!-- ./src/app/todo/todo.component.html -->
<main>
<header class="todo-header">
<div class="wrapper">
<h1 class="font-bold text-2xl">My todo list</h1>
</div>
</header>
<section class="todo-section site-section">
<div class="wrapper">
<form (submit)="addTodo($event)" class="todo-form">
<div class="form-group">
<input
value="{{ todo }}"
(change)="updateBody($event)"
class="input"
type="text"
placeholder="Add a new todo"
/>
<button class="cta shrink !grow-0" type="submit">Add</button>
</div>
</form>
<ul class="todo-list">
<li class="todo-item" *ngFor="let todo of todos">
<div class="todo-item__content">
<input
id="{{ todo.id }}"
value=" {{ todo.id }} "
[checked]="todo.completed"
(change)="(updateCompleted)"
class="input"
type="checkbox"
/>
<label for=" {{ todo.id }} ">{{ todo.todo }}</label>
</div>
<button
(click)="deleteTodo(todo.id!)"
class="todo-item__delete cta"
type="button"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12L19 6.41Z"
fill="currentColor"
/>
</svg>
</button>
</li>
</ul>
</div>
</section>
</main>
With that, we should have something like this:
Awesome.
So far we’ve been able to build out the main functionality of our app but we only want authenticated users to be able to visit this page. We have to give users a way to log in to the application. Let’s work on that.
Set up WebAuthn Server
For the purpose of this tutorial, we’ll be using the SimpleWebAuthn project, which is a collection of packages and examples that will help us get started with WebAuthn easily.
To get started, we’ll install the Simple WebAuthn project which provides us with an example WebAuthn Server we can use for our project.
Clone SimpleWebAuthn
Run:
git clone https://github.com/MasterKale/SimpleWebAuthn.git
Once cloned, navigate to the ./example directory and install the project by running:
npm install
Once the installation is complete, let’s install a few dependencies that will help us use this server for our Angular frontend:
npm install cors uuid
Here we’re installing CORS and UUID to allow us to access our backend from any origin and generate unique demo IDs for users respectively.
In the following sections, we’ll see how we can modify the example code for use in our project.
It should be noted that we’re only building a demo server that does not have database access. The SimpleWebAuthn example project is intended to be a practical reference for implementing the SimpleWebAuthn libraries to add WebAuthn-based Two-Factor Authentication (2FA) support to your website.
You can learn more about the example project, the Server and Browser packages, and how to properly implement it in a standard project on the SimpleWebAuthn docs.
Import Packages
First, let’s import the cors and uuid packages, and enter the following in ./example/index.ts:
// ./index.ts
// ...
import cors from "cors";
import {v4 as uuidv4} from "uuid"
Then, below, we can use the cors package in the application:
// ./index.ts
// ...
app.use(express.static('./public/'));
app.use(express.json());
app.use(cors());
Registration
The example project has a registration endpoint that generates registration endpoints that will be used on the browser to start the passkey registration process. Currently, the example uses a preset username and ID for each registration. We want to be able to define the username from the client side and generate an ID that will mock a real user ID from the backend.
First, we change the const loggedInUserId = "internalUserId
" to let i.e let loggedInUserId = 'internalUserId';
Next, in our '/generate-registration-options' GET route in ./example/index.ts, we get the username from the request query and add it to our inMemoryUserDeviceDB with a generated unique ID:
// ./index.ts
// ...
app.get('/generate-registration-options', (req, res) => {
const _username = req.query.username as string;
loggedInUserId = uuidv4();
inMemoryUserDeviceDB[loggedInUserId] = {
id: loggedInUserId,
username: _username,
devices: [],
currentChallenge: undefined,
};
const user = inMemoryUserDeviceDB[loggedInUserId];
// ...
});
Verify Registration
The '/verify-registration' endpoint listens to a POST request with a body containing passkey registration data generated from the client. We also want to get the username from the request alongside the registration data. To do that, we modify the endpoint:
// ./index.ts
// ...
app.post('/verify-registration', async (req, res) => {
const body = req.body;
const id = req.body.id as string;
const registration: RegistrationCredentialJSON = body.attestationResponse;
const user = inMemoryUserDeviceDB[id];
const expectedChallenge = user.currentChallenge;
let verification: VerifiedRegistrationResponse;
try {
const opts: VerifyRegistrationResponseOpts = {
credential: registration,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin,
expectedRPID: rpID,
requireUserVerification: true,
};
verification = await verifyRegistrationResponse(opts);
} catch (error) {
const _error = error as Error;
console.error(_error);
return res.status(400).send({ error: _error.message });
}
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
const { credentialPublicKey, credentialID, counter } = registrationInfo;
const existingDevice = user.devices.find(device => device.credentialID.equals(credentialID));
if (!existingDevice) {
/**
* Add the returned device to the user's list of devices
*/
const newDevice: AuthenticatorDevice = {
credentialPublicKey,
credentialID,
counter,
transports: registration.transports,
};
user.devices.push(newDevice);
}
}
res.send({ verified });
});
Authentication
The '/generate-authentication-options' generates authentication options and we want to get the username from the request query and get the user that matches that username to authenticate against:
// ./index.ts
// ...
app.get('/generate-authentication-options', (req, res) => {
const username = req.query.username as string;
// get user by username
const user = Object.values(inMemoryUserDeviceDB).find(user => user.username === username) as LoggedInUser;
// ...
});
Verify Authentication
The '/verify-authentication' endpoint verifies the passkey authentication data, we want to get the username and authentication data from the request body so we replace the endpoint with the following code:
// ./index.ts
// ...
app.post('/verify-authentication', async (req, res) => {
const body = req.body;
const username = body.username as string;
const authentication: AuthenticationCredentialJSON = body.assertionResponse;
// get user by username
const user = Object.values(inMemoryUserDeviceDB).find(user => user.username === username) as LoggedInUser;
const expectedChallenge = user.currentChallenge;
let dbAuthenticator;
const bodyCredIDBuffer = base64url.toBuffer(authentication.rawId);
// "Query the DB" here for an authenticator matching `credentialID`
for (const dev of user.devices) {
if (dev.credentialID.equals(bodyCredIDBuffer)) {
dbAuthenticator = dev;
break;
}
}
if (!dbAuthenticator) {
return res.status(400).send({ error: 'Authenticator is not registered with this site' });
}
let verification: VerifiedAuthenticationResponse;
try {
const opts: VerifyAuthenticationResponseOpts = {
credential: authentication,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin,
expectedRPID: rpID,
authenticator: dbAuthenticator,
requireUserVerification: true,
};
verification = await verifyAuthenticationResponse(opts);
} catch (error) {
const _error = error as Error;
console.error(_error);
return res.status(400).send({ error: _error.message });
}
const { verified, authenticationInfo } = verification;
if (verified) {
// Update the authenticator's counter in the DB to the newest count in the authentication
dbAuthenticator.counter = authenticationInfo.newCounter;
}
res.send({ verified, user});
});
Finally, we create a new '/user
' route which gets the currently logged-in user by its ID
Note that in a real application, you might want to use a more secure means like a JWT or another method to get a logged-in user since we’re only using the user id to check if a user is logged in, in this example.
// get user by id
app.get('/user', (req, res) => {
try {
const id = req.query.id as string;
const user = inMemoryUserDeviceDB[id];
console.log({user});
if(!user) throw new Error('User not found')
res.send(user);
} catch (error) {
console.log({error});
res.status(400).json(error);
}
});
With that, we’ve successfully built a custom WebAuthn server that we can use for our angular application.
Add to Environmental Variables
Back in our angular application, let’s save our API URLs in our ./src/environments/environment.ts file:
export const environment = {
production: false,
api: 'http://localhost:8000/',
todo: {
api: 'https://dummyjson.com/todos',
},
};
Now, we can access the WebAuthn API URL as variables in this file from the rest of our application.
Set up WebAuthn Browser Package
We’ll be using the eSimpleWebAuthn Browser package to help us communicate with our WebAuthn Server. Run the following to install it in our angular project:
npm install @simplewebauthn/browser @simplewebauthn/typescript-types
Create Auth Service
Next, we generate our auth service:
ng generate service auth
Once that’s completed, we’ll create a function in our auth service that checks if the user is logged in or not.
Check if the user is authenticated
In our ./src/app/auth-service.ts, we’ll create an isLoggedIn() function which returns true or false depending on if the userInfo() function returns the user information after calling the '/user
' endpoint in the API returns user data:
// ./src/app/auth.service.ts
import { Injectable } from '@angular/core';
import { environment } from './environments/environment';
export type User = {
id: string;
name: string;
displayName: string;
};
@Injectable({
providedIn: 'root',
})
export class AuthService {
_userData: User = { id: '', name: '', displayName: '' };
API = environment.api;
userData: User = this._userData;
// function to check if user is logged in
async isLoggedin(): Promise<boolean> {
try {
// get user data
this.userData = await this.userInfo();
// console.log({ userData: this.userData });
if (!this.userData.id) throw new Error('no user id, not logged in');
return true;
// A valid JWT is in place so that the user object was able to be fetched.
} catch (error) {
console.log({ error });
return false;
}
}
// function to get user data
async userInfo() {
try {
// get user id from cookie
let id = '';
const cookies = document.cookie;
cookies.split('; ').forEach((cookie) => {
const [key, value] = cookie.split('=');
if (key === 'id') {
id = value;
}
});
// get user data from api
const res = await fetch(`${this.API}/user?id=${id || this.userData.id}`);
const user = await res.json();
// set user data in service
this.userData = {
displayName: user.username,
id: user.id,
name: user.username,
};
// return user data
return this.userData;
} catch (error) {
console.log({ error, user: this.userData });
return this.userData;
}
}
// function to log user out
logOut() {
// remove user id from cookie
document.cookie = 'id=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// reset user data
this.userData = this._userData;
}
constructor() {}
}
Here, we also have the logOut() function which removes the "id
" key from the website cookies and resets the user data.
Show user information in site header
Since we can get some user information if the user is logged in, let’s display it for them, specifically, the username used. In ./src/app/app.component.ts, we’ll import the user data from the auth service and add it to our component variables user:
// ./src/app/app.component.ts
import { Component } from '@angular/core';
import { AuthService, User } from './auth.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {
title = 'passkeys-app-angular';
user: User = {
id: '',
name: '',
displayName: '',
};
constructor(private authService: AuthService, private router: Router) {
router.events.subscribe((val) => {
this.user = this.authService.userData;
});
}
async ngOnInit(): Promise<void> {
// get user data when component loads
this.user = await this.authService.userInfo();
console.log({ user: this.user });
}
}
Here, we also subscribe to router events within the constructor in order to set the component user data to that of the service.
Now, we can display it in the template - ./src/app/app.component.html
<!-- ./src/app/app.component.html -->
<header class="site-header">
<div class="wrapper">
<a routerLink="/">
<figure class="site-logo">
<span>Todo</span>
</figure>
</a>
<nav class="site-nav">
<div class="wrapper">
<ul class="links">
<li class="link">
<a routerLink="/todo">Todo</a>
</li>
<li *ngIf="!user.id" class="link">
<a routerLink="/register">Register</a>
</li>
<li *ngIf="!user.id" class="link">
<a routerLink="/login">Login</a>
</li>
<li *ngIf="user.id" class="link">
<span>
{{ user.name }}
</span>
</li>
</ul>
</div>
</nav>
</div>
</header>
<router-outlet></router-outlet>
With that, we should have something like this:
Add Register Functionality
Now, with our Auth service set up, we can add our register functionality in our ./src/app/login/register.component.ts page:
// ./src/app/login/register.component.ts
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { startRegistration } from '@simplewebauthn/browser';
import { AuthService, User } from '../auth.service';
import { environment } from '../environments/environment';
@Component({
selector: 'app-register',
templateUrl: './register.component.html',
styleUrls: ['./register.component.css'],
})
export class RegisterComponent implements OnInit {
// define component state
API = environment.api;
userData: User = {
id: '',
name: '',
displayName: '',
};
username = '';
attestationResponse = {};
isLoading = false;
ngOnInit(): void {}
constructor(private router: Router, private authService: AuthService) {}
// function to update username
updateUsername(event: any) {
this.username = event.target.value;
console.log({ username: this.username });
}
// function to generate registration options
async generateRegistrationOptions() {
try {
const res = await fetch(
`${this.API}/generate-registration-options?username=${this.username}`
);
const creationOptions = await res.json();
this.userData = creationOptions.user;
console.log({ creationOptions, user: this.authService.userData });
try {
this.attestationResponse = await startRegistration(creationOptions);
console.log({ attestationResponse: this.attestationResponse });
} catch (error) {
console.log({ error });
throw error;
}
} catch (error) {
console.log({ error });
}
}
// function to verify registration
async verifyRegistration() {
try {
const res = await fetch(`${this.API}/verify-registration`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: this.userData.id,
attestationResponse: this.attestationResponse,
}),
});
const verification = await res.json();
console.log({ verification });
if (verification.verified) {
alert('Registration successful! Proceed to login');
this.authService.userData.name = this.userData.name;
// redirect to login
this.router.navigate(['/login']);
} else {
alert('Registration failed! Please try again.');
}
} catch (error) {
console.log({ error });
}
}
// function to register
async register(event: any) {
event.preventDefault();
this.isLoading = true;
await this.generateRegistrationOptions();
await this.verifyRegistration();
this.isLoading = false;
}
}
Here, our register function calls generateRegistrationOptions() which gets the username and makes a request to the '/generate-registration-options
' endpoint of our API which then returns some data which we assign to creationOptions. We also obtain the user data that includes the ID which we’ll use to make the registration verification request.
creationOptions is then passed into the startRegistration() function provided by the SimpleAuthn Browser package. This returns data that is passed to this.attestationResponse which we then use in the verifyRegistration() function along with the user ID.
register also calls verifyRegistration() to make the verification request. When the the request is made and verification is successful, we then save the username to the service so that when the page redirects to login for authentication, the user doesn’t have to type in the username again.
Here’s what our template code looks like in ./src/app/register/register.component.html:
<!-- ./src/app/register/register.component.html -->
<section class="site-section">
<div class="wrapper">
<header class="my-6">
<h1 class="font-bold text-4xl">Register</h1>
</header>
<form (submit)="register($event)" class="register-form flex flex-col gap-4">
<div class="form-control">
<label for="username">Username</label>
<input
value="{{ username }}"
(change)="updateUsername($event)"
type="text"
type="username"
name="username"
class="input"
required
/>
</div>
<div class="action-cont">
<button class="cta" type="submit">
{{ isLoading ? "Loading..." : "Register" }}
</button>
</div>
<p>Or <a routerLink="/login" class="link">Login</a></p>
</form>
</div>
</section>
With that, we should have something like this:
Add Login Functionality
For the log in, we have something similar, only we’ll be using the authentication endpoints this time. So in our ./src/app/login/login.component.ts file, we’ll create a few functions for authentiation:
// ./src/app/login/login.component.ts
import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { AuthService } from '../auth.service';
import { startAuthentication } from '@simplewebauthn/browser';
import { environment } from '../environments/environment';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.css'],
})
export class LoginComponent {
API = environment.api;
username = this.authService.userData.name || '';
assertionResponse = {};
isLoading = false;
constructor(private router: Router, private authService: AuthService) {}
// function to update username
updateUsername(event: any) {
this.username = event.target.value;
console.log({ username: this.username });
}
// function to generate authentication options
async generateAuthenticationOptions() {
try {
const res = await fetch(
`${this.API}/generate-authentication-options?username=${this.username}`
);
const authOptions = await res.json();
try {
this.assertionResponse = await startAuthentication(authOptions);
console.log({ assertionResponse: this.assertionResponse });
} catch (error) {
console.log({ error });
throw error;
}
} catch (error) {
console.log({ error });
}
}
// function to verify authentication
async verifyAuthentication() {
try {
const res = await fetch(`${this.API}/verify-authentication`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: this.username,
assertionResponse: this.assertionResponse,
}),
});
const data = await res.json();
// if authentication is successful, set user data and call the redirect function
if (data.verified) {
this.authService.userData = {
id: data.user.id,
name: data.user.username,
displayName: data.user.username,
};
alert(`Authentication successful! Welcome back ${data.user.username}!`);
this.redirectAfterLogin(data);
} else {
alert('Authentication failed. Please try again.');
}
} catch (error) {
console.log({ error });
}
}
// function to authenticate user on form submit
async authenticate(event: any) {
event.preventDefault();
this.isLoading = true;
await this.generateAuthenticationOptions();
await this.verifyAuthentication();
// get user id and set cookie
let id = this.authService.userData.id;
document.cookie = `id=${id}`;
console.log({ id, cookie: document.cookie });
this.isLoading = false;
}
redirectAfterLogin(event: any) {
console.log('redirecting after login', { event });
// call loggedIn frunction to update auth service state
this.authService.isLoggedin();
this.router.navigate(['/todo']);
}
}
Here, we have the generateAuthenticationOptions() function which makes a request to the '/generate-authentication-options
' endpoint. The edpoint returns some data which we pass into authOptions. Then we pass this data in to the startAuthentication() function to start the authentication process.
Once completed, the authenticate function calls verifyAuthentication() which verifies the assertionResponse which was generated by the startAuthentication function.
Once the verification is successful, we redirect to the todo page and save the user data in the auth service and the user id as a cookie, which will be used by the userInfo() function in the auth service to get user information.
Implement Auth Guards
In this section, in order to limit access to the todo application to only authenticated users, we need to implement an auth guard that checks if a condition is met during routing and allows the routing to continue or prevent it.
We’ll generate an auth guard by running:
ng generate guard auth
We should be presented with a prompt asking us which guard to implement, we’ll go with CanActivate:
$ ng generate guard auth
? Which interfaces would you like to implement? CanActivate
CREATE src/app/auth.guard.spec.ts (331 bytes)
CREATE src/app/auth.guard.ts (457 bytes)
Set up route guard to redirect the user if not authenticated
Now, we’ll set up our auth guard in ./src/app/auth.guard.ts that runs the isLoggedIn() function provided by our auth service.
// ./src/app/auth.guard.ts
import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
import { AuthService } from './auth.service';
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate {
constructor(private authService: AuthService, private router: Router) {}
async canActivate(): Promise<boolean> {
if (await this.authService.isLoggedin()) {
return true;
} else {
this.router.navigate(['/login']);
return false;
}
}
}
Here, within the canActive() function, we return true to allow the normal routing process if a user is logged in, if not, we’ll redirect to the login page and return false to cancel normal routing.
Finally, for the route guard we just set up to work, we need to configure our routing in ./src/app/app-routing.module.ts to use the route guard:
// ./src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from './auth.guard';
import { LoginComponent } from './login/login.component';
import { RegisterComponent } from './register/register.component';
import { TodoComponent } from './todo/todo.component';
const routes: Routes = [
{
path: '',
redirectTo: '/todo',
pathMatch: 'full',
},
{
path: 'login',
component: LoginComponent,
},
{
path: 'register',
component: RegisterComponent,
},
{
path: 'todo',
component: TodoComponent,
canActivate: [AuthGuard],
},
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule],
})
export class AppRoutingModule {}
Here, we added the canActivate property with the AuthGuard value to the 'todo' path in order to apply the route guard to the path. Here, you can see that we’ve redirected to /login and can’t access /todo since we’re not authenticated.
Add Logout Functionality
Logout is pretty straightforward as all we have to do is to call the logOut() function in ./src/app/auth.service.ts to remove the user ID from cookies. We’ll create a function inin ./src/app/app.component.html:
// ./src/app/app.component.ts
import { AuthService, User } from './auth.service';
// ...
export class AppComponent {
title = 'passkeys-app-angular';
user: User = {
id: '',
name: '',
displayName: '',
};
// ...
// function to log user out
logOut() {
// remove user id from cookie
document.cookie = 'id=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// reset user data
this.userData = this._userData;
alert("You've been logged out. Please log in again to continue.");
}
}
Now, we can add a button in our site header in ./src/app/app.component.html that appears when the user is logged in. The button will call the logout function when clicked:
<!-- ./src/app/app.component.html -->
<header class="site-header">
<div class="wrapper">
<!-- ... -->
<nav class="site-nav">
<div class="wrapper">
<ul class="links">
<!-- ... -->
<li *ngIf="user.id" class="link">
<button class="cta" (click)="logOut()">Logout</button>
</li>
</ul>
</div>
</nav>
</div>
</header>
<router-outlet></router-outlet>
With that, we should have our complete passkeys authentication flow:
https://www.dropbox.com/s/nnzp0pqi1g1re4q/1f045a7d-3116-4b7d-836a-fb23915ab523_x264.mp4?dl=0
Conclusion
So far, we’ve covered the basics of authentication in Angular using passkeys by creating a simple application with passwordless registration and login. To achieve this without any third-party library or service, we made use of the SimpleWebAuthn Server and Browser packages in our backend and frontend respectively.
Further reading and resources
- Passwordless login with passkeys
- Use cases of Passkey authentication
- What are passkeys?
- WebAuthn Guide
- Web Authentication API
- SimpleWebAuthn - A collection of TypeScript-first libraries for simpler WebAuthn integration
Resources
Here, you’ll find a few resources to help you out:
Posted on February 22, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.