Introduction

In our previous post, we integrated OpenAPI Generator and connected our login component to the backend API. Now we'll implement a comprehensive authentication service that manages JWT tokens, handles automatic token refresh, provides centralized login/logout events, and includes HTTP interceptors for seamless API authentication. This will create a production-ready authentication system that handles token lifecycle automatically.

Why Token Management Matters

Proper token management is crucial for security and user experience:

  1. Security: Tokens stored securely in localStorage (or sessionStorage)
  2. Automatic Refresh: Seamless token renewal before expiration
  3. Centralized State: Single source of truth for authentication
  4. Event-Driven: Components can react to login/logout events
  5. HTTP Integration: Automatic token attachment to API requests
  6. Error Handling: Automatic logout on 401 errors

Architecture Overview

Our authentication system consists of:

User Login
    ↓
Store tokens in localStorage
    ↓
Set up automatic refresh timer
    ↓
Emit login event
    ↓
HTTP Interceptor adds token to requests
    ↓
Token expires → Auto-refresh 1 min before
    ↓
401 Error → Auto-logout

Step 1: Creating the Authentication Service

We'll replace our stubbed auth service with a comprehensive implementation:

core/services/auth.ts:

import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Subject, Observable, timer, EMPTY } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { AuthService as ApiAuthService } from '../api/api/auth.service';
import { LoginResponseDto } from '../api/model/login-response-dto';
import { RefreshTokenDto } from '../api/model/refresh-token-dto';
import { TokenResponseDto } from '../api/model/token-response-dto';
const STORAGE_KEYS = {
  ACCESS_TOKEN: 'auth_access_token',
  REFRESH_TOKEN: 'auth_refresh_token',
  EXPIRES_AT: 'auth_expires_at',
  USER: 'auth_user',
} as const;
@Injectable({
  providedIn: 'root',
})
export class AuthService {
  private readonly apiAuthService: ApiAuthService;
  private readonly router: Router;
  // Event subjects for login/logout
  private readonly loginSubject = new Subject<LoginResponseDto>();
  private readonly logoutSubject = new Subject<void>();
  // Observable streams for login/logout events
  public readonly onLogin$: Observable<LoginResponseDto> = this.loginSubject.asObservable();
  public readonly onLogout$: Observable<void> = this.logoutSubject.asObservable();
  // Token refresh timer subscription
  private refreshTimerSubscription: any = null;
  private isRefreshing = false;
  constructor(
    apiAuthService: ApiAuthService,
    router: Router
  ) {
    this.apiAuthService = apiAuthService;
    this.router = router;
    // Initialize: restore tokens from localStorage and set up refresh timer
    this.initialize();
  }
  private initialize(): void {
    const accessToken = this.getAccessToken();
    const expiresAt = this.getExpiresAt();
    if (accessToken && expiresAt) {
      const expirationDate = new Date(expiresAt);
      const now = new Date();
      if (expirationDate > now) {
        // Token is still valid, set up refresh timer
        this.scheduleTokenRefresh(expirationDate);
      } else {
        // Token has expired, try to refresh it
        this.refreshToken();
      }
    }
  }
  isLoggedIn(): boolean {
    const accessToken = this.getAccessToken();
    const expiresAt = this.getExpiresAt();
    if (!accessToken || !expiresAt) {
      return false;
    }
    const expirationDate = new Date(expiresAt);
    const now = new Date();
    return expirationDate > now;
  }
  getAccessToken(): string | null {
    return localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
  }
  getRefreshToken(): string | null {
    return localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
  }
  getExpiresAt(): string | null {
    return localStorage.getItem(STORAGE_KEYS.EXPIRES_AT);
  }
  getUser(): any | null {
    const userStr = localStorage.getItem(STORAGE_KEYS.USER);
    if (userStr) {
      try {
        return JSON.parse(userStr);
      } catch {
        return null;
      }
    }
    return null;
  }
  handleLogin(loginResponse: LoginResponseDto): void {
    if (!loginResponse.accessToken || !loginResponse.refreshToken || !loginResponse.expiresAt) {
      console.error('Login response missing required token information');
      return;
    }
    // Store tokens in localStorage
    localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, loginResponse.accessToken);
    localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, loginResponse.refreshToken);
    localStorage.setItem(STORAGE_KEYS.EXPIRES_AT, loginResponse.expiresAt);
    // Store user information if available
    if (loginResponse.user) {
      localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(loginResponse.user));
    }
    // Set up automatic token refresh
    const expirationDate = new Date(loginResponse.expiresAt);
    this.scheduleTokenRefresh(expirationDate);
    // Emit login event
    this.loginSubject.next(loginResponse);
  }
  logout(): void {
    // Clear tokens from localStorage
    localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
    localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
    localStorage.removeItem(STORAGE_KEYS.EXPIRES_AT);
    localStorage.removeItem(STORAGE_KEYS.USER);
    // Cancel refresh timer
    this.cancelTokenRefresh();
    // Emit logout event
    this.logoutSubject.next();
    // Navigate to login page
    this.router.navigate(['/login']);
  }
  private scheduleTokenRefresh(expirationDate: Date): void {
    this.cancelTokenRefresh();
    const now = new Date();
    const timeUntilExpiration = expirationDate.getTime() - now.getTime();
    const oneMinute = 60 * 1000;
    // Calculate time until 1 minute before expiration
    const refreshTime = timeUntilExpiration - oneMinute;
    if (refreshTime > 0) {
      // Schedule refresh 1 minute before expiration
      this.refreshTimerSubscription = timer(refreshTime).subscribe(() => {
        this.refreshToken();
      });
    } else if (timeUntilExpiration > 0) {
      // Token expires in less than 1 minute, refresh immediately
      this.refreshToken();
    } else {
      // Token has already expired, try to refresh it
      this.refreshToken();
    }
  }
  private cancelTokenRefresh(): void {
    if (this.refreshTimerSubscription) {
      this.refreshTimerSubscription.unsubscribe();
      this.refreshTimerSubscription = null;
    }
  }
  refreshToken(): void {
    // Prevent multiple simultaneous refresh attempts
    if (this.isRefreshing) {
      console.log('Token refresh already in progress, skipping...');
      return;
    }
    const refreshToken = this.getRefreshToken();
    if (!refreshToken) {
      console.warn('No refresh token available, logging out');
      this.logout();
      return;
    }
    this.isRefreshing = true;
    const refreshTokenDto: RefreshTokenDto = {
      refreshToken: refreshToken,
    };
    this.apiAuthService
      .apiAuthRefreshPost(refreshTokenDto)
      .pipe(
        tap((response: TokenResponseDto) => {
          this.isRefreshing = false;
          
          if (response.accessToken && response.refreshToken && response.expiresAt) {
            // Update tokens in localStorage
            localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.accessToken);
            localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, response.refreshToken);
            localStorage.setItem(STORAGE_KEYS.EXPIRES_AT, response.expiresAt);
            // Schedule next refresh
            const expirationDate = new Date(response.expiresAt);
            this.scheduleTokenRefresh(expirationDate);
            console.log('Token refreshed successfully');
          } else {
            console.error('Token refresh response missing required information');
            this.logout();
          }
        }),
        catchError((error) => {
          this.isRefreshing = false;
          console.error('Token refresh failed:', error);
          this.logout();
          return EMPTY;
        })
      )
      .subscribe();
  }
}

