Reputation: 621
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:
"You provided 'undefined' where a stream was expected. You can provide an Observable, Promise, Array, or Iterable."
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
Reputation: 678
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
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:
subscription
with a switchMap
operatorcatchError
operatorintercept(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