The Complete Guide to Angular User Authentication with Passkeys

andy789

Andy Agarwal

Posted on February 22, 2023

The Complete Guide to Angular User Authentication with Passkeys

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
Enter fullscreen mode Exit fullscreen mode

Once the CLI has been successfully installed, to create a new workspace and initial starter app:

ng new passkeys-app-angular
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: [],
    }
Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

Now we can run the application with:

  ng serve --open
Enter fullscreen mode Exit fullscreen mode

And we should have something like this:

angular preview

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
Enter fullscreen mode Exit fullscreen mode

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 { }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
      },
    };
Enter fullscreen mode Exit fullscreen mode

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() {}
    }
Enter fullscreen mode Exit fullscreen mode

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 });
        });
      }
    }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

With that, we should have something like this:

todo list

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
Enter fullscreen mode Exit fullscreen mode

Once cloned, navigate to the ./example directory and install the project by running:

npm install
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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];    
      // ...

    });
Enter fullscreen mode Exit fullscreen mode

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 });
    });
Enter fullscreen mode Exit fullscreen mode

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;

      // ...
    });
Enter fullscreen mode Exit fullscreen mode

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});
    });
Enter fullscreen mode Exit fullscreen mode

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);
      }
    });

Enter fullscreen mode Exit fullscreen mode

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',
      },
    };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create Auth Service

Next, we generate our auth service:

ng generate service auth
Enter fullscreen mode Exit fullscreen mode

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() {}
    }

Enter fullscreen mode Exit fullscreen mode

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 });
      }
    }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

With that, we should have something like this:

todo-list-updated

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;
      }
    }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

With that, we should have something like this:

register

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']);
      }
    }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
        }
      }
    }
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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.");
      }
    }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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

Resources

Here, you’ll find a few resources to help you out:

💖 💪 🙅 🚩
andy789
Andy Agarwal

Posted on February 22, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related