Managing user authentication in a web application is crucial, and JWT (JSON Web Tokens) is a popular choice for this purpose. However, handling token expiration gracefully is a challenge. In this post, we’ll walk through how to implement a refresh token mechanism in an Angular application to ensure a smooth user experience without frequent reauthentication.
Why Use JWT and Refresh Tokens?
JWTs are great for stateless authentication because they are self-contained and easy to validate. However, JWTs often have short lifespans for security reasons, which means they can expire quickly. This is where refresh tokens come in. A refresh token has a longer lifespan and can be used to obtain a new access token without requiring the user to log in again.
Setting Up the Backend
Before diving into the Angular implementation, ensure your backend supports issuing and refreshing tokens. Typically, when a user logs in, the backend should return both an access token and a refresh token.
Here’s a quick overview of the backend endpoints you should have:
- Login: Issues access and refresh tokens.
- Register: Registers a new user and issues tokens.
- Refresh Token: Accepts a refresh token and issues a new access token.
- Logout: Invalidates the refresh token.
Modifying the Angular AuthService
Let’s start by modifying our Angular AuthService
to handle both access and refresh tokens.
AuthService Modifications
// frontend/src/app/services/auth.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, throwError } from 'rxjs';
import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { ApiConfigService } from './api-config.service';
@Injectable({
providedIn: 'root'
})
export class AuthService {
private currentUserSubject: BehaviorSubject<any>;
public currentUser: Observable<any>;
private baseUrl: string;
private refreshTokenInProgress: boolean = false;
constructor(private http: HttpClient, private apiConfig: ApiConfigService) {
const storedUser = localStorage.getItem('currentUser');
this.currentUserSubject = new BehaviorSubject<any>(storedUser ? JSON.parse(storedUser) : null);
this.currentUser = this.currentUserSubject.asObservable();
this.baseUrl = this.apiConfig.getApiUrl();
}
public get currentUserValue() {
return this.currentUserSubject.value;
}
register(email: string, password: string) {
return this.http.post<any>(`${this.baseUrl}/api/auth/register`, { email, password })
.pipe(
map(user => {
if (user && user.accessToken && user.refreshToken) {
this.storeTokens(user.accessToken, user.refreshToken);
this.currentUserSubject.next(user);
}
return user;
})
);
}
login(email: string, password: string) {
return this.http.post<any>(`${this.baseUrl}/api/auth/login`, { email, password })
.pipe(
map(response => {
if (response && response.accessToken && response.refreshToken) {
const user = {
email: response.user.email,
role: response.user.role,
accessToken: response.accessToken,
refreshToken: response.refreshToken
};
this.storeTokens(response.accessToken, response.refreshToken);
this.currentUserSubject.next(user);
}
return response;
})
);
}
logout() {
this.clearTokens();
this.currentUserSubject.next(null);
}
getProfile() {
return this.http.get<any>(`${this.baseUrl}/api/auth/profile`).pipe(
catchError(error => {
console.error('Error fetching user profile:', error);
return throwError('Failed to fetch user profile');
})
);
}
refreshToken() {
const refreshToken = this.getRefreshToken();
if (refreshToken && !this.refreshTokenInProgress) {
this.refreshTokenInProgress = true;
return this.http.post<any>(`${this.baseUrl}/api/auth/refresh-token`, { refreshToken })
.pipe(
tap(response => {
if (response && response.accessToken) {
this.storeAccessToken(response.accessToken);
const currentUser = this.currentUserValue;
currentUser.accessToken = response.accessToken;
this.currentUserSubject.next(currentUser);
}
this.refreshTokenInProgress = false;
}),
catchError(error => {
this.refreshTokenInProgress = false;
this.logout();
return throwError('Failed to refresh token');
})
);
}
return throwError('No refresh token available');
}
private storeTokens(accessToken: string, refreshToken: string) {
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
}
private storeAccessToken(accessToken: string) {
localStorage.setItem('accessToken', accessToken);
}
private getAccessToken(): string | null {
return localStorage.getItem('accessToken');
}
private getRefreshToken(): string | null {
return localStorage.getItem('refreshToken');
}
private clearTokens() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
}
Adding an HTTP Interceptor
Next, we’ll add an HTTP interceptor to automatically refresh the access token when it expires.
// frontend/src/app/interceptors/token.interceptor.ts
import { Injectable } from '@angular/core';
import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, switchMap, take } from 'rxjs/operators';
import { AuthService } from '../services/auth.service';
@Injectable()
export class TokenInterceptor implements HttpInterceptor {
constructor(private authService: AuthService) { }
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const accessToken = this.authService.getAccessToken();
let authReq = req;
if (accessToken) {
authReq = req.clone({
setHeaders: { Authorization: `Bearer ${accessToken}` }
});
}
return next.handle(authReq).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Access token might have expired, try to refresh it
return this.authService.refreshToken().pipe(
take(1),
switchMap(() => {
const newAccessToken = this.authService.getAccessToken();
const newAuthReq = req.clone({
setHeaders: { Authorization: `Bearer ${newAccessToken}` }
});
return next.handle(newAuthReq);
}),
catchError(err => {
this.authService.logout();
return throwError(err);
})
);
}
return throwError(error);
})
);
}
}
Registering the Interceptor
Finally, register the interceptor in your AppModule
:
// frontend/src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { AppComponent } from './app.component';
import { AuthService } from './services/auth.service';
import { TokenInterceptor } from './interceptors/token.interceptor';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
HttpClientModule
],
providers: [
AuthService,
{
provide: HTTP_INTERCEPTORS,
useClass: TokenInterceptor,
multi: true
}
],
bootstrap: [AppComponent]
})
export class AppModule { }