Key Features

  1. Token Storage: Stores access token, refresh token, expiration time, and user info in localStorage
  2. Automatic Initialization: Restores tokens on app startup and sets up refresh timer
  3. Event Streams: onLogin$ and onLogout$ observables for centralized state management
  4. Automatic Refresh: Schedules token refresh 1 minute before expiration
  5. Duplicate Prevention: Prevents multiple simultaneous refresh attempts
  6. Error Handling: Logs out on refresh failures

Step 2: Updating the Login Component

We'll update the login component to use the new handleLogin() method:

features/auth/login/login.ts:

tap((response) => {
  // Handle successful login - store tokens and set up refresh timer
  this.authService.handleLogin(response);
  // Navigate to home/dashboard
  this.router.navigate(['/']);
}),

The login component now simply calls handleLogin() which handles all token storage and refresh setup automatically.

Step 3: Creating the Auth HTTP Interceptor

We'll create an interceptor that automatically adds the Bearer token to all HTTP requests:

core/interceptors/auth.interceptor.ts:

import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  // Get the access token
  const accessToken = authService.getAccessToken();
  // Only add token if available and user is logged in
  if (accessToken && authService.isLoggedIn()) {
    // Clone the request and add the Authorization header
    const clonedRequest = req.clone({
      setHeaders: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
    return next(clonedRequest);
  }
  // If no token, proceed with the original request
  return next(req);
};

This interceptor:

  • Automatically adds Authorization: Bearer <token> to all HTTP requests
  • Only adds the token if the user is logged in and has a valid token
  • Works globally for all API calls

Step 4: Creating the Error HTTP Interceptor

We'll create an error interceptor that automatically logs out users on 401 errors:

core/interceptors/error.interceptor.ts:

import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { AuthService } from '../services/auth';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      // Handle 401 Unauthorized errors
      if (error.status === 401) {
        // Only log out if the user is currently logged in
        // This prevents logging out on login attempts with invalid credentials
        if (authService.isLoggedIn()) {
          console.warn('Received 401 Unauthorized response. User will be logged out.');
          authService.logout();
        }
      }
      // Re-throw the error so it can be handled by the calling code
      return throwError(() => error);
    })
  );
};

This interceptor:

  • Catches all HTTP errors
  • Automatically logs out on 401 errors (only if user is logged in)
  • Prevents logout on failed login attempts
  • Re-throws errors so components can still handle them

Step 5: Registering the Interceptors

We'll register both interceptors in the app configuration:

app.config.ts:

