Introduction

Welcome to Chapter 8! In the exciting world of web applications, knowing who a user is (authentication) and what they’re allowed to do (authorization) is paramount. Without these, your application is an open book, vulnerable to unauthorized access and data breaches. This chapter dives deep into implementing robust authentication and authorization mechanisms in your modern Angular v20.x standalone application.

We’ll move beyond simple login forms to understand the lifecycle of JSON Web Tokens (JWTs), how to securely manage them, and how to gracefully handle token expiration with silent refresh flows. You’ll learn how to safeguard your application’s routes using functional Angular Route Guards and implement granular, role-based access control. By the end of this chapter, you’ll have a solid understanding of how to build a secure, enterprise-grade authentication system that provides a seamless user experience.

Before we begin, ensure you’re comfortable with the concepts of standalone components, services, and HTTP client interactions, as covered in previous chapters, especially Chapter 7 on advanced HTTP networking. We’ll be building upon the HttpClient and HTTP_INTERCEPTORS concepts significantly here.

Core Concepts

Authentication and authorization are often used interchangeably, but they serve distinct purposes. Let’s clarify them and then explore the modern token-based approach.

Authentication vs. Authorization: Knowing the Difference

  • Authentication: This is the process of verifying who a user is. Think of it like showing your ID to get into a club. You prove your identity. In web apps, this usually involves a username and password, or perhaps a social login.
  • Authorization: This is the process of determining what an authenticated user is allowed to do. Once you’re inside the club, authorization dictates which areas you can access (e.g., VIP lounge, general dance floor) based on your ticket or status. In web apps, this translates to accessing specific routes, features, or data based on roles or permissions.

Why does this distinction matter? If you confuse them, you might accidentally grant access to unauthenticated users or allow authenticated users to perform actions they shouldn’t.

Token-Based Authentication: The Modern Way

In modern single-page applications (SPAs) like Angular, token-based authentication, particularly using JSON Web Tokens (JWTs), is the de facto standard.

What are JWTs?

A JWT is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is digitally signed using a secret (HMAC) or a public/private key pair (RSA or ECDSA).

Why JWTs?

  • Statelessness: The server doesn’t need to store session information. Each request contains the token, which the server can validate independently. This is great for scalability.
  • Self-Contained: A JWT contains all the necessary information about the user (claims), reducing database lookups for every request.
  • Security: They are cryptographically signed, making them tamper-proof.

A typical JWT flow involves:

  1. User sends credentials (username/password) to the authentication server.
  2. Authentication server validates credentials and, if successful, issues an Access Token (JWT) and often a Refresh Token.
  3. The Angular application stores these tokens.
  4. For every subsequent request to a protected API endpoint, the Angular app sends the Access Token in the Authorization header (Bearer <access_token>).
  5. The API server validates the Access Token. If valid, it processes the request.

Access Tokens and Refresh Tokens: A Dynamic Duo

  • Access Token (JWT): This is the token used for authenticating API requests. For security reasons, Access Tokens usually have a short expiration time (e.g., 5-15 minutes). This limits the window of opportunity for attackers if a token is compromised.
  • Refresh Token: This token is used to obtain a new Access Token once the current one expires, without requiring the user to log in again. Refresh Tokens have a longer expiration time (e.g., days or weeks) and are typically sent to a dedicated /refresh endpoint.

What happens if you ignore refresh tokens? Users would be logged out every 5-15 minutes, leading to a terrible user experience and frequent interruptions.

Secure Token Storage: A Balancing Act

Where do you store these precious tokens in the browser? This is a critical security decision with trade-offs.

  1. localStorage / sessionStorage:

    • Pros: Easy to use, accessible via JavaScript, persists across browser sessions (localStorage).
    • Cons: Highly vulnerable to Cross-Site Scripting (XSS) attacks. If an attacker injects malicious JavaScript into your page, they can easily steal tokens from localStorage.
    • Recommendation: Generally not recommended for sensitive tokens like Access Tokens in production without strong Content Security Policy (CSP) and other XSS mitigations.
  2. HttpOnly Cookies:

    • Pros: Not accessible via JavaScript, making them largely immune to XSS attacks (attacker can’t read the cookie). Can be marked Secure (only sent over HTTPS) and SameSite (prevents CSRF).
    • Cons: Vulnerable to Cross-Site Request Forgery (CSRF) if not properly mitigated with SameSite=Lax or anti-CSRF tokens. Requires server-side management to set and clear cookies.
    • Recommendation: Often considered more secure for Access Tokens, especially when combined with CSRF protection. However, managing them in an SPA with a separate API backend can be more complex.

For this guide, we’ll primarily use localStorage for simplicity in examples, but always remember the XSS vulnerability and consider HttpOnly cookies for production-grade security, especially if your backend can manage them.

Angular Route Guards: The Bouncers of Your App

Route Guards are powerful features in Angular that allow you to control navigation to, from, or between routes. They are functions that Angular’s router checks before activating a route, deactivating a route, or loading a lazy-loaded module.

