8

Designing Authentication and Authorization

Designing a high-quality authentication and authorization system without frustrating the end user is a difficult problem to solve. Authentication is the act of verifying the identity of a user, and authorization specifies the privileges that a user must have to access a resource. Both processes, auth for short, must seamlessly work in tandem to address the needs of users with varying roles, needs, and job functions.

On today's web, users have a high baseline level of expectations from any auth system they encounter through the browser, so this is an important part of your application to get absolutely right the first time. The user should always be aware of what they can and can't do in your application. If there are errors, failures, or mistakes, the user should be clearly informed about why they occurred. As your application grows, it will be easy to miss all the ways that an error condition could be triggered. Your implementation should be easy to extend or maintain, otherwise this basic backbone of your application will require a lot of maintenance. In this chapter, we will walk through the various challenges of creating a great auth UX and implement a solid baseline experience.

We will continue the router-first approach to designing SPAs by implementing the auth experience of LemonMart. In Chapter 7, Creating a Router-First Line-of-Business App, we defined user roles, finished our build-out of all major routing, and completed a rough walking-skeleton navigation experience of LemonMart. This means that we are well prepared to implement a role-based conditional navigation experience that captures the nuances of a seamless auth experience.

In this chapter, we will implement a token-based auth scheme around the User entity that we defined in the last chapter. For a robust and maintainable implementation, we will deep dive into object-oriented programming (OOP) with abstraction, inheritance, and factories, along with implementing a cache service, a UI service, and two different auth schemes: an in-memory fake auth service for educational purposes and a Google Firebase auth service that you can leverage in real-world applications.

In this chapter, you will learn about the following topics:

  • Designing an auth workflow
  • TypeScript operators for safe data handling
  • Reusable services leveraging OOP concepts
  • Dynamic UI components and navigation
  • Role-based routing using guards
  • Firebase authentication recipe
  • Providing a service using a factory

The most up-to-date versions of the sample code for the book are on GitHub at the linked repository that follows. The repository contains the final and completed state of the code. You can verify your progress at the end of this chapter by looking for the end-of-chapter snapshot of code under the projects folder.

For Chapter 8:

  1. Clone the repository https://github.com/duluca/lemon-mart
  2. Execute npm install on the root folder to install dependencies
  3. The code sample for this chapter is under the sub-folder
    projects/ch8
    
  4. To run the Angular application for this chapter, execute
    npx ng serve ch8
    
  5. To run the Angular unit tests for this chapter, execute
    npx ng test ch8 --watch=false
    
  6. To run Angular e2e tests for this chapter, execute
    npx ng e2e ch8
    
  7. To build a production-ready Angular application for this chapter, execute
    npx ng build ch8 --prod
    

Note that the dist/ch8 folder at the root of the repository will contain the compiled result.

Be aware that the source code in the book or on GitHub may not always match the code generated by Angular CLI. There may also be slight differences in implementation between the code in the book and what's on GitHub because the ecosystem is ever-evolving. It is natural for the sample code to change over time. Also, on GitHub, expect to find corrections, fixes to support newer versions of libraries, or side-by-side implementations of multiple techniques for you to observe. You are only expected to implement the ideal solution recommended in the book. If you find errors or have questions, please create an issue or submit a pull request on GitHub for the benefit of all readers.

Let's start with going over how a token-based auth workflow functions.

Designing an auth workflow

A well-designed authentication workflow is stateless so that there's no concept of an expiring session. Users are free to interact with your stateless REST APIs from as many devices and tabs as they wish, simultaneously or over time. JSON Web Token (JWT) implements distributed claims-based authentication that can be digitally signed or integration that is protected and/or encrypted using a Message Authentication Code (MAC). This means that once a user's identity is authenticated (that is, a password challenge on a login form), they receive an encoded claim ticket or a token, which can then be used to make future requests to the system without having to reauthenticate the identity of the user.

The server can independently verify the validity of this claim and process the requests without requiring any prior knowledge of having interacted with this user. Thus, we don't have to store session information regarding a user, making our solution stateless and easy to scale. Each token will expire after a predefined period and due to their distributed nature, they can't be remotely or individually revoked; however, we can bolster real-time security by interjecting custom account and user role status checks to ensure that the authenticated user is authorized to access server-side resources.

JWTs implement the Internet Engineering Task Force (IETF) industry standard RFC 7519, found at https://tools.ietf.org/html/rfc7519.

A good authorization workflow enables conditional navigation based on a user's role so that users are automatically taken to the optimal landing screen; they are not shown routes or elements that are not suitable for their roles and if, by mistake, they try to access a restricted path, they are prevented from doing so. You must remember that any client-side role-based navigation is merely a convenience and is not meant for security. This means that every call made to the server should contain the necessary header information, with the secure token, so that the user can be reauthenticated by the server and their role independently verified. Only then will they be allowed to retrieve secured data. Client-side authentication can't be trusted, which is why password reset screens must be built with a server-side rendering technology so that both the user and the server can verify that the intended user is interacting with the system.

JWT life cycle

JWTs complement a stateless REST API architecture with an encrypted token mechanism that allows convenient, distributed, and high-performance authentication and authorization of requests sent by clients. There are three main components of a token-based authentication scheme:

  • Client-side: Captures login information and hides disallowed actions for a good UX
  • Server-side: Validates that every request is both authenticated and has the proper authorization
  • Auth service: Generates and validates encrypted tokens, and independently verifies the auth status of user requests from a data store

A secure system presumes that data sent/received between clients (applications and browsers), systems (servers and services), and databases is encrypted using transport layer security (TLS), which is essentially a newer version of secure sockets layer (SSL). This means that your REST API must be hosted with a properly configured SSL certificate, serving all API calls over HTTPS, so that user credentials are never exposed between the client and the server. Similarly, any database or third-party service call should happen over TLS. This ensures the security of the data in transit.

At-rest (when the data is sitting in the database) passwords should be stored using a secure one-way hashing algorithm with good salting practices.

Did all the talk of hashing and salting make you think of breakfast? Unfortunately, they're cryptography-related terms. If you're interested in learning more, check out this article: https://crackstation.net/hashing-security.htm.

Sensitive user information, such as personally identifiable information (PII), should be encrypted at rest with a secure two-way encryption algorithm, unlike passwords. Passwords are hashed, so we verify that the user is providing the same password without the system knowing what the password is. With PII, we must be able to decrypt the data so that we can display it to the user. But since the data is encrypted at rest, if the database is compromised then the hacked data is worthless.

Following a layered approach to security is critical, because attackers will need to accomplish the unlikely feat of compromising all layers of your security at the same time to cause meaningful harm to your business.

Fun fact: When you hear about massive data breaches from major corporations, most of the time the root cause is a lack of proper implementation of in-transit or at-rest security. Sometimes this is because it is too computationally expensive to continually encrypt/decrypt data, so engineers rely on being behind firewalls. In that case, once the outer perimeter is breached, as they say, the fox has access to the hen house.

Consider the following sequence diagram, which highlights the life cycle of JWT-based authentication:

Figure 8.1: The life cycle of JWT-based authentication

Initially, a user logs in by providing their username and password. Once validated, the user's authentication status and role are encrypted in a JWT with an expiration date and time, and it is sent back to the browser.

Our Angular (or any other) application can cache this token in local or session storage securely so that the user isn't forced to log in with every request. This way, we don't resort to insecure practices like storing user credentials in cookies to provide a good UX.

You will get a better understanding of the JWT life cycle when you implement your own auth service later in this chapter. In the following sections, we will design a fully featured auth workflow around the User data entity, as follows:

Figure 8.2: The User entity

The User entity described is slightly different to our initial entity model. The entity model reflects how data is stored in the database. The entity is a flattened (or simplified) representation of the user record. Even a flattened entity has complex objects, like name, which has properties for first, middle, and last. Furthermore, not all properties are required. Additionally, when interacting with auth systems and other APIs, we may receive incomplete, incorrect, or maliciously formed data, so our code will have to effectively deal with null and undefined variables.

Next, let's see how we can leverage TypeScript operators to effectively deal with unexpected data.

TypeScript operators for safe data handling

JavaScript is a dynamically typed language. At runtime, the JavaScript engine executing our code, like Chrome's V8, doesn't know the type of the variable we're using. As a result, the engine must infer the type. We can have basic types like boolean, number, array, or string, or we can have a complex type, which is essentially a JSON object. In addition, variables can be null or undefined. In broad terms, undefined represents something that hasn't been initialized and null represents something that isn't currently available.

In strongly typed languages, the concept of undefined doesn't exist. Basic types have default values, like a number is a zero or a string is an empty string. However, complex types can be null. A null reference means that the variable is defined, but there's no value behind it.

The inventor of the null reference, Tony Hoare, called it his "billion-dollar mistake."

TypeScript brings the concepts of strongly typed languages to JavaScript, so it must bridge the gap between the two worlds. As a result, TypeScript defines types like null, undefined, any, and never to make sense of JavaScript's type semantics. I've added links to relevant TypeScript documentation in the Further reading section for a deeper dive into TypeScript types.

As the TypeScript documentation puts it, TypeScript treats null and undefined differently in order to match the JavaScript semantics. For example, the union type string | null is a different type than string | undefined and string | undefined | null.

There's another nuance: checking to see whether a value equals null using == versus ===. Using the double equals operator, checking that foo != null means that foo is defined and not null. However, using the triple equals operator, foo !== null means that foo is not null, but could be undefined. However, these two operators don't consider the truthiness of the variable, which includes the case of an empty string.

These subtle differences have a great impact on how you write code, especially when using the strict TypeScript rules that are applied when you create your Angular application using the --strict option. It is important to remember that TypeScript is a development time tool and not a runtime tool. At runtime, we're still dealing with the realities of a dynamically typed language. Just because we declared a type to be a string, it doesn't mean that we will receive a string.

Next, let's see how we can deal with issues related to working with unexpected values.

Null and undefined checking

When working with other libraries or dealing with information sent or received outside of your application, you must deal with the fact that the variable you receive might be null or undefined.

Outside of your application means dealing with user input, reading from a cookie or localStorage, URL parameters from the router, or an API call over HTTP, to name a few examples.

In our code, we mostly care about the truthiness of a variable. This means that a variable is defined, not null, and if it's a basic type, it has a non-default value. Given a string, we can check whether the string is truthy with a simple if statement:

example
const foo: string = undefined
if(foo) {
  console.log('truthy')
} else {
  console.log('falsy')
}

If foo is null, undefined, or an empty string, the variable will be evaluated as falsy. For certain situations, you may want to use the conditional or ternary operator instead of if-else.

The conditional or ternary operator

The conditional or ternary operator has the ?: syntax. On the left-hand side of the question mark, the operator takes a conditional statement. On the right-hand side, we provide the outcomes for true and false around the colon: conditional ? true-outcome : false-outcome. The conditional or ternary operator is a compact way to represent if-else conditions, and can be very useful for increasing the readability of your code base. This operator is not a replacement for an if-else block, but it is great when you're using the output of the if-else condition.

Consider the following example:

example
const foo: string = undefined
let result = ''
if(foo) {
  result = 'truthy'
} else {
  result = 'falsy'
}
console.log(result)

The preceding if-else block can be re-written as:

example
const foo: string = undefined
console.log(foo ? 'truthy' : 'falsy')

In this case, the conditional or ternary operator makes the code more compact and easier to understand at a glance. Another common scenario is returning a default value, where the variable is falsy.

We will consider the null coalescing operator next.

The null coalescing operator

The null coalescing operator is ||. This operator saves us from repetition, when the truthy result of the conditional is the same as the conditional itself.

Consider the example where if foo is defined, we would like to use the value of foo, but if it is undefined, we need a default value of 'bar':

example
const foo: string = undefined
console.log(foo ? foo : 'bar')

As you can see, foo is repeated twice. We can avoid the duplication by using the null coalescing operator:

example
const foo: string = undefined
console.log(foo || 'bar')

So, if foo is undefined, null or an empty string, bar will be output. Otherwise, the value of foo will be used. But in some cases, we need to only use the default value if the value is undefined or null. We will consider the nullish coalescing operator next.

The nullish coalescing operator

The nullish coalescing operator is ??. This operator is like the null coalescing operator, with one crucial difference. Checking the truthiness of a variable is not enough when dealing with data received from an API or user input, where an empty string may be a valid value. As we covered earlier in this section, checking for null and undefined is not as straightforward as it seems. But we know that by using the double equals operator, we can ensure that foo is defined and not null:

