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:
- User sends credentials (username/password) to the authentication server.
- Authentication server validates credentials and, if successful, issues an Access Token (JWT) and often a Refresh Token.
- The Angular application stores these tokens.
- For every subsequent request to a protected API endpoint, the Angular app sends the Access Token in the
Authorizationheader (Bearer <access_token>). - 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
/refreshendpoint.
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.
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.
- Pros: Easy to use, accessible via JavaScript, persists across browser sessions (
HttpOnlyCookies:- 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) andSameSite(prevents CSRF). - Cons: Vulnerable to Cross-Site Request Forgery (CSRF) if not properly mitigated with
SameSite=Laxor 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.
- Pros: Not accessible via JavaScript, making them largely immune to XSS attacks (attacker can’t read the cookie). Can be marked
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 overCanActivatefor 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:
- Detect the 401 (Unauthorized) error from the API.
- Use the Refresh Token to obtain a new Access Token.
- Retry the original failed request with the new Access Token.
- 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
BehaviorSubjectorReplaySubjectto hold the “in-progress refresh” observable. - RxJS operators like
switchMapandfilter.
Let’s visualize the token refresh flow:
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: '[email protected]', 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 theinjectfunction to get instances ofHttpClientandRouter, 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 (ornullif not logged in).currentUser$is its public, read-only observable counterpart, allowing components to react to authentication state changes.AuthTokens&UserInterfaces: 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 inlocalStorage.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,saveTokensis 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
isRefreshingflag andrefreshTokenSubjectto implement the race condition logic. - If
isRefreshingis true, it means a refresh request is already in flight. Instead of initiating a new one, it returns an observable that waits for the existingrefreshTokenSubjectto emit the new tokens (or an error). This prevents multiple simultaneous refresh requests. - If no refresh is in progress, it sets
isRefreshingto true, makes thehttp.postcall to the backend’s refresh endpoint. - On success, it saves the new tokens and emits them via
refreshTokenSubject.next(), then resetsisRefreshing. - On failure, it logs out the user and emits an error via
refreshTokenSubject.error().
- It uses
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 ourAuthServiceto retrieve the access token.- Conditional Header: It only adds the
Authorizationheader if anaccessTokenexists and if the request is not for the/api/authendpoints (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. switchMapis crucial here: it waits for therefreshToken()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), thecatchErrorinsideswitchMapwill triggerauthService.logout().
- It calls
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 inapp.config.tsfor standalone applications. The order matters:authInterceptorusually comes beforerefreshInterceptorso 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 functionalCanActivateguard.inject(AuthService)&inject(Router): Get service instances.authService.isAuthenticated(): Checks our service’s authentication state.- Redirection: If not authenticated,
router.navigatesends the user to/login, preserving the attemptedreturnUrlas 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
/unauthorizedpage (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]: Thedashboardroute is only accessible ifauthGuardreturnstrue.canActivate: [authGuard, roleGuard]: For theadminroute, both guards must returntrue. Angular executes guards in the order they are listed. IfauthGuardfails,roleGuardwon’t even run.data: { roles: ['admin'] }: This is how you pass configuration data to your guards.roleGuardreads thisrolesarray.canMatch: [roleGuard]: For lazy-loaded routes,canMatchis generally preferred overcanActivate. IfcanMatchreturnsfalse, 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.tssrc/app/auth/login/login.component.ts(with a simple form that callsAuthService.login())src/app/dashboard/dashboard.component.tssrc/app/admin/admin-panel.component.tssrc/app/shared/unauthorized/unauthorized.component.tssrc/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:
- Add a
logoutendpoint 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). - Modify your
AuthService.logout()method to first send the refresh token to this backend endpoint before clearing tokens fromlocalStorageand redirecting. - 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
Infinite Loop with Interceptors:
- Pitfall: The
authInterceptortries to add a token to the/api/auth/loginor/api/auth/refreshrequest, or therefreshInterceptortries 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.
- Pitfall: The
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 theisRefreshingflag and uses an RxJSSubject(likerefreshTokenSubject) 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.
- Pitfall: Multiple API requests fail almost simultaneously due to an expired token, leading to multiple
Incorrect Guard Chaining or Data Access:
- Pitfall: Guards are not executing in the expected order, or
route.dataisundefinedwhen trying to access roles. - Troubleshooting:
- Guards in
canActivatearray execute left-to-right. If an earlier guard returnsfalse, subsequent guards won’t run. - Double-check your
app.routes.tsfor typos indataproperties. Ensureroute.data?.['yourKey']uses the correct key. - Use
console.log(route.data)inside your guard to inspect what data is actually available.
- Guards in
- Pitfall: Guards are not executing in the expected order, or
Security of Token Storage (
localStorage):- Pitfall: Storing JWTs in
localStoragewithout 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, investigateHttpOnlycookies (managed by the backend) and ensure robust Content Security Policy (CSP) headers are in place to mitigate XSS risks if usinglocalStorageis unavoidable.
- Pitfall: Storing JWTs in
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) versusHttpOnlycookies for storing tokens. AuthService: Created a central service to manage login, logout, token storage, and the complex refresh token flow.- HTTP Interceptors: Implemented
authInterceptorto automatically injectAuthorizationheaders andrefreshInterceptorto 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
CanActivateFnandCanMatchFnto protect routes based on authentication status (authGuard) and user roles (roleGuard). - Role-Based Access Control (RBAC): Implemented granular permission checks by passing
rolesdata 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
- Angular Official Documentation - HTTP Interceptors: https://angular.io/guide/http#intercepting-requests-and-responses
- Angular Official Documentation - Routing and Navigation (Guards): https://angular.io/guide/router#guards
- Angular Official Documentation - Functional Route Guards: https://angular.io/api/router/CanActivateFn
- JSON Web Tokens (JWT) Official Website: https://jwt.io/
- 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/
- 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.