As of Angular v15+, functional guards are the recommended approach, replacing class-based guards and leveraging the inject function for dependency injection.

Key Guard Types:

  • CanActivate: Controls if a route can be activated. Perfect for checking if a user is authenticated or has the necessary roles before entering a page.
  • CanActivateChild: Controls if child routes can be activated. Useful for applying a guard to an entire section of your application.
  • CanMatch: Controls if a route should be loaded at all. Crucial for lazy-loaded routes, preventing the module from being downloaded if the user doesn’t have permission. This is generally preferred over CanActivate for lazy-loaded feature modules.
  • CanDeactivate: Controls if a user can leave a route. Useful for prompting users to save unsaved changes before navigating away.
  • Resolve: Not strictly a guard, but a resolver fetches data before the route is activated. This ensures the component has all necessary data immediately upon creation, preventing flickering or loading states (though loading states are still good UX).

What happens if you ignore guards? Users could manually type URLs to access sensitive pages without logging in or having the correct permissions, leading to security holes and a broken user experience.

Role-Based Access Control (RBAC)

RBAC is a mechanism to restrict system access to authorized users based on their role within an organization. Instead of assigning individual permissions to users, you assign permissions to roles, and then assign roles to users.

For example:

  • Role: Admin -> Permissions: view_all_users, edit_users, delete_users.
  • Role: Editor -> Permissions: view_own_articles, edit_own_articles.
  • Role: Viewer -> Permissions: view_public_data.

In Angular, you’d extend your CanActivate guard to check the user’s roles (usually obtained from the JWT claims) against the roles required by the target route.

Token Refresh Flow & Silent Race Handling

This is perhaps the most complex part of token management. When an Access Token expires, your application needs to:

  1. Detect the 401 (Unauthorized) error from the API.
  2. Use the Refresh Token to obtain a new Access Token.
  3. Retry the original failed request with the new Access Token.
  4. Crucially: Prevent multiple simultaneous refresh token requests if several API calls fail around the same time due to an expired token. This is the “silent token refresh race handling” problem.

The Race Condition Problem: Imagine a user opens a dashboard with 5 widgets, each making an API call. If their Access Token expires, all 5 requests might simultaneously receive a 401. Without proper handling, each of these 5 requests would trigger a separate refresh token request to your authentication server. This is inefficient, can overload your server, and might lead to unexpected token issues.

The Solution: Use RxJS to ensure only one refresh token request is active at any given time. Subsequent requests that fail while a refresh is in progress should “wait” for the new token and then retry. This often involves:

  • A BehaviorSubject or ReplaySubject to hold the “in-progress refresh” observable.
  • RxJS operators like switchMap and filter.

Let’s visualize the token refresh flow:

sequenceDiagram participant User participant AngularApp participant AuthInterceptor participant RefreshInterceptor participant AuthService participant BackendAPI participant AuthServer User->>AngularApp: Request Protected Data (e.g., /api/users) AngularApp->>AuthInterceptor: HTTP Request (initial) AuthInterceptor->>AuthService: Get Access Token AuthService-->>AuthInterceptor: Access Token AuthInterceptor->>BackendAPI: Request with Access Token alt Access Token Valid BackendAPI-->>AuthInterceptor: 200 OK (Data) AuthInterceptor-->>AngularApp: 200 OK (Data) AngularApp-->>User: Display Data else Access Token Expired (401) BackendAPI-->>RefreshInterceptor: 401 Unauthorized RefreshInterceptor->>AuthService: Start Token Refresh (if not already refreshing) AuthService->>AuthServer: Send Refresh Token AuthServer-->>AuthService: New Tokens (Access & Refresh) AuthService->>AuthService: Store New Tokens AuthService-->>RefreshInterceptor: New Access Token Ready RefreshInterceptor->>AuthInterceptor: Retry Original Request AuthInterceptor->>BackendAPI: Request with NEW Access Token BackendAPI-->>AuthInterceptor: 200 OK (Data) AuthInterceptor-->>AngularApp: 200 OK (Data) AngularApp-->>User: Display Data end

This diagram gives a high-level overview. The “silent race handling” part happens within the AuthService and RefreshInterceptor to manage the Start Token Refresh step.

Step-by-Step Implementation

Let’s build a practical token management and guarding system using Angular v20.x standalone architecture.

1. Setting Up the Authentication Service (auth.service.ts)

First, we need a service to handle login, logout, token storage, and the refresh token mechanism.

Let’s generate the service:

ng generate service auth/auth --standalone

Now, open src/app/auth/auth.service.ts.

// src/app/auth/auth.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { BehaviorSubject, Observable, throwError, catchError, tap, map, filter, take, switchMap } from 'rxjs';

// Define interfaces for our tokens and user data
interface AuthTokens {
  accessToken: string;
  refreshToken: string;
}