example
const foo: string = undefined
console.log(foo != null ? foo : 'bar')

In the preceding case, if foo is an empty string or another value, we will get the value of foo output. If it is null or undefined, we will get 'bar'. A more compact way to do this is by using the nullish coalescing operator:

example
const foo: string = undefined
console.log(foo ?? 'bar')

The preceding code will yield the same result as the previous example. However, when dealing with complex objects, we need to consider whether their properties are null or undefined as well. For this, we will consider the optional chaining operator.

Optional chaining

The optional chaining operator is ?. It is like Angular's safe navigation operator, which was covered in Chapter 3, Creating a Basic Angular App. Optional chaining ensures that a variable or property is defined and not null before attempting to access a child property or invoke a function. So the statement foo?.bar?.callMe() executes without throwing an error, even if foo or bar is null or undefined.

Consider the User entity, which has a name object with properties for first, middle, and last. Let's see what it would take to safely provide a default value of an empty string for a middle name using the nullish coalescing operator:

example
const user = {
  name: {
    first: 'Doguhan',
    middle: null,
    last: 'Uluca'
  } 
}
console.log((user && user.name && user.name.middle) ?? '')

As you can see, we need to check whether a parent object is truthy before accessing a child property. If middle is null, an empty string is output. Optional chaining makes this task simpler:

example
console.log(user?.name?.middle ?? '')

Using optional chaining and the nullish coalescing operator together, we can eliminate repetition and deliver robust code that can effectively deal with the realities of JavaScript's dynamic runtime.

So, when designing your code, you have to make decisions on whether to introduce the concept of null to your logic or work with default values like empty strings. In the next section, as we implement the User entity, you will see how these choices play out. So far, we have only used interfaces to define the shape of our data. Next, let's build the User entity, leveraging OOP concepts like classes, enums, and abstraction to implement it, along with an auth service.

Reusable services leveraging OOP concepts

As mentioned, we have only worked with interfaces to represent data. We still want to continue using interfaces when passing data around various components and services. Interfaces are great for describing the kind of properties or functions an implementation has, but they suggest nothing about the behavior of these properties or functions.

With ES2015 (ES6), JavaScript gained native support for classes, which is a crucial concept of the OOP paradigm. Classes are actual implementations of behavior. As opposed to just having a collection of functions in a file, a class can properly encapsulate behavior. A class can then be instantiated as an object using the new keyword.

TypeScript takes the ES2015 (and beyond) implementation of classes and introduces necessary concepts like abstract classes, private, protected, and public properties, and interfaces to make it possible to implement OOP patterns.

OOP is an imperative programming style, compared to the reactive programming style that RxJS enables. Classes form the bedrock of OOP, whereas observables do the same for reactive programming using RxJS.

I encourage you to become familiar with OOP terminology. Please see the Further reading section for some useful resources. You should become familiar with:

  1. Classes versus objects
  2. Composition (interfaces)
  3. Encapsulation (private, protected, and public properties, and property getters and setters)
  4. Polymorphism (inheritance, abstract classes, and method overriding)

As you know, Angular uses OOP patterns to implement components and services. For example, interfaces are used to implement life cycle hooks such as OnInit. Let's see how these patterns are implemented within the context of JavaScript classes.

JavaScript classes

In this section, I will demonstrate how you can use classes in your own code design to define and encapsulate the behavior of your models, such as the User class. Later in this chapter, you will see examples of class inheritance with abstract base classes, which allows us to standardize our implementation and reuse base functionality in a clean and easy-to-maintain manner.

I must point out that OOP has very useful patterns that can increase the quality of your code; however, if you overuse it then you will start losing the benefits of the dynamic, flexible, and functional nature of JavaScript.

Sometimes all you need are a bunch of functions in a file, and you'll see examples of that throughout the book.

A great way to demonstrate the value of classes would be to standardize the creation of a default User object. We need this because a BehaviorSubject object needs to be initialized with a default object. It is best to do this in one place, rather than copy-paste the same implementation in multiple places. It makes a lot of sense for the User object to own this functionality instead of an Angular service creating default User objects. So, let's implement a User class to achieve this goal.

Let's begin by defining our interfaces and enums:

  1. Define user roles as an enum at the location src/app/auth/auth.enum.ts:
    src/app/auth/auth.enum.ts
    export enum Role {
      None = 'none',
      Clerk = 'clerk',
      Cashier = 'cashier',
      Manager = 'manager',
    }
    
  2. Create a user.ts file under the src/app/user/user folder.
  3. Define a new interface named IUser in the user.ts file:
    src/app/user/user/user.ts
    import { Role } from '../../auth/auth.enum'
    export interface IUser {
      _id: string
      email: string
      name: IName
      picture: string
      role: Role | string
      userStatus: boolean
      dateOfBirth: Date | null | string
      level: number
      address: {
        line1: string
        line2?: string
        city: string
        state: string
        zip: string
      }
      phones: IPhone[]
    }
    

    Note that every complex property that is defined on the interface can also be represented as a string. In transit, all objects are converted to strings using JSON.stringify(). No type information is included. We also leverage interfaces to represent Class objects in-memory, which can have complex types. So, our interface properties must reflect both cases using union types. For example, role can either be of type Role or string. Similarly, dateOfBirth can be a Date or a string.

    We define address as an inline type, because we don't use the concept of an address outside of this class. In contrast, we define IName as its own interface, because in Chapter 11, Recipes – Reusability, Routing, and Caching, we will implement a separate component for names. We also define a separate interface for phones, because they are represented as an array. When developing a form, we need to be able to address individual array elements, like IPhone, in the template code.

    It is the norm to insert a capital I in front of interface names so they are easy to identify. Don't worry, there are no compatibility issues with using the IPhone interface on Android phones!

  4. In user.ts, define the IName and IPhone interfaces, and implement the PhoneType enum:
    src/app/user/user/user.ts
    export interface IName {
      first: string
      middle?: string
      last: string
    }
    export enum PhoneType {
      None = 'none',
      Mobile = 'mobile',
      Home = 'home',
      Work = 'work',
    }
    export interface IPhone {
      type: PhoneType
      digits: string
      id: number
    }
    

    Note that in the PhoneType enum, we explicitly defined string values. By default, enum values are converted into strings as they're typed, which can lead to issues with values stored in a database falling out of sync with how a developer chooses to spell a variable name. With explicit and all lowercase values, we reduce the risk of bugs.

  5. Finally, define the User class, which implements the IUser interface:
    src/app/user/user/user.ts
    export class User implements IUser {
      constructor(
        // tslint:disable-next-line: variable-name
        public _id = '',
        public email = '',
        public name = { first: '', middle: '', last: '' } as IName,
        public picture = '',
        public role = Role.None,
        public dateOfBirth: Date | null = null,
        public userStatus = false,
        public level = 0,
        public address = {
          line1: '',
          city: '',
          state: '',
          zip: '',
        },
        public phones: IPhone[] = []
      ) {}
      static Build(user: IUser) {
        if (!user) {
          return new User()
        }
        if (typeof user.dateOfBirth === 'string') {
          user.dateOfBirth = new Date(user.dateOfBirth)
        }
        return new User(
          user._id,
          user.email,
          user.name,
          user.picture,
          user.role as Role,
          user.dateOfBirth,
          user.userStatus,
          user.level,
          user.address,
          user.phones
        )
      }
    }
    

    Note that by defining all properties with default values in the constructors as public properties, we hit two birds with one stone; otherwise, we would need to define properties and initialize them separately. This way, we achieve a concise implementation.

    Using a static Build function, we can quickly hydrate the object with data received from the server. We can also implement the toJSON() function to customize the serialization behavior of our object before sending the data up to the server. But before that, let's add a calculated property.

    We can use calculated properties in templates or in toast messages to conveniently display values assembled from multiple parts. A great example is extracting a full name from the name object as a property in the User class.

    A calculated property for assembling a full name encapsulates the logic for combining a first, middle, and last name, so you don't have to rewrite this logic in multiple places, adhering to the DRY principle!

  6. Implement a fullName property getter in the User class:
    src/app/user/user/user.ts
    export class User implements IUser {
      ...
      public get fullName(): string {
        if (!this.name) {
          return ''
        }
        if (this.name.middle) {
          return `${this.name.first} ${this.name.middle} ${this.name.last}`
        }
        return `${this.name.first} ${this.name.last}`
      }
    }
    
  7. Add fullName IUser as readonly and an optional property:
    src/app/user/user/user.ts
    export interface IUser {
      ...
      readonly fullName?: string
    }
    

    You can now use the fullName property through the IUser interface.

  8. Implement the toJSON function:
    src/app/user/user/user.ts
    export class User implements IUser {
      ...
      
    toJSON(): object {
        const serialized = Object.assign(this)
        delete serialized._id
        delete serialized.fullName
        return serialized
      }
    }
    

Note that when serializing the object, we delete the _id and fullName fields. These are values that we don't want to be stored in the database. The fullName field is a calculated property, so it doesn't need to be stored. The _id is normally passed as a parameter in a GET or a PUT call to locate the record. This avoids mistakes that may result in overwriting the id fields of existing objects.

Now that we have the User data entity implemented, next let's implement the auth service.

Abstraction and inheritance

We aim to design a flexible auth service that can implement multiple auth providers. In this chapter, we will implement an in-memory provider and a Google Firebase provider. In Chapter 10, RESTful APIs and Full-Stack Implementation, we will implement a custom provider to interact with our backend.

By declaring an abstract base class, we can describe the common login and logout behavior of our application, so when we implement another auth provider, we don't have to re-engineer our application.

In addition, we can declare abstract functions, which the implementors of our base class would have to implement, enforcing our design. Any class that implements the base class would also get the benefit of the code implemented in the base class, so we wouldn't need to repeat the same logic in two different places.

The following class diagram reflects the architecture and inheritance hierarchy of our abstract AuthService:

Figure 8.3: The AuthService inheritance structure

AuthService implements the interface IAuthService, as shown:

export interface IAuthService {
  readonly authStatus$: BehaviorSubject<IAuthStatus>
  readonly currentUser$: BehaviorSubject<IUser>
  login(email: string, password: string): Observable<void>
  logout(clearToken?: boolean): void
  getToken(): string
}

The interface reflects the public properties that the service exposes. The service provides the authentication status as the authStatus$ observable and the current user as currentUser$, and it provides three functions to login, logout, and getToken.

AuthService inherits caching functionality from another abstract class called CacheService. Since AuthService is an abstract class, it can't be used on its own, so we implement three auth providers, InMemoryAuthService, FirebaseAuthService, and CustomAuthService, as seen at the bottom of the diagram.

Note that all three auth services implement all abstract functions. In addition, the FirebaseAuthService overrides the base logout function to implement its own behavior. All three classes inherit from the same abstract class and expose the same public interface. All three will execute the same auth workflow against different auth servers.

The in-memory auth service doesn't communicate with a server. The service is for demonstration purposes only. It implements fake JWT encoding, so we can demonstrate how the JWT life cycle works.

Let's start by creating the auth service.

Create the auth service

We will start by creating the abstract auth service and the in-memory service:

  1. Add an auth service:
    $ npx ng g s auth --flat false --lintFix 
    $ npx ng g s auth/inMemoryAuth --lintFix --skipTests
    
  2. Rename in-memory-auth.service.ts to auth.inmemory.service.ts so the different auth providers visually group together in File Explorer.
  3. Remove the config object { providedIn: 'root' } from the @Injectable() decorator of auth.service.ts and auth.inmemory.service.ts.
  4. Ensure that authService is provided in app.module.ts, but the InMemoryAuthService is actually used and not the abstract class:
    src/app/app.module.ts
    import { AuthService } from './auth/auth.service'
    import { InMemoryAuthService } from './auth/auth.inmemory.service'
    ...
      providers: [
        {
          provide: AuthService,
          useClass: InMemoryAuthService
        },
        ...
    ]
    

Creating a separate folder for the service organizes various components related to auth, such as the enum definition for the user role. Additionally, we will be able to add an authService fake to the same folder for automated testing.

Implement an abstract auth service