import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from './core/interceptors/auth.interceptor';
import { errorInterceptor } from './core/interceptors/error.interceptor';
export const appConfig: ApplicationConfig = {
  providers: [
    // ... other providers
    provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])),
  ]
};

The interceptors run in order:

  1. Auth Interceptor: Adds token to requests
  2. Error Interceptor: Handles errors (including 401s)

Understanding Token Refresh Flow

The automatic token refresh works as follows:

Token expires in 60 minutes
    ↓
Timer scheduled for 59 minutes
    ↓
59 minutes later → refreshToken() called
    ↓
API call to /api/auth/refresh
    ↓
New tokens received
    ↓
Update localStorage
    ↓
Schedule next refresh (59 minutes from new expiration)

Why 1 Minute Before Expiration?

Refreshing 1 minute before expiration:

  • Provides buffer time for network delays
  • Prevents race conditions
  • Ensures seamless user experience
  • Handles slow API responses gracefully

Using Login/Logout Events

Components can subscribe to authentication events:

export class MyComponent extends BaseComponent implements OnInit {
  private authService = inject(AuthService);
  ngOnInit(): void {
    // Subscribe to login events
    this.authService.onLogin$
      .pipe(takeUntil(this.destroyed$))
      .subscribe((loginResponse) => {
        console.log('User logged in:', loginResponse.user);
        // Update UI, fetch user-specific data, etc.
      });
    // Subscribe to logout events
    this.authService.onLogout$
      .pipe(takeUntil(this.destroyed$))
      .subscribe(() => {
        console.log('User logged out');
        // Clear user-specific data, reset UI, etc.
      });
  }
}

This enables:

  • Centralized authentication state management
  • Automatic UI updates on login/logout
  • Clean separation of concerns
  • Event-driven architecture

localStorage vs sessionStorage

We chose localStorage because:

  • Persistence: Tokens survive browser restarts
  • User Experience: Users stay logged in across sessions
  • Refresh Tokens: Long-lived refresh tokens work better with localStorage

Security Considerations:

  • Tokens are still secure (HttpOnly cookies would be better, but localStorage is acceptable for SPAs)
  • XSS protection is still required
  • Tokens are cleared on logout
  • Automatic logout on 401 errors

Project Structure

After completing this setup:

src/app/
├── core/
│   ├── interceptors/
│   │   ├── auth.interceptor.ts
│   │   └── error.interceptor.ts
│   └── services/
│       └── auth.ts
├── features/
│   └── auth/
│       └── login/
│           └── login.ts (updated)
└── app.config.ts (updated)

Best Practices

1. Token Management

  • Always check token expiration before using
  • Store tokens securely (localStorage is acceptable for SPAs)
  • Clear tokens on logout
  • Handle refresh failures gracefully

2. HTTP Interceptors

  • Keep interceptors focused and single-purpose
  • Don't modify request/response unnecessarily
  • Re-throw errors so components can handle them
  • Use functional interceptors (Angular 15+)

3. Error Handling

  • Only log out on 401 if user is actually logged in
  • Allow components to handle errors after interceptors
  • Provide user-friendly error messages
  • Log errors for debugging

4. Event-Driven Architecture

  • Use Subjects for user-triggered events
  • Expose observables for subscriptions
  • Clean up subscriptions properly
  • Keep event payloads minimal

Testing the Implementation

  1. Start the API: docker-compose up api
  2. Start the UI: npm start
  3. Test Login: Submit login form with valid credentials
  4. Verify Storage: Check localStorage for tokens
  5. Test Auto-Refresh: Wait for token refresh (or manually trigger)
  6. Test 401 Handling: Make an API call with invalid token
  7. Test Logout: Call authService.logout()

Common Issues and Solutions

Issue: Token refresh happens too frequently

Solution: Check that isRefreshing flag is working correctly

Issue: 401 errors on login

Solution: Error interceptor checks isLoggedIn() to prevent logout on failed login

Issue: Tokens not persisting

Solution: Verify localStorage is available (not in private/incognito mode)

Issue: Interceptor not adding token

Solution: Check that interceptor is registered and isLoggedIn() returns true

Conclusion

We've successfully implemented:

  • ✅ Comprehensive authentication service with token management
  • ✅ Automatic token refresh 1 minute before expiration
  • ✅ Centralized login/logout events
  • ✅ HTTP interceptor for automatic token attachment
  • ✅ Error interceptor for automatic logout on 401 errors
  • ✅ Token persistence in localStorage
  • ✅ Seamless user experience with automatic token management

The authentication system is now production-ready with automatic token management, error handling, and event-driven state management. Users can log in once and stay authenticated with automatic token refresh, and the system handles errors gracefully.

In the next blog post, we'll add user profile management and additional authentication features.

Tags: Angular, TypeScript, Frontend Development, Authentication, Token Management, JWT, HTTP Interceptors, RxJS, Local Storage, Automatic Token Refresh, Error Handling, Security, Web Development, UI Development, State Management, Event-Driven Architecture