interface User {
  id: string;
  email: string;
  roles: string[]; // e.g., ['admin', 'editor']
}

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  // Use inject for dependencies in standalone services
  private http = inject(HttpClient);
  private router = inject(Router);

  // A BehaviorSubject to hold the current user state
  // null means not authenticated, User object means authenticated
  private _currentUser = new BehaviorSubject<User | null>(null);
  public readonly currentUser$ = this._currentUser.asObservable(); // Public observable

  // A BehaviorSubject to manage the token refresh process
  // We use this to prevent multiple refresh requests from happening concurrently
  private isRefreshing = false;
  private refreshTokenSubject: BehaviorSubject<AuthTokens | null> = new BehaviorSubject<AuthTokens | null>(null);

  // API endpoints (replace with your actual backend URLs)
  private readonly AUTH_API_URL = 'http://localhost:3000/api/auth';
  private readonly TOKEN_STORAGE_KEY_ACCESS = 'accessToken';
  private readonly TOKEN_STORAGE_KEY_REFRESH = 'refreshToken';

  constructor() {
    this.loadTokensAndUser(); // On service initialization, try to load existing tokens
  }

  /**
   * Tries to load tokens from localStorage and set the current user.
   */
  private loadTokensAndUser(): void {
    const accessToken = this.getAccessToken();
    if (accessToken) {
      // In a real app, you'd decode the JWT to get user roles/info
      // For simplicity, we'll mock a user based on token presence
      const mockUser: User = { id: '1', email: 'user@example.com', roles: ['user'] };
      if (accessToken.includes('admin')) { // Simple mock for admin role
        mockUser.roles.push('admin');
      }
      this._currentUser.next(mockUser);
    }
  }

  /**
   * Saves access and refresh tokens to localStorage.
   * @param tokens The AuthTokens object containing accessToken and refreshToken.
   */
  public saveTokens(tokens: AuthTokens): void {
    localStorage.setItem(this.TOKEN_STORAGE_KEY_ACCESS, tokens.accessToken);
    localStorage.setItem(this.TOKEN_STORAGE_KEY_REFRESH, tokens.refreshToken);
    // After saving, we should update the current user (e.g., by decoding the token)
    this.loadTokensAndUser();
  }

  /**
   * Retrieves the access token from localStorage.
   * @returns The access token string or null if not found.
   */
  public getAccessToken(): string | null {
    return localStorage.getItem(this.TOKEN_STORAGE_KEY_ACCESS);
  }

  /**
   * Retrieves the refresh token from localStorage.
   * @returns The refresh token string or null if not found.
   */
  public getRefreshToken(): string | null {
    return localStorage.getItem(this.TOKEN_STORAGE_KEY_REFRESH);
  }

  /**
   * Clears all tokens from localStorage and resets the current user.
   */
  public clearTokens(): void {
    localStorage.removeItem(this.TOKEN_STORAGE_KEY_ACCESS);
    localStorage.removeItem(this.TOKEN_STORAGE_KEY_REFRESH);
    this._currentUser.next(null);
  }

  /**
   * Checks if the user is currently authenticated.
   * @returns True if authenticated, false otherwise.
   */
  public isAuthenticated(): boolean {
    return !!this.getAccessToken() && !!this._currentUser.value;
  }

  /**
   * Checks if the current user has any of the required roles.
   * @param requiredRoles An array of role strings.
   * @returns True if the user has at least one of the required roles, false otherwise.
   */
  public hasRole(requiredRoles: string[]): boolean {
    const user = this._currentUser.value;
    if (!user || !user.roles) {
      return false;
    }
    return requiredRoles.some(role => user.roles.includes(role));
  }

  /**
   * Handles user login.
   * @param credentials An object containing username and password.
   * @returns An Observable of AuthTokens.
   */
  public login(credentials: { username: string, password: string }): Observable<AuthTokens> {
    // In a real app, send credentials to your backend's login endpoint
    // For this example, we'll mock a successful login response
    console.log('Attempting login...');
    return this.http.post<AuthTokens>(`${this.AUTH_API_URL}/login`, credentials)
      .pipe(
        tap(tokens => {
          this.saveTokens(tokens);
          console.log('Login successful, tokens saved.');
        }),
        catchError(error => {
          console.error('Login failed:', error);
          this.clearTokens();
          return throwError(() => new Error('Login failed.'));
        })
      );
  }

  /**
   * Handles user logout.
   */
  public logout(): void {
    console.log('Logging out...');
    this.clearTokens();
    this.router.navigate(['/login']); // Redirect to login page
  }

  /**
   * Attempts to refresh the access token using the refresh token.
   * Implements silent refresh race handling.
   * @returns An Observable that emits the new AuthTokens or throws an error.
   */
  public refreshToken(): Observable<AuthTokens> {
    // If a refresh is already in progress, wait for it to complete
    if (this.isRefreshing && this.refreshTokenSubject.value) {
      return this.refreshTokenSubject.pipe(
        filter(token => token !== null),
        take(1)
      );
    }

    this.isRefreshing = true; // Mark refresh as in-progress
    this.refreshTokenSubject.next(null); // Clear previous refresh token subject value

    const currentRefreshToken = this.getRefreshToken();
    if (!currentRefreshToken) {
      this.isRefreshing = false;
      this.logout(); // No refresh token, force logout
      return throwError(() => new Error('No refresh token available.'));
    }

    console.log('Attempting to refresh token...');
    return this.http.post<AuthTokens>(`${this.AUTH_API_URL}/refresh`, { refreshToken: currentRefreshToken })
      .pipe(
        tap(tokens => {
          this.saveTokens(tokens);
          this.refreshTokenSubject.next(tokens); // Emit new tokens to waiting requests
          this.isRefreshing = false;
          console.log('Token refresh successful, new tokens saved.');
        }),
        catchError((error: HttpErrorResponse) => {
          console.error('Token refresh failed:', error);
          this.isRefreshing = false;
          this.logout(); // Refresh failed, force logout
          this.refreshTokenSubject.error(error); // Notify waiting requests of failure
          return throwError(() => new Error('Failed to refresh token.'));
        })
      );
  }
}