Now, let's build an abstract auth service that will orchestrate logins and logouts, while encapsulating the logic of how to manage JWTs, auth status, and information regarding the current user. By leveraging the abstract class, we should be able to implement our own auth service against any auth provider without modifying the internal behavior of our application.

The abstract auth service that is being demonstrated enables rich and intricate workflows. It is a solution that you can drop into your applications without modifying the internal logic. As a result, it is a complicated solution.

This auth service will enable us to demonstrate logging in with an email and password, caching, and conditional navigation concepts based on authentication status and a user's role:

  1. Start by installing a JWT decoding library, and, for faking authentication, a JWT encoding library:
    $ npm install jwt-decode 
    $ npm install -D @types/jwt-decode
    
  2. Implement an IAuthStatus interface to store decoded user information, a helper interface, and the secure by default defaultAuthStatus:
    src/app/auth/auth.service.ts
    import { Role } from './auth.enum'
    ...
    export interface IAuthStatus {
      isAuthenticated: boolean
      userRole: Role
      userId: string
    }
    export interface IServerAuthResponse {
      accessToken: string
    }
    export const defaultAuthStatus: IAuthStatus = {
      isAuthenticated: false,
      userRole: Role.None,
      userId: '',
    }
    ...
    

    IAuthStatus is an interface that represents the shape of a typical JWT that you may receive from your authentication service. It contains minimal information about the user and the user's role. The auth status object can be attached to the header of every REST call to APIs to verify the user's identity. The auth status can be optionally cached in localStorage to remember the user's login state; otherwise, they would have to re-enter their password with every page refresh.

    In the preceding implementation, we're assuming the default role of None, as defined in the Role enum. By not giving any role to the user by default, we're following a least-privileged access model. The user's correct role will be set after they log in successfully with the information received from the auth API.

  3. Define the IAuthService interface in auth.service.ts:
    src/app/auth/auth.service.ts
    export interface IAuthService {
      readonly authStatus$: BehaviorSubject<IAuthStatus>
      readonly currentUser$: BehaviorSubject<IUser>
      login(email: string, password: string): Observable<void>
      logout(clearToken?: boolean): void
      getToken(): string
    }
    
  4. Make AuthService an abstract class, as shown:
    export abstract class AuthService
    
  5. Implement the interface, IAuthService, using VS Code's quick fix functionality:
    src/app/auth/auth.service.ts
    @Injectable()
    export abstract class AuthService implements IAuthService {
      authStatus$: BehaviorSubject<IAuthStatus>
      currentUser$: BehaviorSubject<IUser>
      
      constructor() {}
      
      login(email: string, password: string): Observable<void> {
        throw new Error('Method not implemented.')
      }
      logout(clearToken?: boolean): void {
        throw new Error('Method not implemented.')
      }
      getToken(): string {
        throw new Error('Method not implemented.')
      }
    }
    
  6. Implement the authStatus$ and currentUser$ properties as readonly and initialize our data anchors with their default values:
    src/app/auth/auth.service.ts
    import { IUser, User } from '../user/user/user'
    ...
    @Injectable()
    export abstract class AuthService implements IAuthService {
      readonly authStatus$ = 
        new BehaviorSubject<IAuthStatus>(defaultAuthStatus)  
      readonly currentUser$ = 
        new BehaviorSubject<IUser>(new User())
      ...
    }
    

Note that we removed the type definitions of the properties. Instead, we're letting TypeScript infer the type from the initialization.

You must always declare your data anchors as readonly, so you don't accidentally overwrite the data stream by re-initializing a data anchor as a new BehaviorSubject. Doing so would render any prior subscribers orphaned, leading to memory leaks, and have many unintended consequences.

All implementors of IAuthService need to be able to log the user in, transform the token we get back from the server so we can read it and store it, support access to the current user, and the auth status, and provide a way to log the user out. We have successfully put in the functions for our public methods and implemented default values for our data anchors to create hooks for the rest of our application to use. But so far, we have only defined what our service can do, and not how it can do it.

As always, the devil is in the details, and the hard part is the "how." Abstract functions can help us to complete the implementation of a workflow in a service within our application, while leaving the portions of the service that must implement external APIs undefined.

Abstract functions

Auth services that implement the abstract class should be able to support any kind of auth provider, and any kind of token transformation, while being able to modify behaviors like user retrieval logic. We must be able to implement login, logout, token, and auth status management without implementing calls to specific services.

By defining abstract functions, we can declare a series of methods that must implement a given set of inputs and outputs—a signature without an implementation. We can then use these abstract functions to orchestrate the implementation of our auth workflow.

Our design goal here is driven by the Open/Closed principle. The AuthService will be open to extension through its ability to be extended to work with any kind of token-based auth provider, but closed to modification. Once we're done implementing the AuthService, we won't need to modify its code to add additional auth providers.

Now we need to define the abstract functions that our auth providers must implement, as shown in Figure 8.3 from earlier in the chapter:

  • authProvider(email, password): Observable<IServerAuthResponse> can log us in via a provider and return a standardized IServerAuthResponse
  • transformJwtToken(token): IAuthStatus can normalize the token a provider returns to the interface of IAuthStatus
  • getCurrentUser(): Observable<User> can retrieve the user profile of the logged-in user

We can then use these functions in our login, logout, and getToken methods to implement the auth workflow:

  1. Define the abstract methods that the derived classes should implement as protected properties, so they're accessible in the derived class, but not publicly:
    src/app/auth/auth.service.ts
    ...
    export abstract class AuthService implements IAuthService {
         protected abstract authProvider(
           email: string,
           password: string
         ): Observable<IServerAuthResponse>
         protected abstract transformJwtToken(token: unknown):
           IAuthStatus
         protected abstract getCurrentUser(): Observable<User>
         ...
    }
    

    Leveraging these stubbed out methods, we can now implement a login method that performs a login and retrieves the currently logged-in user, making sure to update the authStatus$ and currentUser$ data streams.

  2. Before we move on, implement a transformError function to handle errors of different types like HttpErrorResponse and string, providing them in an observable stream. In a new file named common.ts under src/app/common create the transformError function:
    src/app/common/common.ts
    import { HttpErrorResponse } from '@angular/common/http'
    import { throwError } from 'rxjs'
    export function transformError(error: HttpErrorResponse | string) {
      let errorMessage = 'An unknown error has occurred'
      if (typeof error === 'string') {
        errorMessage = error
      } else if (error.error instanceof ErrorEvent) {
        errorMessage = `Error! ${error.error.message}`
      } else if (error.status) {
        errorMessage = 
          `Request failed with ${error.status} ${error.statusText}`
      } else if (error instanceof Error) {
        errorMessage = error.message
      }
      return throwError(errorMessage)
    }
    
  3. In auth.service.ts, implement the login method:
    src/app/auth/auth.service.ts
    import * as decode from 'jwt-decode'
    import { transformError } from '../common/common'
    ...
      login(email: string, password: string): Observable<void> {
        const loginResponse$ = this.authProvider(email, password)
          .pipe(
            map((value) => {
              const token = decode(value.accessToken)
              return this.transformJwtToken(token)
            }),
            tap((status) => this.authStatus$.next(status)),
            filter((status: IAuthStatus) => status.isAuthenticated),
            flatMap(() => this.getCurrentUser()),
            map(user => this.currentUser$.next(user)),
            catchError(transformError)
          )
        loginResponse$.subscribe({
          error: err => {
            this.logout()
            return throwError(err)
          },
        })
        return loginResponse$
      }
    

    The login method encapsulates the correct order of operations by calling the authProvider with the email and password information, then decoding the received JWT, transforming it, and updating authStatus$. Then getCurrentUser() is called only if status.isAuthenticated is true. Later, currentUser$ is updated and, finally, we catch any errors using our custom transformError function.

    We activate the observable stream by calling subscribe on it. In the case of an error, we call logout() to maintain the correct status of our application and bubble up errors to consumers of login by re-throwing the error using throwError.

    Now, the corresponding logout function needs to be implemented. Logout is triggered by the Logout button from the application toolbar in the case of a failed login attempt, as shown earlier, or if an unauthorized access attempt is detected. We can detect unauthorized access attempts by using a router auth guard as the user is navigating the application, which is a topic covered later in the chapter.

  4. Implement the logout method:
    src/app/auth/auth.service.ts
      ...
      logout(clearToken?: boolean): void {
        setTimeout(() => this.authStatus$.next(defaultAuthStatus), 0)
      }
    

We log out by pushing out the defaultAuthStatus as the next value in the authStatus$ stream. Note the use of setTimeout, which allows us to avoid timing issues when core elements of the application are all changing statuses at once.

Think about how the login method adheres to the Open/Closed principle. The method is open to extension through the abstract functions authProvider, transformJwtToken, and getCurrentUser. By implementing these functions in a derived class, we maintain the ability to externally supply different auth providers without having to modify the login method. As a result, the implementation of the method remains closed to modification, thus adhering to the Open/Closed principle.

The true value of creating abstract classes is the ability to encapsulate common functionality in an extensible way.

You may ignore the getToken function for now, as we are not yet caching our JWT. Without caching, the user would have to log in with every page refresh. Let's implement caching next.

Abstract caching service using localStorage

We must be able to cache the authentication status of the logged-in user. As mentioned, otherwise, with every page refresh, the user will have to go through the login routine. We need to update AuthService so that it persists the auth status.

There are three main ways to store data:

  • cookie
  • localStorage
  • sessionStorage

Cookies should not be used to store secure data because they can be sniffed or stolen by bad actors. In addition, cookies can store only 4 KB of data and can be set to expire.

localStorage and sessionStorage are similar to each other. They are protected and isolated browser-side stores that allow the storage of larger amounts of data for your application. Unlike cookies, you can't set an expiration date-time on values stored in either store. Values stored in either store survive page reloads and restores, making them better candidates than cookies for caching information.

The major difference between localStorage and sessionStorage is that the values are removed when the browser window is closed. In most cases, user logins are cached anywhere from minutes to a month or more depending on your business, so relying on whether the user closes the browser window isn't very useful. Through this process of elimination, I prefer localStorage because of the isolation it provides and long-term storage capabilities.

JWTs can be encrypted and include a timestamp for expiration. In theory, this counters the weaknesses of both cookies and localStorage. If implemented correctly, either option should be secure for use with JWTs, but localStorage is still preferred.

Let's start by implementing a caching service that can abstract away our method of caching. We can then derive from this service to cache our authentication information:

  1. Start by creating an abstract cacheService that encapsulates the method of caching:
    src/app/auth/cache.service.ts
    export abstract class CacheService {
      protected getItem<T>(key: string): T | null {
        const data = localStorage.getItem(key)
        if (data != null) {
          return JSON.parse(data)
        }
        return null
      }
      protected setItem(key: string, data: object | string) {
        if (typeof data === 'string') {
          localStorage.setItem(key, data)
        }
        localStorage.setItem(key, JSON.stringify(data))
      }
      protected removeItem(key: string) {
        localStorage.removeItem(key)
      }
      protected clear() {
        localStorage.clear()
      }
    }
    

    This cache service base class can be used to give caching capabilities to any service. It is not the same as creating a centralized cache service that you inject into another service. By avoiding a centralized value store, we avoid interdependencies between various services.

  2. Update AuthService to extend the CacheService, which will enable us to implement caching of the JWT in the next section:
    src/app/auth/auth.service.ts
    ...
    export abstract class AuthService 
      extends CacheService implements IAuthService { 
      constructor() {
        super()
      }
      ... 
    }
    

Note that we must call the constructor of the base class from the derived class's constructor using the super method.

Let's go over an example of how to use the base class's functionality by caching the value of the authStatus object:

example
authStatus$ = new BehaviorSubject<IAuthStatus>(
  this.getItem('authStatus') ?? defaultAuthStatus
)
constructor() {
  super()
  this.authStatus$.pipe(
    tap(authStatus => this.setItem('authStatus', authStatus))
  )
}

The technique demonstrated in the example leverages RxJS observable streams to update the cache whenever the value of authStatus$ changes. You can use this pattern to persist any kind of data without having to litter your business logic with caching code. In this case, we wouldn't need to update the login function to call setItem, because it already calls this.authStatus.next, and we can just tap into the data stream. This helps with staying stateless and avoiding side effects by decoupling functions from each other.

Note that we also initialize the BehaviorSubject using the getItem function. Using the nullish coalescing operator, we only use cached data if it is not undefined or null. Otherwise, we provide the default value.

