Sander Bakker
Sander Bakker

Reputation: 621

Angular JWT refresh token

I'm trying to implement JWT with refresh tokens based on an external API and Angular. I wrote the following code

TokenInterceptor

import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor,
  HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, first } from 'rxjs/operators';
import {AuthenticationService} from '../services/authentication.service'

@Injectable()
export class TokenInterceptor implements HttpInterceptor {

  constructor(public authService : AuthenticationService ) {}

  intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
      console.log(`AddTokenInterceptor - ${request.url}`);      

      return next.handle(this.addToken(request, localStorage.getItem('access_token')))
      .pipe(catchError(error => {
        if (error instanceof HttpErrorResponse && error.status === 401) {
          this.refreshToken()
          .pipe(first())
          .subscribe(
              data => {
                return next.handle(this.addToken(request, localStorage.getItem('access_token')))
              },
          ) 
        } else {
          return throwError(error);
        }
      }));
  }

  private addToken(request: HttpRequest<any>, token: string) {
    return request.clone({
      setHeaders: {
        'Authorization': `Bearer ${token}`
      }
    });
  } 

  private refreshToken(){
    return this.authService.refreshToken()
  }
}

AuthenticationService

import { Injectable } from '@angular/core';

import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class AuthenticationService {
  public currentUser: string

  constructor(
    private http: HttpClient, 
    ) { }

  login(username:string, password:string){
    return this.http.post<any>('http://localhost:8000/api/token/', {username: username, password: password})
      .pipe(
        map(data => {
          localStorage.setItem('access_token', data.access)
          localStorage.setItem('refresh_token', data.refresh)
        })
      )
  }

  logout(){
    localStorage.removeItem('access_token')
    localStorage.removeItem('refresh_token')
  }

  getJWToken(){
    return localStorage.getItem('access_token')
  }

  getRefreshToken(){
    return localStorage.getItem('refresh_token')
  }

  refreshToken(){
    let refreshToken : string = localStorage.getItem('refresh_token'); 

    return this.http.post<any>('http://localhost:8000/api/token/refresh/', {"refresh": refreshToken}).pipe(
      map(data => {
        localStorage.setItem('access_token', data.access)
      })
    )
  }
}

HomeComponent

import { Component, OnInit } from '@angular/core';
import { TeamService} from '../../services/team.service'
import { first } from 'rxjs/operators';


@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.scss']
})
export class HomeComponent implements OnInit {
  teams; 
  constructor(private teamService : TeamService) { }

  ngOnInit(): void {
    this.teamService.getTeams().pipe(first()).subscribe(
      data => {
        this.teams = data.results
      },
      error => {
        console.log(error.error)
      }
    )        

  }

  login() : void {
    console.log(this.teams)
  }
}

I'm trying to refresh the token when a 401 response is returned, the following happens right now:

After that, when I refresh the page the teams variable is correctly loaded and can be used. My question: how can I refresh the token before the request is made so that the request can always be made with a valid access token? It seems that the mistake is in the TokenInterceptor but I can't seem to figure out how to solve this

Upvotes: 1

Views: 490

Answers (2)

You can't refresh the token for all 401 responses because if the user tries to login to your system using invalid credentials will also have a 401 response.

Usually, the HTTP response header that comes from the API has something that indicates that this client once was authenticated but now has an expired token. Typically, the response header has a property called token-expired or www-authenticate; you have to check this before starting the refreshes token process.

Code sample:

AuthInterceptor

import { Injectable } from '@angular/core';
import {
  HttpInterceptor,
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpErrorResponse
} from '@angular/common/http';
import { AuthService } from '../services/auth.service';
import { Observable, BehaviorSubject, throwError } from 'rxjs';
import { environment } from 'src/environments/environment';
import { filter, switchMap, take, catchError } from 'rxjs/operators';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  private tryingRefreshing = false;
  private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);

  constructor(public authService: AuthService) { }

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const token = this.authService.getToken();
    request = this.addAuthorization(request, token);
    return next.handle(request).pipe(catchError(error => {
      if (error instanceof HttpErrorResponse && error.status === 401) {
        const tokenExpired = error.headers.get('token-expired');
        if (tokenExpired) {
          return this.handle401Error(request, next);
        }

        this.authService.logout();
        return throwError(error);
      } else {
        return throwError(error);
      }
    }));
  }

  private handle401Error(request: HttpRequest<any>, next: HttpHandler) {
    if (!this.tryingRefreshing) {
      this.tryingRefreshing = true;
      this.refreshTokenSubject.next(null);
      
     return this.authService.refreshToken().pipe(
        switchMap((token: any) => {
          this.tryingRefreshing = false;
          this.refreshTokenSubject.next(token);
          return next.handle(this.addAuthorization(request, token));
        }));

    } else {
      return this.refreshTokenSubject.pipe(
        filter(token => token != null),
        take(1),
        switchMap(jwt => {
          return next.handle(this.addAuthorization(request, jwt));
        }));
    }
  }

  addAuthorization(httpRequest: HttpRequest<any>, token: string) {
    return httpRequest = httpRequest.clone({
      setHeaders: {
        Authorization: `Bearer ${token}`
      }
    });
  }
}

Refresh token

This is just a sample method to show the share() approach.

  refreshToken(): Observable<string> {
    return this.http.post<any>(`${this.baseUrl}/auth/token/refresh-token`, {}, { withCredentials: true })
      .pipe(
        share(),
        map((authResponse) => {
          this.currentAuthSubject.next(authResponse);
          this.addToLocalStorage(authResponse);
          return authResponse.token;
        }));
  }

Upvotes: 0

Barremian
Barremian

Reputation: 31125

Everything looks fine except instead of subscribing inside the interceptor try to map the response in the pipe.

Update

As @ionut-t pointed in the comments there must be two changes:

  • Replace subscription with a switchMap operator
  • Return the observable in the catchError operator
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
  console.log(`AddTokenInterceptor - ${request.url}`);      

  return next.handle(this.addToken(request, localStorage.getItem('access_token')))
  .pipe(catchError(error => {
    if (error instanceof HttpErrorResponse && error.status === 401) {
      return this.refreshToken()
      .pipe(
        first(),
        switchMap(        // <-- map the response instead of subscribing here
          data => next.handle(this.addToken(request, localStorage.getItem('access_token')))
        )
      )
      ...

Upvotes: 2

Related Questions