Explanation of AuthService:

  • inject: We use the inject function to get instances of HttpClient and Router, which is the modern way to get dependencies in standalone services and functional guards.
  • _currentUser (BehaviorSubject): This private subject holds the current user’s data (or null if not logged in). currentUser$ is its public, read-only observable counterpart, allowing components to react to authentication state changes.
  • AuthTokens & User Interfaces: Good practice to define types for structured data.
  • loadTokensAndUser(): Called on service initialization to check if tokens already exist (e.g., from a previous session) and “re-authenticate” the user. In a real app, this would involve decoding the JWT to extract user claims like roles.
  • saveTokens(), getAccessToken(), getRefreshToken(), clearTokens(): Basic utility methods for managing tokens in localStorage.
  • isAuthenticated(): A simple check to see if an access token exists and a user is loaded.
  • hasRole(): Checks if the current user possesses a specific role, crucial for RBAC.
  • login(): Simulates a login request. Upon success, saveTokens is called. Error handling is included.
  • logout(): Clears tokens and redirects to the login page.
  • refreshToken(): This is the core of our silent token refresh and race handling:
    • It uses isRefreshing flag and refreshTokenSubject to implement the race condition logic.
    • If isRefreshing is true, it means a refresh request is already in flight. Instead of initiating a new one, it returns an observable that waits for the existing refreshTokenSubject to emit the new tokens (or an error). This prevents multiple simultaneous refresh requests.
    • If no refresh is in progress, it sets isRefreshing to true, makes the http.post call to the backend’s refresh endpoint.
    • On success, it saves the new tokens and emits them via refreshTokenSubject.next(), then resets isRefreshing.
    • On failure, it logs out the user and emits an error via refreshTokenSubject.error().

2. Implementing HTTP Interceptors

We need two interceptors: one to inject the access token into outgoing requests and another to handle 401 errors and trigger the token refresh.

2.1. Auth Token Interceptor (auth.interceptor.ts)

This interceptor will add the Authorization: Bearer header to all outgoing requests if an access token is available.

Generate the interceptor:

ng generate interceptor auth/auth --standalone

Open src/app/auth/auth.interceptor.ts.

// src/app/auth/auth.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';

/**
 * Interceptor to inject the Authorization header with the access token.
 */
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const accessToken = authService.getAccessToken();

  // If no access token, or if the request is to the auth API itself, pass it through
  // This prevents infinite loops or adding headers to login/refresh requests
  if (!accessToken || req.url.includes('/api/auth')) {
    return next(req);
  }

  // Clone the request and add the Authorization header
  const authReq = req.clone({
    setHeaders: {
      Authorization: `Bearer ${accessToken}`
    }
  });

  return next(authReq);
};

Explanation of authInterceptor:

  • HttpInterceptorFn: This is the modern functional way to define interceptors in Angular v15+.
  • inject(AuthService): We get an instance of our AuthService to retrieve the access token.
  • Conditional Header: It only adds the Authorization header if an accessToken exists and if the request is not for the /api/auth endpoints (login/refresh). This is crucial to prevent the interceptor from trying to add an expired token to the refresh request, which would cause issues.
  • req.clone(): Requests are immutable, so we must clone them to modify headers.

2.2. Token Refresh Interceptor (refresh.interceptor.ts)

This interceptor handles 401 Unauthorized responses, triggers the token refresh, and retries the original request.

Generate the interceptor:

ng generate interceptor auth/refresh --standalone

Open src/app/auth/refresh.interceptor.ts.