You can implement your own custom cache expiration scheme in the setItem and getItem functions, or leverage a service created by a third party.

However, for an additional layer of security, we won't cache the authStatus object. Instead, we will only cache the encoded JWT, which contains just enough information, so we can authenticate requests sent to the server. It is important to understand how token-based authentication works to avoid revealing compromising secrets. Review the JWT life cycle from earlier in this chapter to improve your understanding.

Next, let's cache the token.

Caching the JWT

Let's update the authentication service so that it can cache the token.

  1. Update AuthService to be able to set, get, and clear the token, as shown:
    src/app/auth/auth.service.ts
    ...
      protected setToken(jwt: string) {
        this.setItem('jwt', jwt)
      }
      getToken(): string {
        return this.getItem('jwt') ?? ''
      }
      protected clearToken() {
        this.removeItem('jwt')
      }
    
  2. Call clearToken and setToken during login, and clearToken during logout, as shown:
    src/app/auth/auth.service.ts
    ...
      login(email: string, password: string): Observable<void> {
        this.clearToken()
        const loginResponse$ = this.authProvider(email, password)
          .pipe(
            map(value => {
              this.setToken(value.accessToken)
              const token = decode(value.accessToken)
              return this.transformJwtToken(token)
            }),
            tap((status) => this.authStatus$.next(status)),
            ...
      }
      logout(clearToken?: boolean) {
        if (clearToken) {
          this.clearToken()
        }
        setTimeout(() => this.authStatus$.next(defaultAuthStatus), 0)
      }
    

Every subsequent request will contain the JWT in the request header. You should secure every API to check for and validate the token received. For example, if a user wants to access their profile, the AuthService will validate the token to check whether the user is authenticated or not; however, a further database call will still be required to check whether the user is also authorized to view the data. This ensures an independent confirmation of the user's access to the system and prevents any abuse of an unexpired token.

If an authenticated user makes a call to an API where they don't have the proper authorization, say if a clerk wants to get access to a list of all users, then the AuthService will return a falsy status, and the client will receive a 403 Forbidden response, which will be displayed as an error message to the user.

A user can make a request with an expired token; when this happens, a 401 Unauthorized response is sent to the client. As a good UX practice, we should automatically prompt the user to log in again and let them resume their workflow without any data loss.

In summary, true security is achieved with robust server-side implementation. Any client-side implementation is largely there to enable a good UX around good security practices.

Implement an in-memory auth service

Now, let's implement a concrete version of the auth service that we can actually use:

  1. Start by installing a JWT decoding library and, for faking authentication, a JWT encoding library:
    $ npm install fake-jwt-sign
    
  2. Extend the abstract AuthService:
    src/app/auth/auth.inmemory.service.ts
    import { AuthService } from './auth.service'
    @Injectable()
    export class InMemoryAuthService extends AuthService {
      constructor() {
        super()
        console.warn(
          "You're using the InMemoryAuthService. Do not use this service in production."
        )
      }
      ...
    }
    
  3. Implement a fake authProvider function that simulates the authentication process, including creating a fake JWT on the fly:
    src/app/auth/auth.inmemory.service.ts
      import { sign } from 'fake-jwt-sign'//For InMemoryAuthService only
    ...
      protected authProvider(
        email: string,
        password: string
      ): Observable<IServerAuthResponse> {
        email = email.toLowerCase()
        if (!email.endsWith('@test.com')) {
          return throwError('Failed to login! Email needs to end with @test.com.')
        }
        const authStatus = {
          isAuthenticated: true,
          userId: this.defaultUser._id,
          userRole: email.includes('cashier')
            ? Role.Cashier
            : email.includes('clerk')
            ? Role.Clerk
            : email.includes('manager')
            ? Role.Manager
            : Role.None,
        } as IAuthStatus
        this.defaultUser.role = authStatus.userRole
        const authResponse = {
          accessToken: sign(authStatus, 'secret', {
            expiresIn: '1h',
            algorithm: 'none',
          }),
        } as IServerAuthResponse
        return of(authResponse)
      }
    ...
    

    The authProvider implements what would otherwise be a server-side method right in the service, so we can conveniently experiment with the code while fine-tuning our auth workflow. The provider creates and signs a JWT with the temporary fake-jwt-sign library so that I can also demonstrate how to handle a properly formed JWT.

    Do not ship your Angular application with the fake-jwt-sign dependency, since it is meant to be server-side code.

    In contrast, a real auth provider would include a POST call to a server. See the example code that follows:

    example
    private exampleAuthProvider(
      email: string,
      password: string
    ): Observable<IServerAuthResponse> { return this.httpClient.post<IServerAuthResponse>(
        `${environment.baseUrl}/v1/login`, 
        { email: email, password: password }
      )
    }
    

    It is pretty straightforward, since the hard work is done on the server side. This call can also be made to a third-party auth provider, which I cover in the Firebase authentication recipe later in this chapter.

    Note that the API version, v1, in the URL path is defined at the service and not as part of the baseUrl. This is because each API can change versions independently. Login may remain v1 for a long time, while other APIs may be upgraded to v2, v3, and so on.

  4. Implementing transformJwtToken will be trivial, because the login function provides us with a token that adheres to IAuthStatus:
    src/app/auth/auth.inmemory.service.ts
    protected transformJwtToken(token: IAuthStatus): 
      IAuthStatus {
        return token
      }
    
  5. Finally, implement getCurrentUser, which should return some default user:
    src/app/auth/auth.inmemory.service.ts
    protected getCurrentUser(): Observable<User> {
      return of(this.defaultUser)
    }
    

    Next, provide a defaultUser as a private property to the class; what follows is one that I've created.

  6. Add a private defaultUser property to the InMemoryAuthService class:
    src/app/auth/auth.inmemory.service.ts
    import { PhoneType, User } from '../user/user/user'
    ...
    private defaultUser = User.Build({
      _id: '5da01751da27cc462d265913',
      email: '[email protected]',
      name: { first: 'Doguhan', last: 'Uluca' },
      picture: 'https://secure.gravatar.com/avatar/7cbaa9afb5ca78d97f3c689f8ce6c985',
      role: Role.Manager,
      dateOfBirth: new Date(1980, 1, 1),
      userStatus: true,
      address: {
        line1: '101 Sesame St.',
        city: 'Bethesda',
        state: 'Maryland',
        zip: '20810',
      },
      level: 2,
      phones: [
        {
          id: 0,
          type: PhoneType.Mobile,
          digits: '5555550717',
        },
      ],
    })
    

Congratulations, you've implemented a concrete, but still fake, auth service. Now that you have the in-memory auth service in place, be sure to run your Angular application and ensure that there are no errors.

Let's test our auth service by implementing a simple login and logout functionality accessible through the UI.

Simple login

Before we implement a fully-featured login component, let's wire up pre-baked login behavior to the Login as manager button we have in the HomeComponent. We can test the behavior of our auth service before getting into the details of delivering a rich UI component.

Our goal is to simulate logging in as a manager. To accomplish this, we need to hard code an e mail address and a password to log in, and upon successful login, maintain the functionality of navigating to the /manager route.

Note that on GitHub the code sample for this section resides in a file named home.component.simple.ts under the folder structure of projects/ch8. The alternate file exists for reference purposes only, since the code from this section dramatically changes later in the chapter. Ignore the file name difference, as it will not impact your coding for this section.

Let's implement a simple login mechanism:

  1. In the HomeComponent, implement a login function that uses the AuthService:
    src/app/home/home.component.ts
    import { AuthService } from '../auth/auth.service'
    export class HomeComponent implements OnInit {
      constructor(private authService: AuthService) {}
      ngOnInit(): void {}
      login() {
        this.authService.login('[email protected]', '12345678')
      }
    }
    
  2. Update the template to remove the routerLink and instead call the login function:
    src/app/home/home.component.ts
    template: `
        <div fxLayout="column" fxLayoutAlign="center center">
          <span class="mat-display-2">Hello, Limoncu!</span>
          <button mat-raised-button color="primary" (click)="login()">
            Login as Manager
          </button>
        </div>
      `,
    

    On successful login, we need to navigate to the /manager route. We can verify that we're successfully logged in by listening to the authStatus$ and currentUser$ observables exposed by the AuthService. If authStatus$.isAuthenticated is true and currentUser$._id is a non-empty string, that means that we have a valid login. We can listen to both observables by using RxJS's combineLatest operator. Given a valid login condition, we can then use the filter operator to reactively navigate to the /manager route.

  3. Update the login() function to implement the login conditional and upon success, navigate to the /manager route:
    src/app/home/home.component.ts
    constructor(
      private authService: AuthService,
      private router: Router
    ) {}
      
    login() {
      this.authService.login('[email protected]', '12345678')
    combineLatest([
      this.authService.authStatus$, this.authService.currentUser$
    ]) 
      .pipe(
        filter(([authStatus, user]) => 
          authStatus.isAuthenticated && user?._id !== ''
        ),
        tap(([authStatus, user]) => {
          this.router.navigate(['/manager'])
        })
      )
      .subscribe()
    }
    

    Note that we subscribe to the combineLatest operator at the end, which is critical in activating the observable streams. Otherwise, our login action will remain dormant unless some other component subscribes to the stream. You only need to activate a stream once.

  4. Now test out the new login functionality. Verify that the JWT is created and stored in localStorage using the Chrome DevTools| Application tab, as shown here:

    Figure 8.4: DevTools showing Application Local Storage

You can view Local Storage under the Application tab. Make sure that the URL of your application is highlighted. In step 3, you can see that we have a key named jwt with a valid-looking token.

Note steps 4 and 5 highlighting two warnings, which advise us not to use the InMemoryAuthService and the fake-jwt-sign package in production code.

Use breakpoints to debug and step through the code to get a more concrete understanding of how HomeComponent, InMemoryAuthService, and AuthService work together to log the user in.

When you refresh the page, note that you're still logged in, because we're caching the token in local storage.

Since we're caching the login status, we also need to implement a logout experience to complete the auth workflow.

Logout

The logout button on the application toolbar is already wired up to navigate to the logout component we created before. Let's update this component so it can log the user out when navigated to:

  1. Implement the logout component:
    src/app/user/logout/logout.component.ts
    import { Component, OnInit } from '@angular/core' 
    import { Router } from '@angular/router'
    import { AuthService } from '../../auth/auth.service'
    @Component({
      selector: 'app-logout', 
      template: `<p>Logging out...</p>`,
    })
    export class LogoutComponent implements OnInit { 
      constructor(private router: Router, private authService: AuthService) {}
      ngOnInit() { 
        this.authService.logout(true)
        this.router.navigate(['/'])
      }
    }
    

    Note that we are explicitly clearing the JWT by passing in true to the logout function. After we call logout, we navigate the user back to the home page.

  2. Test out the logout button.
  3. Verify that local storage is cleared after logout.

We have nailed a solid login and logout implementation. However, we're not yet done with the fundamentals of our auth workflow.

Next, we need to consider the expiration status of our JWT.

Resuming a JWT session

It wouldn't be a great UX if you had to log in to Gmail or Amazon every single time you visited the site. This is why we cache the JWT, but it would be an equally bad UX to keep you logged in forever. A JWT has an expiration date policy, where the provider can select a number of minutes or even months to allow your token to be valid for depending on security needs. The in-memory service creates tokens that expire in one hour, so if a user refreshes their browser window within that frame, we should honor the valid token and let the user continue using the application without asking them to log back in.

On the flip side, if the token is expired, we should automatically navigate the user to the login screen for a smooth UX.

Let's get started:

  1. Update the AuthService class to implement a function named hasExpiredToken to check whether the token is expired, and a helper function named getAuthStatusFromToken to decode the token, as shown:
    src/app/auth/auth.service.ts
    ...
      protected hasExpiredToken(): boolean {
        const jwt = this.getToken()
        if (jwt) {
          const payload = decode(jwt) as any
          return Date.now() >= payload.exp * 1000
        }
        return true
      }
      protected getAuthStatusFromToken(): IAuthStatus {
        return this.transformJwtToken(decode(this.getToken()))
      }
    

    Keep your code DRY! Update the login() function to use getAuthStatusFromToken() instead.

  2. Update the constructor of AuthService to check the status of the token:
    src/app/auth/auth.service.ts
    ...
    constructor() {
        super()
        if (this.hasExpiredToken()) {
          this.logout(true)
        } else {
          this.authStatus$.next(this.getAuthStatusFromToken())
        }
    }
    

    If the token has expired, we log the user out and clear the token from localStorage. Otherwise, we decode the token and push the auth status to the data stream.

    A corner case to consider here is to also trigger the reloading of the current user in the event of a resumption. We can do this by implementing a new pipe that reloads the current user if activated.

  3. First, let's refactor the existing user update logic in the login() function to a private property named getAndUpdateUserIfAuthenticated so we can reuse it:
    src/app/auth/auth.service.ts
    ...
      @Injectable()
    export abstract class AuthService extends CacheService implements IAuthService {
      private getAndUpdateUserIfAuthenticated = pipe(
        filter((status: IAuthStatus) => status.isAuthenticated),
        flatMap(() => this.getCurrentUser()),
        map((user: IUser) => this.currentUser$.next(user)),
        catchError(transformError)
      )
      ...
      login(email: string, password: string): Observable<void> {
        this.clearToken()
        const loginResponse$ = this.authProvider(email, password)
          .pipe(
            map((value) => {
              this.setToken(value.accessToken)
              const token = decode(value.accessToken)
              return this.transformJwtToken(token)
            }),
            tap((status) => this.authStatus$.next(status)),
            this.getAndUpdateUserIfAuthenticated
          )
        ...
      }
      ...
    }
    
  4. In AuthService, define an observable property named resumeCurrentUser$ as a fork of authStatus$, and use the getAndUpdateUserIfAuthenticated logic:
    src/app/auth/auth.service.ts
    ...
      protected readonly resumeCurrentUser$ = this.authStatus$.pipe(
        this.getAndUpdateUserIfAuthenticated 
      )
    

    Once resumeCurrentUser$ is activated and status.isAuthenticated is true, then this.getCurrentUser() will be invoked and currentUser$ will be updated.

  5. Update the constructor of AuthService to activate the pipeline if the token is unexpired:
    src/app/auth/auth.service.ts
    ...
    constructor() {
      super()
      if (this.hasExpiredToken()) {
        this.logout(true)
      } else {
        this.authStatus$.next(this.getAuthStatusFromToken())
        // To load user on browser refresh,
        // resume pipeline must activate on the next cycle
        // Which allows for all services to constructed properly
        setTimeout(() => this.resumeCurrentUser$.subscribe(), 0)
      }
    }
    

Using the preceding technique, we can retrieve the latest user profile data without having to deal with caching issues.

To experiment with token expiration, I recommend that you create a faster-expiring token in InMemoryAuthService.

As demonstrated earlier in the caching section, it is possible to cache the user profile data using this.setItem and the profile data from cache on first launch. This would provide a faster UX and cover cases where users may be offline. After the application launches, you could then asynchronously fetch fresh user data and update currentUser$ when new data comes in. You would need to add additional caching and tweak the getCurrentUser() logic to get such functionality working. Oh, and you would need a whole lot of testing! It takes a lot of testing to create a high-quality auth experience.

Congratulations, we're done implementing a robust auth workflow! Next, we need to integrate auth with Angular's HTTP client so we can attach the token to the HTTP header of every request.

HTTP interceptor

Implement an HTTP interceptor to inject the JWT into the header of every request sent to the user and gracefully handle authentication failures by asking the user to log back in:

  1. Create an AuthHttpInterceptor under auth:
    src/app/auth/auth-http-interceptor.ts
    import {
      HttpEvent,
      HttpHandler,
      HttpInterceptor,
      HttpRequest,
    } from '@angular/common/http'
    import { Injectable } from '@angular/core'
    import { Router } from '@angular/router'
    import { Observable, throwError } from 'rxjs'
    import { catchError } from 'rxjs/operators'
    import { AuthService } from './auth.service'
    @Injectable()
    export class AuthHttpInterceptor implements HttpInterceptor {
      constructor(private authService: AuthService, private router: Router) {}
      intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        const jwt = this.authService.getToken()
        const authRequest = req.clone({ setHeaders: { authorization: `Bearer ${jwt}` } })
        return next.handle(authRequest).pipe(
          catchError((err, caught) => {
            if (err.status === 401) {
              this.router.navigate(
                ['/login'], { queryParams: {
                  redirectUrl: this.router.routerState.snapshot.url},}
              )
            }
            return throwError(err)
          })
        )
      }
    }
    

    Note that AuthService is leveraged to retrieve the token, and the redirectUrl is set for the login component after a 401 error.

  2. Update app.module.ts to provide the interceptor:
    src/app/app.module.ts
      providers: [
        ...
        {
          provide: HTTP_INTERCEPTORS,
          useClass: AuthHttpInterceptor,
          multi: true,
        },
      ],
    
  3. Ensure that the interceptor is adding the token to requests. To do this, open the Chrome DevTools | Network tab, log in, and then refresh the page:

    Figure 8.5: The request header for lemon.svg

