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:
- Security: Tokens stored securely in localStorage (or sessionStorage)
- Automatic Refresh: Seamless token renewal before expiration
- Centralized State: Single source of truth for authentication
- Event-Driven: Components can react to login/logout events
- HTTP Integration: Automatic token attachment to API requests
- 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-logoutStep 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
- Token Storage: Stores access token, refresh token, expiration time, and user info in localStorage
- Automatic Initialization: Restores tokens on app startup and sets up refresh timer
- Event Streams:
onLogin$andonLogout$observables for centralized state management - Automatic Refresh: Schedules token refresh 1 minute before expiration
- Duplicate Prevention: Prevents multiple simultaneous refresh attempts
- 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:
- Auth Interceptor: Adds token to requests
- 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
- Start the API:
docker-compose up api - Start the UI:
npm start - Test Login: Submit login form with valid credentials
- Verify Storage: Check localStorage for tokens
- Test Auto-Refresh: Wait for token refresh (or manually trigger)
- Test 401 Handling: Make an API call with invalid token
- 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