// src/app/auth/refresh.interceptor.ts
import { HttpInterceptorFn, HttpRequest, HttpHandlerFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
import { catchError, switchMap, throwError } from 'rxjs';

/**
 * Interceptor to handle 401 Unauthorized errors and trigger token refresh.
 */
export const refreshInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      // Check if the error is 401 Unauthorized
      // and if it's not the refresh token request itself
      if (error.status === 401 && !req.url.includes('/api/auth/refresh')) {
        // Trigger the token refresh process
        return authService.refreshToken().pipe(
          switchMap(newTokens => {
            // If refresh successful, retry the original request with the new access token
            const clonedReq = req.clone({
              setHeaders: {
                Authorization: `Bearer ${newTokens.accessToken}`
              }
            });
            return next(clonedReq);
          }),
          catchError(refreshError => {
            // If refresh fails (e.g., refresh token expired), log out the user
            authService.logout();
            return throwError(() => refreshError); // Re-throw the refresh error
          })
        );
      }
      // For any other error, or if it's the refresh token request itself, just re-throw
      return throwError(() => error);
    })
  );
};

Explanation of refreshInterceptor:

  • catchError: This RxJS operator catches errors from the HTTP request stream.
  • error.status === 401 && !req.url.includes('/api/auth/refresh'): We only want to trigger a refresh if it’s a 401 error and the request was not already the refresh token request itself (to prevent an infinite loop).
  • authService.refreshToken().pipe(switchMap(...)): This is the core logic.
    • It calls authService.refreshToken(), which handles the race condition.
    • switchMap is crucial here: it waits for the refreshToken() observable to complete and emit the new tokens. Once it does, switchMap “switches” to a new observable, which is the retried original request with the new access token.
    • If refreshToken() itself fails (e.g., refresh token expired), the catchError inside switchMap will trigger authService.logout().

2.3. Providing Interceptors

Now, we need to tell Angular to use these interceptors. In a standalone application, you provide them in your app.config.ts.

Open src/app/app.config.ts.

// src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';

import { routes } from './app.routes';
import { authInterceptor } from './auth/auth.interceptor';
import { refreshInterceptor } from './auth/refresh.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    // Provide HttpClient with our interceptors
    provideHttpClient(
      withInterceptors([
        authInterceptor,
        refreshInterceptor
      ])
    )
  ]
};

Explanation:

  • provideHttpClient(withInterceptors([...])): This is how you register functional HTTP interceptors in app.config.ts for standalone applications. The order matters: authInterceptor usually comes before refreshInterceptor so that the token is added before the 401 is potentially caught.

3. Creating Functional Route Guards

Let’s create two guards: one for basic authentication (AuthGuard) and one for role-based authorization (RoleGuard).

3.1. AuthGuard (auth.guard.ts)

This guard checks if the user is authenticated. If not, it redirects them to the login page.

Generate the guard:

ng generate guard auth/auth --functional --standalone

Open src/app/auth/auth.guard.ts.

// src/app/auth/auth.guard.ts
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
import { tap } from 'rxjs';

/**
 * Functional guard to check if a user is authenticated.
 * If not, redirects to the login page.
 */
export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);
  const router = inject(Router);

  if (authService.isAuthenticated()) {
    return true; // User is authenticated, allow access
  } else {
    // User is not authenticated, redirect to login page
    console.warn('Authentication required. Redirecting to login.');
    router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
    return false;
  }
};

Explanation of authGuard:

  • CanActivateFn: The type for a functional CanActivate guard.
  • inject(AuthService) & inject(Router): Get service instances.
  • authService.isAuthenticated(): Checks our service’s authentication state.
  • Redirection: If not authenticated, router.navigate sends the user to /login, preserving the attempted returnUrl as a query parameter for a better UX after login.

3.2. RoleGuard (role.guard.ts)

This guard checks if the authenticated user has the necessary roles to access a route.

Generate the guard:

ng generate guard auth/role --functional --standalone

Open src/app/auth/role.guard.ts.

// src/app/auth/role.guard.ts
import { CanActivateFn, Router } from '@angular/router';
import { inject } from '@angular/core';
import { AuthService } from './auth.service';

/**
 * Functional guard to check if an authenticated user has the required roles.
 * Expects 'roles' array in route data, e.g., `data: { roles: ['admin'] }`.
 * If not authorized, redirects to an 'unauthorized' page or login.
 */
export const roleGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);
  const router = inject(Router);

  // First, ensure the user is authenticated at all
  if (!authService.isAuthenticated()) {
    console.warn('Role check failed: User not authenticated. Redirecting to login.');
    router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
    return false;
  }

  // Get required roles from route data
  const requiredRoles = route.data?.['roles'] as string[];

  // If no specific roles are required, and user is authenticated, allow access
  if (!requiredRoles || requiredRoles.length === 0) {
    return true;
  }

  // Check if the authenticated user has any of the required roles
  if (authService.hasRole(requiredRoles)) {
    return true; // User has required role, allow access
  } else {
    // User is authenticated but does not have the required role
    console.warn('Role check failed: User does not have required roles. Redirecting to unauthorized.');
    router.navigate(['/unauthorized']); // Or redirect to a forbidden page
    return false;
  }
};