In step 4, you can now observe the interceptor in action. The request for the lemon.svg file has the bearer token in the request header.

Now that we have our auth mechanisms in place, let's take advantage of all the supporting code we have written with dynamic UI components and a conditional navigation system for a role-based UX.

Dynamic UI components and navigation

AuthService provides asynchronous auth status and user information, including a user's name and role. We can use all this information to create a friendly and personalized experience for users. In this next section, we will implement the LoginComponent so that users can enter their username and password information and attempt a login.

Implementing the login component

The login component leverages the AuthService that we just created and implements validation errors using reactive forms.

Remember that in app.module.ts we provided AuthService using the class InMemoryAuthService. So, during run time, when AuthService is injected into the login component, the in-memory service will be the one in use.

The login component should be designed to be rendered independently of any other component, because during a routing event, if we discover that the user is not properly authenticated or authorized, we will navigate them to this component. We can capture this origination URL as a redirectUrl so that once a user logs in successfully, we can navigate them back to it.

Let's begin:

  1. Install the SubSink package.
  2. Create a new component named login in the root of your application with inline styles.
  3. Let's start with implementing the routes to the login component:
    src/app/app-routing.modules.ts
    ...
      { path: 'login', component: LoginComponent },
      { path: 'login/:redirectUrl', component: LoginComponent },
    ...
    

    Remember that the '**' path must be the last one defined.

  4. Using a similar login logic to the one we implemented in HomeComponent, now implement the LoginComponent with some styles:

    Don't forget to import the requisite dependent modules into your Angular application for the upcoming steps. This is intentionally left as an exercise for you to locate and import the missing modules.

    src/app/login/login.component.tsimport { AuthService } from '../auth/auth.service'
    import { Role } from '../auth/role.enum'
    @Component({
      selector: 'app-login',
      templateUrl: 'login.component.html',
      styles: [
        `
          .error {
            color: red
          }
        `,
        `
          div[fxLayout] {
            margin-top: 32px;
          }
        `,
      ],
    })
    export class LoginComponent implements OnInit { 
      private subs = new SubSink()
      loginForm: FormGroup
      loginError = ''
      redirectUrl: string
      constructor(
        private formBuilder: FormBuilder,
        private authService: AuthService,
        private router: Router,
        private route: ActivatedRoute
      ) {
        this.subs.sink = route.paramMap.subscribe(
          params => (this.redirectUrl = 
            params.get('redirectUrl') ?? ''
          )
        )
      }
      ngOnInit() {
        this.authService.logout()
        this.buildLoginForm()
      }
      buildLoginForm() {
        this.loginForm = this.formBuilder.group({
          email: ['', [Validators.required, Validators.email]],
          password: ['', [
            Validators.required,
            Validators.minLength(8),
            Validators.maxLength(50),
          ]],
        })
      }
      async login(submittedForm: FormGroup) {
        this.authService
          .login(
            submittedForm.value.email,
            submittedForm.value.password
          )
          .pipe(catchError(err => (this.loginError = err)))
        this.subs.sink = combineLatest([
          this.authService.authStatus$,
          this.authService.currentUser$,
        ])
          .pipe(
            filter(
              ([authStatus, user]) =>
                authStatus.isAuthenticated && user?._id !== ''
            ),
            tap(([authStatus, user]) => {
              this.router.navigate([this.redirectUrl || '/manager'])
            })
          )
          .subscribe()
      } 
    }
    

    We are using SubSink to manage our subscriptions. We ensure that we are logged out when ngOnInit is called. We build the reactive form in a standard manner. Finally, the login method calls this.authService.login to initiate the login process.

    We listen to the authStatus$ and currentUser$ data streams simultaneously using combineLatest. Every time there's a change in each stream, our pipe gets executed. We filter out unsuccessful login attempts. As the result of a successful login attempt, we leverage the router to navigate an authenticated user to their profile. In the case of an error sent from the server via the service, we assign that error to loginError.

  5. Here's an implementation for a login form to capture and validate a user's email and password, and if there are any server errors, display them:

    Don't forget to import ReactiveFormsModule in app.modules.ts.

    src/app/login/login.component.html
    <div fxLayout="row" fxLayoutAlign="center">
      <mat-card fxFlex="400px">
        <mat-card-header>
          <mat-card-title>
            <div class="mat-headline">Hello, Limoncu!</div>
          </mat-card-title>
        </mat-card-header>
        <mat-card-content>
          <form [formGroup]="loginForm" (ngSubmit)="login(loginForm)" fxLayout="column">
            <div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="10px">
              <mat-icon>email</mat-icon>
              <mat-form-field fxFlex>
                <input matInput placeholder="E-mail" aria-label="E- mail" formControlName="email">
                <mat-error *ngIf="loginForm.get('email')?.hasError('required')">
                  E-mail is required
                </mat-error>
                <mat-error *ngIf="loginForm.get('email')?.hasError('email')">
                  E-mail is not valid
                </mat-error>
              </mat-form-field>
            </div>
            <div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="10px">
              <mat-icon matPrefix>vpn_key</mat-icon>
              <mat-form-field fxFlex>
                <input matInput placeholder="Password" aria- label="Password" type="password" formControlName="password">
                <mat-hint>Minimum 8 characters</mat-hint>
                <mat-error *ngIf="loginForm.get('password')?.hasError('required')">
                  Password is required
                </mat-error>
                <mat-error *ngIf="loginForm.get('password')?.hasError('minlength')">
                  Password is at least 8 characters long
                </mat-error>
                <mat-error *ngIf="loginForm.get('password')?.hasError('maxlength')">
                  Password cannot be longer than 50 characters
                </mat-error>
              </mat-form-field>
            </div>
            <div fxLayout="row" class="margin-top">
              <div *ngIf="loginError" class="mat-caption error">{{loginError}}</div>
              <div class="flex-spacer"></div>
              <button mat-raised-button type="submit" color="primary" [disabled]="loginForm.invalid">Login</button>
            </div>
          </form>
        </mat-card-content>
      </mat-card>
    </div>
    

    The Login button is disabled until the email and password meet client site validation rules. Additionally, <mat-form-field> will only display one mat-error at a time, unless you create more space for more errors, so be sure to place your error conditions in the correct order.

    Once you're done implementing the login component, you can now update the home screen to conditionally display or hide the new component we created.

  6. Update the HomeComponent to clean up the code we added previously, so we can display the LoginComponent when users land on the home page of the app:
    src/app/home/home.component.ts
      ...
      template: `
        <div *ngIf="displayLogin">
          <app-login></app-login>
        </div>
        <div *ngIf="!displayLogin">
          <span class="mat-display-3">You get a lemon, you get a lemon, you get a lemon...</span>
        </div>
      `,
    }) 
    export class HomeComponent {
      displayLogin = true
      constructor() {
      }
    }
    

Your application should look similar to this screenshot:

Figure 8.6: LemonMart with login

There's still some work to be done in terms of implementing and showing/hiding the sidenav menu, profile, and logout icons given the user's authentication status.

Conditional navigation

Conditional navigation is necessary for creating a frustration-free UX. By selectively showing the elements that the user has access to and hiding the ones they don't have access to, we allow the user to confidently navigate through the application.