Explanation of roleGuard:

  • route.data?.['roles']: This is how we access data defined in the route configuration. We expect an array of strings representing the roles needed for that route.
  • Chained Check: It first uses authService.isAuthenticated() to ensure the user is logged in before checking roles.
  • authService.hasRole(): Uses our service method to verify role membership.
  • Redirection: If roles don’t match, it redirects to an /unauthorized page (which you’d create).

4. Integrating Guards into Routing

Now, let’s configure our Angular routes to use these guards.

Open src/app/app.routes.ts.

// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './auth/auth.guard';
import { roleGuard } from './auth/role.guard';

// Assume these components exist or create mock ones
import { LoginComponent } from './auth/login/login.component';
import { DashboardComponent } from './dashboard/dashboard.component';
import { AdminPanelComponent } from './admin/admin-panel.component';
import { UnauthorizedComponent } from './shared/unauthorized/unauthorized.component';
import { HomeComponent } from './home/home.component';

export const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'login', component: LoginComponent },
  { path: 'unauthorized', component: UnauthorizedComponent },

  // Protected route: requires authentication
  {
    path: 'dashboard',
    component: DashboardComponent,
    canActivate: [authGuard] // Use authGuard here
  },

  // Protected route: requires specific role (e.g., 'admin')
  {
    path: 'admin',
    component: AdminPanelComponent,
    canActivate: [authGuard, roleGuard], // Chain guards: first authenticate, then check role
    data: {
      roles: ['admin'] // Define required roles in route data
    }
  },

  // Example of a lazy-loaded route protected by CanMatch
  // This prevents the 'reports' module from even being downloaded
  // if the user doesn't have the 'analyst' role.
  {
    path: 'reports',
    loadComponent: () => import('./reports/reports-home/reports-home.component').then(m => m.ReportsHomeComponent),
    canMatch: [roleGuard], // Use canMatch for lazy-loaded routes
    data: {
      roles: ['analyst', 'admin'] // Only 'analyst' or 'admin' can access
    }
  },

  { path: '**', redirectTo: '' } // Catch-all for unknown routes
];

Explanation of Routing:

  • canActivate: [authGuard]: The dashboard route is only accessible if authGuard returns true.
  • canActivate: [authGuard, roleGuard]: For the admin route, both guards must return true. Angular executes guards in the order they are listed. If authGuard fails, roleGuard won’t even run.
  • data: { roles: ['admin'] }: This is how you pass configuration data to your guards. roleGuard reads this roles array.
  • canMatch: [roleGuard]: For lazy-loaded routes, canMatch is generally preferred over canActivate. If canMatch returns false, Angular won’t even attempt to load the component or module bundle, saving bandwidth and improving security.

5. Create Mock Components and Backend Structure

To make this runnable, you’ll need some basic components and a mock backend.

Mock Components (for app.routes.ts):

Create these files with minimal content:

  • src/app/home/home.component.ts
  • src/app/auth/login/login.component.ts (with a simple form that calls AuthService.login())
  • src/app/dashboard/dashboard.component.ts
  • src/app/admin/admin-panel.component.ts
  • src/app/shared/unauthorized/unauthorized.component.ts
  • src/app/reports/reports-home/reports-home.component.ts (for lazy loading example)

Example login.component.ts:

// src/app/auth/login/login.component.ts
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../auth.service';
import { Router, ActivatedRoute } from '@angular/router';

@Component({
  selector: 'app-login',
  standalone: true,
  imports: [CommonModule, FormsModule],
  template: `
    <div class="login-container">
      <h2>Login</h2>
      <form (ngSubmit)="onLogin()">
        <div class="form-group">
          <label for="username">Username:</label>
          <input type="text" id="username" [(ngModel)]="username" name="username" required>
        </div>
        <div class="form-group">
          <label for="password">Password:</label>
          <input type="password" id="password" [(ngModel)]="password" name="password" required>
        </div>
        <button type="submit">Log In</button>
      </form>
      <p *ngIf="errorMessage" class="error-message">{{ errorMessage }}</p>
      <p>Try 'user'/'password' for regular access.</p>
      <p>Try 'admin'/'password' for admin access.</p>
    </div>
  `,
  styles: [`
    .login-container { max-width: 400px; margin: 50px auto; padding: 20px; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
    .form-group { margin-bottom: 15px; }
    label { display: block; margin-bottom: 5px; font-weight: bold; }
    input[type="text"], input[type="password"] { width: calc(100% - 20px); padding: 10px; border: 1px solid #ddd; border-radius: 4px; }
    button { width: 100%; padding: 10px; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; }
    button:hover { background-color: #0056b3; }
    .error-message { color: red; margin-top: 10px; }
  `]
})
export class LoginComponent {
  username = '';
  password = '';
  errorMessage: string | null = null;

  private authService = inject(AuthService);
  private router = inject(Router);
  private route = inject(ActivatedRoute);

  onLogin(): void {
    this.errorMessage = null;
    this.authService.login({ username: this.username, password: this.password }).subscribe({
      next: () => {
        const returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/dashboard';
        this.router.navigateByUrl(returnUrl);
      },
      error: (err) => {
        this.errorMessage = err.message || 'An unknown error occurred during login.';
      }
    });
  }
}

Mock Backend (using json-server or a simple Express app):

To test this, you’ll need a simple backend that provides /api/auth/login and /api/auth/refresh endpoints.

db.json for json-server (install with npm install -g json-server):

{
  "users": [
    { "id": 1, "username": "user", "password": "password", "roles": ["user"] },
    { "id": 2, "username": "admin", "password": "password", "roles": ["user", "admin"] },
    { "id": 3, "username": "analyst", "password": "password", "roles": ["user", "analyst"] }
  ],
  "data": [
    { "id": 1, "content": "Some protected user data" }
  ]
}

Simple server.js (using Express) to handle auth logic:

// server.js (run with node server.js)
const express = require('express');
const jwt = require('jsonwebtoken');
const bodyParser = require('body-parser');
const cors = require('cors'); // Required for Angular frontend

const app = express();
const SECRET_KEY = 'your_super_secret_key'; // Use a strong, environment-variable-based key in production
const ACCESS_TOKEN_EXPIRATION = '15s'; // Short for testing refresh flow
const REFRESH_TOKEN_EXPIRATION = '7d';

app.use(bodyParser.json());
app.use(cors({ origin: 'http://localhost:4200' })); // Allow requests from Angular dev server

// Mock user data (in a real app, this would come from a database)
const users = [
    { id: '1', username: 'user', password: 'password', roles: ['user'] },
    { id: '2', username: 'admin', password: 'password', roles: ['user', 'admin'] },
    { id: '3', username: 'analyst', password: 'password', roles: ['user', 'analyst'] }
];

// Utility to generate tokens
function generateTokens(user) {
    const accessToken = jwt.sign({ id: user.id, username: user.username, roles: user.roles }, SECRET_KEY, { expiresIn: ACCESS_TOKEN_EXPIRATION });
    const refreshToken = jwt.sign({ id: user.id }, SECRET_KEY, { expiresIn: REFRESH_TOKEN_EXPIRATION });
    return { accessToken, refreshToken };
}

// Login endpoint
app.post('/api/auth/login', (req, res) => {
    const { username, password } = req.body;
    const user = users.find(u => u.username === username && u.password === password);

    if (user) {
        const { accessToken, refreshToken } = generateTokens(user);
        return res.json({ accessToken, refreshToken });
    } else {
        return res.status(401).json({ message: 'Invalid credentials' });
    }
});

// Token refresh endpoint
app.post('/api/auth/refresh', (req, res) => {
    const { refreshToken } = req.body;

    if (!refreshToken) {
        return res.status(400).json({ message: 'Refresh Token is required' });
    }

    jwt.verify(refreshToken, SECRET_KEY, (err, user) => {
        if (err) {
            console.error('Refresh token verification failed:', err.message);
            return res.status(403).json({ message: 'Invalid Refresh Token' });
        }
        // In a real app, you'd check if the user exists in DB and if refresh token is valid
        const foundUser = users.find(u => u.id === user.id);
        if (!foundUser) {
            return res.status(403).json({ message: 'User not found for refresh token' });
        }

        const { accessToken, refreshToken: newRefreshToken } = generateTokens(foundUser);
        return res.json({ accessToken, refreshToken: newRefreshToken });
    });
});

// Middleware to verify access token for protected routes
function verifyAccessToken(req, res, next) {
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1];

    if (!token) {
        return res.status(401).json({ message: 'Access Token required' });
    }

    jwt.verify(token, SECRET_KEY, (err, user) => {
        if (err) {
            console.error('Access token verification failed:', err.message);
            // If token expired, Angular interceptor will handle refresh
            return res.status(401).json({ message: 'Access Token expired or invalid' });
        }
        req.user = user; // Attach user info to request
        next();
    });
}

// Protected API endpoint
app.get('/api/protected-data', verifyAccessToken, (req, res) => {
    res.json({ message: `Hello ${req.user.username}, this is protected data for your roles: ${req.user.roles.join(', ')}!` });
});

// Admin-specific protected API endpoint
app.get('/api/admin-data', verifyAccessToken, (req, res) => {
    if (req.user.roles.includes('admin')) {
        res.json({ message: `Welcome Admin ${req.user.username}, here is your admin data!` });
    } else {
        res.status(403).json({ message: 'Forbidden: Admin role required' });
    }
});


const PORT = 3000;
app.listen(PORT, () => console.log(`Backend server running on http://localhost:${PORT}`));

Run this server.js with node server.js in a separate terminal. Then run your Angular app with ng serve.

Mini-Challenge: Enhance Logout Experience