Let's start by hiding the login component after a user logs in to the application:

  1. On the HomeComponent, inject the AuthService into the constructor as a public variable:
    src/app/home/home.component.simple.ts
    ...
    import { AuthService } from '../auth/auth.service'
    ...
    export class HomeComponent { 
      constructor(public authService: AuthService) {}
    }
    
  2. Remove the local variable displayLogin, because we can directly tap into the auth status in the template using the async pipe.
  3. Implement a new template using the ngIf; else syntax, along with the async pipe, as shown here:
    src/app/home/home.component.ts
    ...
      template: `
        <div *ngIf= 
    "(authService.authStatus$ | async)?.isAuthenticated; else doLogin">
          <div class="mat-display-4">
            This is LemonMart! The place where
          </div>
          <div class="mat-display-4">
            You get a lemon, you get a lemon, you get a lemon...
          </div>
          <div class="mat-display-4">
            Everybody gets a lemon.
          </div>
        </div>
        <ng-template #doLogin>
          <app-login></app-login>
        </ng-template>  
      `,
    

    Using the async pipe avoids errors like Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Whenever you see this error, stop using local variables and instead use the async pipe. It is the reactive thing to do!

  4. On the AppComponent, we will follow a similar pattern by injecting AuthService as a public variable:
    src/app/app.component.ts
    import { Component, OnInit } from '@angular/core'
    import { AuthService } from './auth/auth.service'
    ...
    export class AppComponent implements OnInit { 
      constructor(..., public authService: AuthService) {
      }
      ngOnInit(): void {}
      ...
    }
    
  5. Update mat-toolbar in the template, so that we monitor both authStatus$ and currentUser$ using the async pipe:
          <mat-toolbar ...
            *ngIf="{
              status: authService.authStatus$ | async,
              user: authService.currentUser$ | async
            } as auth;">
    
  6. Use *ngIf to hide all buttons meant for logged-in users:
    src/app/app.component.ts
    <button *ngIf="auth?.status?.isAuthenticated" ... >
    

    Now, when a user is logged out, your toolbar should look all clean, with no buttons, as shown here:

    Figure 8.7: The LemonMart toolbar before a user logs in

  7. We can also swap out the generic account_circle icon in the profile button if the user has a picture:
    src/app/app.component.ts
    styles: [
    `
      .image-cropper {
        width: 40px;
        height: 40px;
        position: relative;
        overflow: hidden;
        border-radius: 50%;
        margin-top: -8px;
      }
    `],
    template: `
      ...
      <button
        *ngIf="auth?.status?.isAuthenticated"
        mat-mini-fab
        routerLink="/user/profile"
        matTooltip="Profile"
        aria-label="User Profile"
      >
        <img *ngIf="auth?.user?.picture" class="image-cropper"
          [src]="auth?.user?.picture" />
        <mat-icon *ngIf="!auth?.user?.picture">account_circle</mat-icon>
      </button>
    

We now have a highly functional toolbar that reacts to the auth status of the application and is additionally able to display information that belongs to the logged-in user.

Common validations for forms

Before we move on, we need to refactor the validations for LoginComponent. As we implement more forms in Chapter 11, Recipes – Reusability, Routing, and Caching, you will realize that it gets tedious, fast, to repeatedly type out form validations in either template or reactive forms. Part of the allure of reactive forms is that they are driven by code, so we can easily extract the validations to a shared class, unit test, and reuse them, as follows:

  1. Create a validations.ts file under the common folder.
  2. Implement email and password validations:
    src/app/common/validations.ts
    import { Validators } from '@angular/forms'
    export const EmailValidation = [
      Validators.required, Validators.email
    ]
    export const PasswordValidation = [
      Validators.required,
      Validators.minLength(8),
      Validators.maxLength(50),
    ]
    

Depending on your password validation needs, you can use a RegEx pattern with the Validations.pattern() function to enforce password complexity rules or leverage the OWASP npm package, owasp-password-strength-test, to enable pass-phrases, as well as set more flexible password requirements. See the link to the OWASP authentication general guidelines in the Further reading section.

  1. Update the login component with the new validations:
    src/app/login/login.component.ts
    import { EmailValidation, PasswordValidation } from '../common/validations'
      ...
        this.loginForm = this.formBuilder.group({
          email: ['', EmailValidation],
          password: ['', PasswordValidation],
        })
    

Next, let's encapsulate some common UI behavior in an Angular service.

UI service

As we start dealing with complicated workflows, such as the auth workflow, it is important to be able to programmatically display a toast notification for the user. In other cases, we may want to ask for a confirmation before executing a destructive action with a more intrusive pop-up notification.

No matter what component library you use, it gets tedious to recode the same boilerplate just to display a quick notification. A UI service can neatly encapsulate a default implementation that can also be customized as needed.

In the UI service, we will implement a showToast and a showDialog function that can trigger notifications or prompt users for a decision, in such a manner that we can use it within the code that implements our business logic.

Let's get started:

  1. Create a new service named ui under common.
  2. Implement a showToast function using MatSnackBar:

    Check out the documentation for MatSnackBar at https://material.angular.io.

    Don't forget to update app.module.ts and material.module.ts with the various dependencies as they are introduced.

    src/app/common/ui.service.ts
    @Injectable({
      providedIn: 'root',
    })
    export class UiService {
      constructor(private snackBar: MatSnackBar, private dialog: MatDialog) {}
      showToast(message: string, action = 'Close', config?: MatSnackBarConfig) {
        this.snackBar.open( message,
        action,
        config || { duration: 7000}
        )
      }
    ...
    }
    

    For a showDialog function using MatDialog, we must implement a basic dialog component.

    Check out the documentation for MatDialog at https://material.angular.io.

  3. Add a new component named simpleDialog under the common folder provided in app.module.ts with inline templates and styling, skip testing, and a flat folder structure:
    app/common/simple-dialog.component.ts
    import { Component, Inject } from '@angular/core'
    import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'
    @Component({
      // prettier-ignore
      template: `
        <h2 mat-dialog-title>{{ data.title }}</h2>
        <mat-dialog-content>
          <p>{{ data.content }}</p>
        </mat-dialog-content>
        <mat-dialog-actions>
          <span class="flex-spacer"></span>
          <button mat-button mat-dialog-close *ngIf="data.cancelText">
            {{ data.cancelText }}
          </button>
          <button mat-button mat-button-raised color="primary" [mat-dialog-close]="true"
            cdkFocusInitial>
            {{ data.okText }}
          </button>
        </mat-dialog-actions>
      `
    })
    export class SimpleDialogComponent {
      constructor(
        public dialogRef: MatDialogRef<SimpleDialogComponent, boolean>,
        @Inject(MAT_DIALOG_DATA) public data: any
      ) {}
    }
    

    Note that SimpleDialogComponent should not have an application selector like selector: 'app-simple-dialog' since we only plan to use it with UiService. Remove this property from your component.

  4. Then, implement a showDialog function using MatDialog to display the SimpleDialogComponent:
    app/common/ui.service.ts
    ...
    showDialog(
      title: string,
      content: string,
      okText = 'OK',
      cancelText?: string,
      customConfig?: MatDialogConfig
    ): Observable<boolean> {
      const dialogRef = this.dialog.open(
        SimpleDialogComponent,
        customConfig || {
          width: '300px',
          data: { title, content, okText, cancelText },
        }
      )
      return dialogRef.afterClosed()
    }
    

    ShowDialog returns an Observable<boolean>, so you can implement a follow-on action, depending on what selection the user makes. Clicking on OK will return true, and Cancel will return false.

    In SimpleDialogComponent, using @Inject, we're able to use all variables sent by showDialog to customize the content of the dialog.

  5. In app.module.ts, declare SimpleDialogComponent as an entry component:
    src/app/app.module.ts
    @NgModule({
      ...
      bootstrap: [AppComponent],
      entryComponents: [SimpleDialogComponent],
    })
    Export class AppModule {}
    

    Note that with the Ivy rendering engine, entryComponents should be unnecessary and is deprecated in Angular 9. However, at the time of publishing, it is still required to declare this component as an entry component.

  6. Update the login() function on the LoginComponent to display a toast message after login:
    src/app/login/login.component.ts
    import { UiService } from '../common/ui.service'
    ...
    constructor(... , private uiService: UiService)
      ...
      async login(submittedForm: FormGroup) {
        ...
        tap(([authStatus, user]) => {
          this.uiService.showToast(
            `Welcome ${user.fullName}! Role: ${user.role}`
          )
          ...
        })
     ...
    

    Now, a toast message will appear after a user logs in, as shown:

    Figure 8.8: Material snackbar

    The snackBar will either take up the full width of the screen or a portion, depending on the size of the browser.

  7. Experiment with displaying a dialog instead:
    src/app/login/login.component.ts
    this.uiService.showDialog(
      `Welcome ${user.fullName}!`, `Role: ${user.role}`
    )
    

Now that you've verified that both showToast and showDialog work, which one do you prefer? My rule of thumb is that unless the user is about to take an irreversible action, you should choose toast messages over dialogs, so you don't interrupt the user's workflow.

Next, let's implement an application-wide side navigation experience as an alternative to the toolbar-based navigation we already have, so that users can switch between modules with ease.

Side navigation

Enable mobile-first workflows and provide an easy navigation mechanism to quickly jump to the desired functionality. Using the authentication service, given a user's current role, only display the links for features they can access. We will be implementing the side navigation mock-up as follows:

Figure 8.9: Side navigation mock-up

Let's implement the code for the side navigation as a separate component, so that it is easier to maintain:

  1. In the root of the application, create a component named NavigationMenu with inline templates and styles.

    The side navigation isn't technically required until after a user is logged in. However, in order to be able to launch the side navigation menu from the toolbar, we need to be able to trigger it from AppComponent. Since this component will be simple, we will eagerly load it. To do this lazily, Angular does have a Dynamic Component Loader pattern, which has a high implementation overhead that will only make sense if multi-hundred kilobyte savings are made.

    SideNav will be triggered from the toolbar, and it comes with a <mat-sidenav-container> parent container that hosts the SideNav itself and the content of the application. So, we will need to render all application content by placing the <router-outlet> inside <mat-sidenav-content>.

  2. In AppComponent, define some styles that will ensure that the web application will expand to fill the entire page and remain properly scrollable for desktop and mobile scenarios:
    src/app/app.component.ts
    styles: [
      `
        .app-container {
          display: flex;
          flex-direction: column;
          position: absolute;
          top: 0;
          bottom: 0;
          left: 0;
          right: 0;
        }
        .app-is-mobile .app-toolbar {
          position: fixed;
          z-index: 2;
        }
        .app-sidenav-container {
          flex: 1;
        }
        .app-is-mobile .app-sidenav-container {
          flex: 1 0 auto;
        }
        mat-sidenav {
          width: 200px;
        }
        .image-cropper {
          width: 40px;
          height: 40px;
          position: relative;
          overflow: hidden;
          border-radius: 50%;
          margin-top: -8px;
        }
      `,
    ],
    
  3. Inject the MediaObserver service from Angular Flex Layout in AppComponent. Also, implement OnInit and OnDestory, initialize SubSink, and add a Boolean property named opened:
    src/app/app.component.ts
    import { MediaObserver } from '@angular/flex-layout'
    export class AppComponent implements OnInit, OnDestroy {
      private subs = new SubSink()
      opened: boolean
      constructor(
        ...
        public media: MediaObserver
      ) {
      ...
      }
      ngOnDestroy() {
        this.subs.unsubscribe()
      }
      ngOnInit(): void {
        throw new Error('Method not implemented.')
      }
    }
    

    To automatically determine the open/closed status of the side navigation, we need to monitor the media observer and the auth status. When the user logs in, we would like to show the side navigation, and hide it when the user logs out. We can do this with settings opened to the value of authStatus$.isAuthenticated. However, if we only consider isAuthenticated, and the user is on a mobile device, we will create a less than ideal UX. Watching for the media observer's mediaValue, we can check to see whether the screen size is set to extra small, or xs; if so, we can keep the side navigation closed.

  4. Update ngOnInit to implement the dynamic side navigation open/closed logic:
    src/app/app.component.ts
      ngOnInit() {
        this.subs.sink = combineLatest([
          this.media.asObservable(),
          this.authService.authStatus$,
        ])
          .pipe(
            tap(([mediaValue, authStatus]) => {
              if (!authStatus?.isAuthenticated) {
                this.opened = false
              } else {
                if (mediaValue[0].mqAlias === 'xs') {
                  this.opened = false
                } else {
                  this.opened = true
                }
              }
            })
          )
          .subscribe()
      }
    

    By monitoring both the media and authStatus$ streams, we can consider unauthenticated scenarios where the side navigation should not be opened even if there's enough screen space.

  5. Update the template with a responsive SideNav that will slide over the content in mobile or push the content aside in desktop scenarios:
    src/app/app.component.ts
    ...
    // prettier-ignore
    template: `
      <div class="app-container">
      <mat-toolbar color="primary" fxLayoutGap="8px"
        class="app-toolbar"
        [class.app-is-mobile]="media.isActive('xs')"
        *ngIf="{
          status: authService.authStatus$ | async,
          user: authService.currentUser$ | async
        } as auth;"
      >
        <button *ngIf="auth?.status?.isAuthenticated"
          mat-icon-button (click)="sidenav.toggle()"
        >
          <mat-icon>menu</mat-icon>
        </button>
        ...
      </mat-toolbar>
      <mat-sidenav-container class="app-sidenav-container">
        <mat-sidenav #sidenav
          [mode]="media.isActive('xs') ? 'over' : 'side'"
          [fixedInViewport]="media.isActive('xs')"
          fixedTopGap="56" [(opened)]="opened"
        >
          <app-navigation-menu></app-navigation-menu>
        </mat-sidenav>
        <mat-sidenav-content>
          <router-outlet></router-outlet>
        </mat-sidenav-content>
      </mat-sidenav-container>
      </div>
    `,
    

    The preceding template leverages the Angular Flex Layout media observer that was injected earlier for a responsive implementation.

    You can use the // prettier-ignore directive above your template to prevent Prettier from breaking up your template into too many lines, which can hurt readability in certain conditions similar to this one.

    We will implement navigational links in NavigationMenuComponent. The number of links in our application will likely grow over time and be subject to various role-based business rules. Therefore, if we were to implement these links in app.component.ts, we would risk that file getting too large. In addition, we don't want app.component.ts to change very often, since changes made there can impact the entire application. It is a good practice to implement the links in a separate component.

  6. Implement navigational links in NavigationMenuComponent:
    src/app/navigation-menu/navigation-menu.component.ts
    ...
      styles: [
        `
          .active-link {
            font-weight: bold;
            border-left: 3px solid green;
          }
        `,
      ],
      template: `
        <mat-nav-list>
          <h3 matSubheader>Manager</h3>
          <a mat-list-item
            routerLinkActive="active-link"
            routerLink="/manager/users">
              Users
          </a>
          <a mat-list-item
            routerLinkActive="active-link"
            routerLink="/manager/receipts">
              Receipts
          </a>
          <h3 matSubheader>Inventory</h3>
          <a mat-list-item
            routerLinkActive="active-link"
            routerLink="/inventory/stockEntry">
              Stock Entry
          </a>
          <a mat-list-item
            routerLinkActive="active-link"
            routerLink="/inventory/products">
              Products
          </a>
          <a mat-list-item
            routerLinkActive="active-link"
            routerLink="/inventory/categories">
              Categories
          </a>
          <h3 matSubheader>Clerk</h3>
          <a mat-list-item
            routerLinkActive="active-link"
            routerLink="/pos">
              POS
          </a>
        </mat-nav-list>
      `,
    ...
    

<mat-nav-list> is functionally equivalent to <mat-list>, so you can use the documentation of MatList for layout purposes. Observe the subheaders for Manager, Inventory, and Clerk here:

Figure 8.10: The Manager dashboard showing Receipt Lookup on desktop

routerLinkActive="active-link" highlights the selected Receipts route, as shown in the preceding screenshot.

Additionally, you can see the difference in appearance and behavior on mobile devices as follows:

Figure 8.11: The Manager dashboard showing Receipt Lookup on mobile

Next, let's implement role-based routing.

Role-based routing using guards

This is the most elemental and important part of your application. With lazy loading, we have ensured that only the bare minimum number of assets will be loaded to enable a user to log in.

Once a user logs in, they should be routed to the appropriate landing screen as per their user role, so they're not guessing how they need to use the application. For example, a cashier needs to only access the point of sale (POS) to check out customers, so they can automatically be routed to that screen.

The following is a mock-up of the POS screen:

Figure 8.12: A POS screen mock-up

Let's ensure that users get routed to the appropriate page after logging in by updating the LoginComponent.

Update the login logic to route per role in the function named homeRoutePerRole:

app/src/login/login.component.ts
async login(submittedForm: FormGroup) {
  ...
    this.router.navigate([
      this.redirectUrl ||
      this.homeRoutePerRole(user.role as Role)
    ])
  ...
}
private homeRoutePerRole(role: Role) {
  switch (role) {
    case Role.Cashier:
      return '/pos'
    case Role.Clerk:
      return '/inventory'
    case Role.Manager:
      return '/manager'
    default:
      return '/user/profile'
  }
}

Similarly, clerks and managers are routed to their landing screens to access the features they need to accomplish their tasks, as shown earlier. Since we have implemented a default manager role, the corresponding landing experience will be launched automatically. The other side of the coin is intentional and unintentional attempts to access routes that a user isn't meant to have access to. In the next section, you will learn about router guards that can help to check authentication and even load requisite data before the form is rendered.

Router guards

Router guards enable the further decoupling and reuse of logic, and greater control over the component life cycle.

Here are the four major guards you will most likely use:

  1. CanActivate and CanActivateChild: Used for checking auth access to a route
  2. CanDeactivate: Used to ask permission before navigating away from a route
  3. Resolve: Allows the pre-fetching of data from route parameters
  4. CanLoad: Allows custom logic to execute before loading feature module assets

Refer to the following sections to discover how to leverage CanActivate and CanLoad. The Resolve guard will be covered in Chapter 11, Recipes – Reusability, Routing, and Caching.

Auth guards

Auth guards enable a good UX by allowing or disallowing accidental navigation to a feature module or a component before the module has loaded or before any improper data requests have been made to the server. For example, when a manager logs in, they're automatically routed to the /manager/home path. The browser will cache this URL, and it would be completely plausible for a clerk to accidentally navigate to the same URL. Angular doesn't know whether a particular route is accessible to a user or not and, without an AuthGuard, it will happily render the manager's home page and trigger server requests that will end up failing.

Regardless of the robustness of your frontend implementation, every REST API you implement should be properly secured server-side.

Let's update the router so that ProfileComponent can't be activated without an authenticated user and the ManagerModule won't load unless a manager is logging in using an AuthGuard:

  1. Implement an AuthGuard service:
    src/app/auth/auth-guard.service.ts
    import { Injectable } from '@angular/core'
    import {
      ActivatedRouteSnapshot,
      CanActivate,
      CanActivateChild,
      CanLoad,
      Route,
      Router,
      RouterStateSnapshot,
    } from '@angular/router'
    import { Observable } from 'rxjs'
    import { map, take } from 'rxjs/operators'
    import { UiService } from '../common/ui.service'
    import { Role } from './auth.enum'
    import { AuthService } from './auth.service'
    @Injectable({
      providedIn: 'root',
    })
    export class AuthGuard implements CanActivate, CanActivateChild, CanLoad {
      constructor(
        protected authService: AuthService,
        protected router: Router,
        private uiService: UiService
      ) {}
      canLoad(route: Route):
        boolean | Observable<boolean> | Promise<boolean> {
          return this.checkLogin()
      }
      canActivate(
        route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot
      ): boolean | Observable<boolean> | Promise<boolean> {
        return this.checkLogin(route)
      }
      canActivateChild(
        childRoute: ActivatedRouteSnapshot,
        state: RouterStateSnapshot
      ): boolean | Observable<boolean> | Promise<boolean> {
        return this.checkLogin(childRoute)
      }
      protected checkLogin(route?: ActivatedRouteSnapshot):
        Observable<boolean> {
        return this.authService.authStatus$.pipe(
          map((authStatus) => {
            const roleMatch = this.checkRoleMatch(
              authStatus.userRole, route
            )
            const allowLogin = authStatus.isAuthenticated && roleMatch
            if (!allowLogin) {
              this.showAlert(authStatus.isAuthenticated, roleMatch)
              this.router.navigate(['login'], {
                queryParams: {
                  redirectUrl: this.getResolvedUrl(route),
                },
              })
            }
            return allowLogin
          }),
          take(1) // complete the observable for the guard to work
        )
      }
      private checkRoleMatch(
        role: Role,
        route?: ActivatedRouteSnapshot
      ) {
        if (!route?.data?.expectedRole) {
          return true
        }
        return role === route.data.expectedRole
      }
      private showAlert(isAuth: boolean, roleMatch: boolean) {
        if (!isAuth) {
          this.uiService.showToast('You must login to continue')
        }
        if (!roleMatch) {
          this.uiService.showToast(
            'You do not have the permissions to view this resource'
          )
        }
      }
      getResolvedUrl(route?: ActivatedRouteSnapshot): string {
        if (!route) {
          return ''
        }
        return route.pathFromRoot
          .map((r) => r.url.map((segment) => segment.toString())
          .join('/'))
          .join('/')
          .replace('//', '/')
      }
    }
    
  2. Use the CanLoad guard to prevent the loading of a lazily loaded module, such as the manager's module:
    src/app/app-routing.module.ts
    ...
    {
      path: 'manager',
      loadChildren: () => import('./manager/manager.module')
        .then((m) => m.ManagerModule), 
      canLoad: [AuthGuard],
    },
    ...
    

    In this instance, when the ManagerModule is being loaded, AuthGuard will be activated during the canLoad event, and the checkLogin function will verify the authentication status of the user. If the guard returns false, the module will not be loaded. At this point, we don't have the metadata to check the role of the user.

  3. Use the CanActivate guard to prevent the activation of individual components, such as the user's profile:
    src/app/user/user-routing.module.ts
    ...
    { 
      path: 'profile', component: ProfileComponent, 
      canActivate: [AuthGuard] 
    },
    ...
    

    In the case of user-routing.module.ts, AuthGuard is activated during the canActivate event, and the checkLogin function controls where this route can be navigated to. Since the user is viewing their own profile, there's no need to check the user's role here.

  4. Use CanActivate or CanActivateChild with an expectedRole property to prevent the activation of components by other users, such as ManagerHomeComponent:
    src/app/mananger/manager-routing.module.ts
    ...
      {
        path: 'home',
        component: ManagerHomeComponent,
        canActivate: [AuthGuard],
        data: {
          expectedRole: Role.Manager,
        },
      },
      {
        path: 'users',
        component: UserManagementComponent,
        canActivate: [AuthGuard],
        data: {
          expectedRole: Role.Manager,
        },
      },
      {
        path: 'receipts',
        component: ReceiptLookupComponent,
        canActivate: [AuthGuard],
        data: {
          expectedRole: Role.Manager,
        },
      },
    ...
    

Inside ManagerModule, we can verify whether the user is authorized to access a particular route. We can do this by defining some metadata in the route definition, like expectedRole, which will be passed into the checkLogin function by the canActivate event. If a user is authenticated but their role doesn't match Role.Manager, AuthGuard will return false and the navigation will be prevented.

Next, we will go over some techniques to get our tests passing.

Auth service fake and common testing providers

We need to provide mocked versions of services like AuthService or UiService using the commonTestingProviders function in common.testing.ts, using a pattern similar to commonTestingModules, which was mentioned in Chapter 7, Creating a Router-First Line-of-Business App. This way, we won't have to mock the same objects over and over again.

Let's create the spy objects using the autoSpyObj function from angular-unit-test-helper and go over some less obvious changes we need to implement to get our tests passing:

  1. Update commonTestingProviders in common.testing.ts:
    src/app/common/common.testing.ts
    import { autoSpyObj } from 'angular-unit-test-helper'
    export const commonTestingProviders: any[] = [
      { provide: AuthService, useValue: autoSpyObj(AuthService) },
      { provide: UiService, useValue: autoSpyObj(UiService) }, 
    ]
    
  2. Observe the fake being provided for the MediaObserver in app.component.spec.ts and update it to use commonTestingModules:
    src/app/app.component.spec.ts
    ...
      TestBed.configureTestingModule({
        imports: commonTestingModules,
        providers: commonTestingProviders.concat([
          { provide: MediaObserver, useClass: MediaObserverFake },
    ...
    

    See how the commonTestingProviders array is being concatenated with fakes that are specific to app.component.ts; our new mocks should apply automatically.

  3. Update the spec file for LoginComponent to leverage commonTestingModules and commonTestingProviders:
    src/app/login/login.component.spec.ts
    ...
      TestBed.configureTestingModule({
        imports: commonTestingModules,
        providers: commonTestingProviders,
        declarations: [LoginComponent],
      }).compileComponents()
    
  4. Go ahead and apply this technique to all spec files that have a dependency on AuthService and UiService.
  5. The notable exception is services, as in auth.service.spec.ts, where you do not want to use a test double. Since AuthService is the class under test, make sure it is configured as follows:
    src/app/auth/auth.service.spec.ts
    ...
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [AuthService, 
      { provide: UiService, useValue: autoSpyObj(UiService) }],
    })
    
  6. Update ui.service.spec.ts with similar considerations.

Remember, don't move on until all your tests are passed!

Firebase authentication recipe

We can leverage our current authentication setup and integrate it with a real authentication service. For this section, you need a free Google and Firebase account. Firebase is Google's comprehensive mobile development platform: https://firebase.google.com. You can create a free account to host your application and leverage the Firebase authentication system.

The Firebase console, found at https://console.firebase.google.com, allows you to manage users and send a password reset email without having to implement a backend for your application. Later on, you can leverage Firebase functions to implement APIs in a serverless manner.

Start by adding your project to Firebase using the Firebase console:

Figure 8.13: The Firebase console

  1. Click on Add project
  2. Provide your project name
  3. Enable Google Analytics for your project

It helps to create a Google Analytics account before attempting this, but it should still work. Once your project is created, you should see your project dashboard:

Figure 8.14: The Firebase project overview

On the left-hand side, marked with step 1, you can see a menu of tools and services that you can add to your project. At the top, marked with step 2, you can quickly jump between your projects. First, you need to add an application to your project.

Add an application

Your project can include multiple distributions of your application, like web, iOS, and Android versions. In this chapter, we're only interested in adding a web application.

Let's get started:

  1. On your project dashboard, click on the web application button to add an application, which is marked with step 3 in Figure 8.14
  2. Provide an application nickname
  3. Select the option to set up Firebase Hosting
  4. Continue by hitting the Register app button
  5. Skip over the Add Firebase SDK section
  6. Install the Firebase CLI as instructed:
    $ npm install -g firebase-tools
    
  7. Sign in:
    $ firebase login
    

    Make sure your current directory is your project's root folder.

  8. Initialize your project:
    $ firebase init
    
  9. Select the Hosting option; don't worry, you can add more features later
  10. Select the project you created as the default, that is, lemon-mart-007
  11. For the public directory enter dist/lemon-mart or the outputPath defined in your angular.json file
  12. Say yes to configure it as a single-page application.

    This will create two new files: firebase.json and .firebaserc.

  13. Build your project for production:
    $ npx ng build --prod
    

    or

    $ npm run build:prod
    
  14. Now you can deploy your Angular application by executing the following command:
    $ firebase deploy
    

Your website should be available on a URL similar to https://lemon-mart-007.firebaseapp.com, as shown in the terminal.

Add the .firebase folder to .gitignore so you don't check in your cache files. The other two files, firebase.json and .firebaserc, are safe to commit.

Optionally, connect a custom domain name that you own to the account using the Firebase console.

Configure authentication

Now, let's configure authentication.

In the Firebase console:

  1. Select Authentication from the side navigation:

    Figure 8.15: The Firebase Authentication page

  2. Select Email/Password as the provider
  3. Enable it
  4. Do not enable the email link
  5. Save your configuration

You can now see the user management console:

Figure 8.16: The Firebase user management console

It is fairly straightforward and intuitive to operate, so I will leave the configuration of it as an exercise for you.

Implement Firebase authentication

Let's start by adding Angular Fire, the official Firebase library for Angular, to our application:

$ npx ng add @angular/fire

Follow Angular Fire's quickstart guide to finish setting up the library with your Angular project, which you can find linked from the readme file on GitHub at https://github.com/angular/angularfire2.

  1. Ensure Firebase modules are provided in app.module.ts as per the documentation.
  2. Ensure your Firebase config object is in all your environment.ts files.

    Note that any information provided in environment.ts is public information. So, when you place your Firebase API key in this file, it will be publicly available. There's a small chance that another developer could abuse your API key and run up your bill. To protect yourself from any such attack, check out this blog post by paachu: How to secure your Firebase project even when your API key is publicly available at https://medium.com/@impaachu/how-to-secure-your-firebase-project-even-when-your-api-key-is-publicly-available-a462a2a58843.

  3. Create a new FirebaseAuthService:
    $ npx ng g s auth/firebaseAuth --lintFix
    
  4. Rename the service file to auth.firebase.service.ts.
  5. Be sure to remove { providedIn: 'root' }.
  6. Implement Firebase auth by extending the abstract auth service:
    src/app/auth/auth.firebase.service.ts
    import { Injectable } from '@angular/core'
    import { AngularFireAuth } from '@angular/fire/auth'
    import { User as FirebaseUser } from 'firebase'
    import { Observable, Subject } from 'rxjs'
    import { map } from 'rxjs/operators'
    import { IUser, User } from '../user/user/user'
    import { Role } from './auth.enum'
    import {
      AuthService,
      IAuthStatus,
      IServerAuthResponse,
      defaultAuthStatus,
    } from './auth.service'
    interface IJwtToken {
      email: string
      iat: number
      exp: number
      sub: string
    }
    @Injectable()
    export class FirebaseAuthService extends AuthService {
      constructor(private afAuth: AngularFireAuth) {
        super()
      }
      protected authProvider(
        email: string,
        password: string
      ): Observable<IServerAuthResponse> {
        const serverResponse$ = new Subject<IServerAuthResponse>()
        this.afAuth.signInWithEmailAndPassword(email, password).then(
          (res) => {
            const firebaseUser: FirebaseUser | null = res.user
            firebaseUser?.getIdToken().then(
              (token) => serverResponse$.next(
                { accessToken: token } as IServerAuthResponse
              ),
              (err) => serverResponse$.error(err)
            )
          },
          (err) => serverResponse$.error(err)
        )
        return serverResponse$
      }
      protected transformJwtToken(token: IJwtToken): IAuthStatus {
        if (!token) {
          return defaultAuthStatus
        }
        return {
          isAuthenticated: token.email ? true : false,
          userId: token.sub,
          userRole: Role.None,
        }
      }
      protected getCurrentUser(): Observable<User> {
        return this.afAuth.user.pipe(map(this.transformFirebaseUser))
      }
      private transformFirebaseUser(firebaseUser: FirebaseUser): User
      {
        if (!firebaseUser) {
          return new User()
        }
        return User.Build({
          name: {
            first: firebaseUser?.displayName?.split(' ')[0] ||
              'Firebase',
            last: firebaseUser?.displayName?.split(' ')[1] || 'User',
          },
          picture: firebaseUser.photoURL,
          email: firebaseUser.email,
          _id: firebaseUser.uid,
          role: Role.None,
        } as IUser)
      }
      logout() {
        if (this.afAuth) {
          this.afAuth.signOut()
        }
        this.clearToken()
        this.authStatus$.next(defaultAuthStatus)
      }
    }
    

    As you can see, we only had to implement the delta between our already established authentication code and Firebase's authentication methods. We didn't have to duplicate any code and we even transformed a Firebase user object into our application's internal user object.

  7. To use Firebase authentication instead of in-memory authentication, update the AuthService provider in app.module.ts:
    src/app/app.module.ts
      {
        provide: AuthService,
        useClass: FirebaseAuthService,
      }, 
    

    Once you've completed the steps, add a new user from the Firebase authentication console and you should be able to log in using real authentication.

    Always make sure that you're using HTTPS when transmitting any kind of personally identifiable information (PII) or sensitive information (like passwords) over the Internet. Otherwise, your information will get logged on third-party servers or captured by bad actors.

  8. Once again, be sure to update your unit tests before moving on:
    src/app/auth/auth.firebase.service.spec.ts
    import { AngularFireAuth } from '@angular/fire/auth'
    import { UiService } from '../common/ui.service'
    import { FirebaseAuthService } from './auth.firebase.service'
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        FirebaseAuthService,
        { provide: UiService, useValue: autoSpyObj(UiService) },
        { provide: AngularFireAuth, 
          useValue: autoSpyObj(AngularFireAuth) 
        },
      ],
    })
    

Stop! Remove the fake-jwt-sign package from your project before deploying a real authentication method.

Congratulations, your application is integrated with Firebase! Next, let's cover service factories, which can help you to switch the providers of your abstract classes dynamically.

Providing a service using a factory

You can dynamically choose providers during load time, so instead of having to change code to switch between authentication methods, you can parametrize environment variables, so different kinds of builds can have different authentication methods. This is especially useful when writing automated UI tests against your application, where real authentication can be difficult, if not impossible, to deal with.

First, we will create an enum in environment.ts to help define our options, and then we will use that enum to choose an auth provider during our application's bootstrap process.

Let's get started:

  1. Create a new enum called AuthMode:
    src/app/auth/auth.enum.ts
    export enum AuthMode {
      InMemory = 'In Memory',
      CustomServer = 'Custom Server',
      Firebase = 'Firebase',
    }
    
  2. Add an authMode property in environment.ts:
    src/environments/environment.ts
    ...
      authMode: AuthMode.InMemory,
    ...
    src/environments/environment.prod.ts
    ...
      authMode: AuthMode.Firebase,
    ...
    
  3. Create an authFactory function in a new file under auth/auth.factory.ts:
    src/app/auth/auth.factory.ts
    export function authFactory(afAuth: AngularFireAuth) {
      switch (environment.authMode) {
        case AuthMode.InMemory:
          return new InMemoryAuthService()
        case AuthMode.Firebase:
          return new FirebaseAuthService(afAuth)
        case AuthMode.CustomServer:
          throw new Error('Not yet implemented')
      }
    }
    

    Note that the factory has to import any dependent service.

  4. Update the AuthService provider in app.module.ts to use the factory instead:
    src/app/app.module.ts
      providers: [
        {
          provide: AuthService,
          useFactory: authFactory,
          deps: [AngularFireAuth],
        }, 
    

Note that you can remove imports of InMemoryAuthService and FirebaseAuthService from AppModule.

With this configuration in place, whenever you build your application for local development, you will be using the in-memory auth service and production (or prod) builds will use the Firebase auth service.

Summary

You should now be familiar with how to create high-quality auth experiences. In this chapter, we defined a User object that we can hydrate from or serialize to JSON objects, applying object-oriented class design and TypeScript operators for safe data handling.

We leveraged OOP design principals, using inheritance and abstract classes to implement a base auth service that demonstrates the Open/Closed principle.

We covered the fundamentals of token-based authentication and JWTs so that you don't leak any critical user information. You learned that caching and HTTP interceptors are necessary so that users don't have to input their login information with every request. Following that, we implemented two distinct auth providers, one in-memory and one with Firebase.

We then designed a great conditional navigation experience that you can use in your own applications by copying the base elements to your project and implementing your own auth provider. We created a reusable UI service so that you can conveniently inject alerts into the flow-control logic of your application.

Finally, we covered router guards to prevent users from stumbling onto screens they are not authorized to use, and we reaffirmed the point that the real security of your application should be implemented on the server side. You saw how you can use a factory to dynamically provide different auth providers for different environments.

In the next chapter, we will shift gears a bit and learn about containerization using Docker. Docker allows powerful workflows that can greatly improve development experiences, while allowing you to implement your server configuration as code, putting a final nail in the coffin of the developer's favorite excuse when their software breaks: "But it works on my machine!"

Further reading

Questions

Answer the following questions as best as you can to ensure that you've understood the key concepts from this chapter without Googling. Do you need help answering the questions? See Appendix D, Self-Assessment Answers online at https://static.packt-cdn.com/downloads/9781838648800_Appendix_D_Self-Assessment_Answers.pdf or visit https://expertlysimple.io/angular-self-assessment.

  1. What's in-transit and at-rest security?
  2. What's the difference between authentication and authorization?
  3. Explain inheritance and polymorphism.
  4. What is an abstract class?
  5. What is an abstract method?
  6. Explain how the AuthService adheres to the Open/Closed principle.
  7. How does JWT verify your identity?
  8. What is the difference between RxJS's combineLatest and merge operators?
  9. What is a router guard?
  10. What does a service factory allow you to do?
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.137.213.128