Challenge: Currently, if a user logs out, they are simply redirected to /login. Enhance the logout experience by making an API call to a /api/auth/logout endpoint on your backend to invalidate the refresh token on the server side. This is a crucial security step to prevent stolen refresh tokens from being used indefinitely.

Steps:

  1. Add a logout endpoint to your mock backend (server.js) that takes a refresh token and “invalidates” it (e.g., by removing it from a list of valid refresh tokens or simply acknowledging the request).
  2. Modify your AuthService.logout() method to first send the refresh token to this backend endpoint before clearing tokens from localStorage and redirecting.
  3. Handle potential errors during the backend logout call (e.g., the refresh token might already be invalid, but the frontend should still proceed with local logout).

Hint: The backend’s logout endpoint doesn’t need to return anything specific, just a 200 OK. Use this.http.post in your AuthService. Remember to catchError and finally to ensure local logout happens regardless of backend success/failure.

What to observe/learn: You’ll see how to integrate backend-driven token invalidation into your logout flow, significantly improving security by making refresh tokens single-use or revocable.

Common Pitfalls & Troubleshooting

  1. Infinite Loop with Interceptors:

    • Pitfall: The authInterceptor tries to add a token to the /api/auth/login or /api/auth/refresh request, or the refreshInterceptor tries to refresh the token when the refresh token request itself fails with a 401.
    • Troubleshooting: Always include checks like !req.url.includes('/api/auth') or !req.url.includes('/api/auth/refresh') in your interceptors to exclude authentication-related API calls from token injection or refresh logic. This is critical.
  2. Silent Token Refresh Race Conditions:

    • Pitfall: Multiple API requests fail almost simultaneously due to an expired token, leading to multiple refreshToken() calls to the backend, potentially causing issues (e.g., backend invalidates old refresh tokens upon issuing new ones, making subsequent refresh attempts fail).
    • Troubleshooting: Ensure your AuthService.refreshToken() method correctly implements the isRefreshing flag and uses an RxJS Subject (like refreshTokenSubject) to queue and replay new tokens to waiting requests. Test this by making several rapid requests after logging in and waiting for the access token to expire.
  3. Incorrect Guard Chaining or Data Access:

    • Pitfall: Guards are not executing in the expected order, or route.data is undefined when trying to access roles.
    • Troubleshooting:
      • Guards in canActivate array execute left-to-right. If an earlier guard returns false, subsequent guards won’t run.
      • Double-check your app.routes.ts for typos in data properties. Ensure route.data?.['yourKey'] uses the correct key.
      • Use console.log(route.data) inside your guard to inspect what data is actually available.
  4. Security of Token Storage (localStorage):

    • Pitfall: Storing JWTs in localStorage without understanding the XSS vulnerability.
    • Troubleshooting: While convenient for examples, be acutely aware that if your application is vulnerable to XSS, an attacker can steal tokens from localStorage. For production, investigate HttpOnly cookies (managed by the backend) and ensure robust Content Security Policy (CSP) headers are in place to mitigate XSS risks if using localStorage is unavoidable.

Summary

You’ve just completed a deep dive into building a robust authentication and authorization system in Angular v20.x standalone applications!

Here are the key takeaways:

  • Authentication vs. Authorization: Clearly understood the difference between verifying identity and granting permissions.
  • JWTs: Learned about Access Tokens (short-lived) and Refresh Tokens (long-lived) for stateless authentication.
  • Secure Token Storage: Explored the trade-offs of localStorage (XSS risk) versus HttpOnly cookies for storing tokens.
  • AuthService: Created a central service to manage login, logout, token storage, and the complex refresh token flow.
  • HTTP Interceptors: Implemented authInterceptor to automatically inject Authorization headers and refreshInterceptor to handle 401 errors and trigger silent token refreshes.
  • Silent Token Refresh Race Handling: Addressed the critical problem of preventing multiple simultaneous refresh token requests using RxJS operators and state management within AuthService.
  • Functional Route Guards: Mastered CanActivateFn and CanMatchFn to protect routes based on authentication status (authGuard) and user roles (roleGuard).
  • Role-Based Access Control (RBAC): Implemented granular permission checks by passing roles data to route guards.

This knowledge empowers you to build highly secure and user-friendly applications where access is tightly controlled, and token management happens seamlessly behind the scenes.

What’s next? In Chapter 9, we’ll continue exploring advanced topics like RxJS async control, diving deeper into operators that are essential for managing complex asynchronous data flows in your Angular applications.

References

  1. Angular Official Documentation - HTTP Interceptors: https://angular.io/guide/http#intercepting-requests-and-responses
  2. Angular Official Documentation - Routing and Navigation (Guards): https://angular.io/guide/router#guards
  3. Angular Official Documentation - Functional Route Guards: https://angular.io/api/router/CanActivateFn
  4. JSON Web Tokens (JWT) Official Website: https://jwt.io/
  5. OWASP Web Security Testing Guide - Session Management: https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/06-Session_Management_Testing/
  6. MDN Web Docs - SameSite cookies: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.