1

I'm editing the whole question to simplify it as I found what was causing it. If you look at the screenshot below, 'authorize attribute applied' is called twice. next.handle(...) creates a subscription and I'm not sure but my auth.service.ts creates another subscription (2nd one) instead of returning Observable<HttpEvent<any>>. I think that's the actual problem. I found a similar question Angular HTTP Interceptor subscribing to observable and then returning next.handle but throwing TypeError: You provided 'undefined', but unfortunately it doesn't really help me fix my issue.

return this.authService.currentUser$.pipe(
    switchMap(currentUser => {
      console.log('authorize attribute applied');
  
      const headers = request.headers.set('Authorization', `Bearer ${currentUser.accessToken}`);
      return next.handle(request.clone({ headers }));
    })
  );

enter image description here

auth.interceptor.ts

import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable, switchMap } from 'rxjs';

import { AuthService } from '@modules/auth/auth.service';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  constructor(private authService: AuthService) {}

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    console.log('auth interceptor called');

    if (!request.headers.has('Content-Type')) {
      request = request.clone({
        headers: request.headers.set('Content-Type', 'application/json')
      });
    }

    return this.authService.currentUser$.pipe(
      switchMap(currentUser => {
        console.log('authorize attribute applied');
    
        const headers = request.headers.set('Authorization', `Bearer ${currentUser.accessToken}`);
        return next.handle(request.clone({ headers }));
      })
    );
  }
}

auth.service.ts

import { Injectable, OnDestroy } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import {
  BehaviorSubject,
  delay,
  map,
  Observable,
  of,
  Subscription,
  switchMap,
  tap,
  timer
} from 'rxjs';

import { environment } from '@env';
import { JwtService } from '@core/services';
import { AuthResponse, INITIAL_AUTH_STATE, User } from './auth';

@Injectable({
  providedIn: 'root'
})
export class AuthService implements OnDestroy {
  private readonly TOKEN_URL = `${environment.apiUrl}/Accounts/token`;

  private currentUserSubject = new BehaviorSubject<AuthResponse>(INITIAL_AUTH_STATE);
  private timer!: Subscription;

  currentUser$: Observable<AuthResponse> = this.currentUserSubject.asObservable();

  get userInfo(): User | null {
    const accessToken = this.currentUserValue?.accessToken;

    return accessToken ? this.jwtService.decodeToken<User>(accessToken) : null;
  }

  private get currentUserValue(): AuthResponse | null {
    return this.currentUserSubject.value;
  }

  private get localStorageCurrentUser(): AuthResponse {
    const localStorageUser = localStorage.getItem('currentUser');
    return localStorageUser ? JSON.parse(localStorageUser) : INITIAL_AUTH_STATE;
  }

  constructor(
    private httpClient: HttpClient,
    private router: Router,
    private jwtService: JwtService
  ) {
    this.currentUserSubject.next(this.localStorageCurrentUser);
    window.addEventListener('storage', this.storageEventListener.bind(this));
  }

  ngOnDestroy(): void {
    window.removeEventListener('storage', this.storageEventListener.bind(this));
  }

  signIn(username: string, password: string): Observable<AuthResponse> {
    const TOKEN_URL: string = this.TOKEN_URL + '/create';

    return this.httpClient
      .post<AuthResponse>(TOKEN_URL, {
        username,
        password
      })
      .pipe(
        map((res) => {
          if (res && res.accessToken) {
            this.setCurrentUser(res);
          }

          return res;
        })
      );
  }

  signOut(): void {
    this.clearCurrentUser();
    this.router.navigate(['auth']);
  }

  refreshToken(): Observable<AuthResponse | null> {
    console.log('refresh token call');

    const accessToken = this.currentUserValue?.accessToken;
    const refreshToken = this.currentUserValue?.refreshToken;
    if (!accessToken || !refreshToken) {
      this.clearCurrentUser();
      return of(null);
    }

    return this.httpClient
      .post<AuthResponse>(`${this.TOKEN_URL}/refresh`, {
        accessToken: accessToken,
        refreshToken: refreshToken
      })
      .pipe(
        map((res) => {
          this.setCurrentUser(res);
          return res;
        })
      );
  }

  private setCurrentUser(user: AuthResponse) {
    this.currentUserSubject.next(user);
    this.setLocalStorage(user);
    this.startTokenTimer();
  }

  private clearCurrentUser() {
    this.currentUserSubject.next(INITIAL_AUTH_STATE);
    this.clearLocalStorage();
    this.stopTokenTimer();
  }

  private setLocalStorage(userState: AuthResponse) {
    localStorage.setItem('currentUser', JSON.stringify(userState));
    localStorage.setItem('login-event', 'login' + Math.random());
  }

  private clearLocalStorage() {
    localStorage.removeItem('currentUser');
    localStorage.setItem('logout-event', 'logout' + Math.random());
  }

  private getTokenRemainingTime(): number {
    const expiresAtUtc = this.currentUserValue?.expiresAtUtc;
    if (!expiresAtUtc) {
      return 0;
    }
    const expires = new Date(expiresAtUtc);
    return expires.getTime() - Date.now();
  }

  private startTokenTimer() {
    console.log('timeout called');

    const timeout = this.getTokenRemainingTime();

    this.timer = of(true)
      .pipe(
        delay(timeout),
        tap(() => this.refreshToken().subscribe())
      )
      .subscribe();
  }

  private stopTokenTimer() {
    this.timer?.unsubscribe();
  }

  private storageEventListener(event: StorageEvent) {
    if (event.storageArea === localStorage) {
      if (event.key === 'logout-event') {
        this.currentUserSubject.next(INITIAL_AUTH_STATE);
      }

      if (event.key === 'login-event') {
        location.reload();
      }
    }
  }
}

Edit:

I confirm that the problem is the one I described above. Basically next.handle(...) is being called twice and the double refresh token requests are because the interceptor creates a new subscription and auth.service.ts creates another one too. I should somehow re-use the first subscription, but I don't know how to do that.

First way

There is a workaround which fixes the double refresh token requests, but I don't like it, because next.handle(...) is still being called twice.

enter image description here

import { Injectable, OnDestroy } from '@angular/core';
import { HttpBackend, HttpClient } from '@angular/common/http';
import { Router } from '@angular/router';
import {
  BehaviorSubject,
  delay,
  map,
  Observable,
  of,
  Subscription,
  switchMap,
  tap,
  timer
} from 'rxjs';

import { environment } from '@env';
import { JwtService } from '@core/services';
import { AuthResponse, INITIAL_AUTH_STATE, User } from './auth';

@Injectable({
  providedIn: 'root'
})
export class AuthService implements OnDestroy {
  private readonly TOKEN_URL = `${environment.apiUrl}/Accounts/token`;

  private currentUserSubject = new BehaviorSubject<AuthResponse>(INITIAL_AUTH_STATE);
  private timer!: Subscription;
  private backendClient: HttpClient;

  currentUser$: Observable<AuthResponse> = this.currentUserSubject.asObservable();

  get userInfo(): User | null {
    const accessToken = this.currentUserValue?.accessToken;

    return accessToken ? this.jwtService.decodeToken<User>(accessToken) : null;
  }

  private get currentUserValue(): AuthResponse | null {
    return this.currentUserSubject.value;
  }

  private get localStorageCurrentUser(): AuthResponse {
    const localStorageUser = localStorage.getItem('currentUser');
    return localStorageUser ? JSON.parse(localStorageUser) : INITIAL_AUTH_STATE;
  }

  constructor(
    private httpClient: HttpClient,
    private router: Router,
    private jwtService: JwtService,
    handler: HttpBackend
  ) {
    this.currentUserSubject.next(this.localStorageCurrentUser);
    window.addEventListener('storage', this.storageEventListener.bind(this));

    this.backendClient = new HttpClient(handler);
  }

  ngOnDestroy(): void {
    window.removeEventListener('storage', this.storageEventListener.bind(this));
  }

  signIn(username: string, password: string): Observable<AuthResponse> {
    const TOKEN_URL: string = this.TOKEN_URL + '/create';

    return this.httpClient
      .post<AuthResponse>(TOKEN_URL, {
        username,
        password
      })
      .pipe(
        map((res) => {
          if (res && res.accessToken) {
            this.setCurrentUser(res);
          }

          return res;
        })
      );
  }

  signOut(): void {
    this.clearCurrentUser();
    this.router.navigate(['auth']);
  }

  refreshToken(): Observable<AuthResponse | null> {
    console.log('refresh token call');

    const accessToken = this.currentUserValue?.accessToken;
    const refreshToken = this.currentUserValue?.refreshToken;
    if (!accessToken || !refreshToken) {
      this.clearCurrentUser();
      return of(null);
    }

    return this.backendClient
      .post<AuthResponse>(`${this.TOKEN_URL}/refresh`, {
        accessToken: accessToken,
        refreshToken: refreshToken
      })
      .pipe(
        map((res) => {
          this.setCurrentUser(res);
          return res;
        })
      );
  }

  private setCurrentUser(user: AuthResponse) {
    this.currentUserSubject.next(user);
    this.setLocalStorage(user);
    this.startTokenTimer();
  }

  private clearCurrentUser() {
    this.currentUserSubject.next(INITIAL_AUTH_STATE);
    this.clearLocalStorage();
    this.stopTokenTimer();
  }

  private setLocalStorage(userState: AuthResponse) {
    localStorage.setItem('currentUser', JSON.stringify(userState));
    localStorage.setItem('login-event', 'login' + Math.random());
  }

  private clearLocalStorage() {
    localStorage.removeItem('currentUser');
    localStorage.setItem('logout-event', 'logout' + Math.random());
  }

  private getTokenRemainingTime(): number {
    const expiresAtUtc = this.currentUserValue?.expiresAtUtc;
    if (!expiresAtUtc) {
      return 0;
    }
    const expires = new Date(expiresAtUtc);
    return expires.getTime() - Date.now();
  }

  private startTokenTimer() {
    console.log('timeout called');

    const timeout = this.getTokenRemainingTime();

    this.timer = of(true)
      .pipe(
        delay(timeout),
        tap(() => this.refreshToken().subscribe())
      )
      .subscribe();
  }

  private stopTokenTimer() {
    this.timer?.unsubscribe();
  }

  private storageEventListener(event: StorageEvent) {
    if (event.storageArea === localStorage) {
      if (event.key === 'logout-event') {
        this.currentUserSubject.next(INITIAL_AUTH_STATE);
      }

      if (event.key === 'login-event') {
        location.reload();
      }
    }
  }
}

Second way

This one fixes both issues, but a separate subscription, just for the purpose of that currentUser$ Observable?

enter image description here

import { Injectable, OnDestroy } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable, Subject, switchMap, takeUntil } from 'rxjs';

import { AuthService } from '@modules/auth/auth.service';

@Injectable()
export class AuthInterceptor implements HttpInterceptor, OnDestroy {
  private componentDestroyed$ = new Subject<boolean>();

  constructor(private authService: AuthService) {}

  ngOnDestroy(): void {
    this.componentDestroyed$.next(true);
    this.componentDestroyed$.complete();
  }

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    console.log('auth interceptor called');

    this.authService.currentUser$.pipe(takeUntil(this.componentDestroyed$)).subscribe((user) => {
      const isLoggedIn = user && user.accessToken;
      if (isLoggedIn) {
        request = request.clone({
          setHeaders: { Authorization: `Bearer ${user.accessToken}` }
        });
      }

      return next.handle(request);
    });

    return next.handle(request);
  }
}

nop
  • 4,711
  • 6
  • 32
  • 93

2 Answers2

0

You're subscribing twice - naturally you're going to hit the server twice.

replace the problematic code part with this pipeline:

import { timer } from 'rxjs';
import { switchMap } from 'rxjs/operators';

// ...

this.timer = timer(timeout)
               .pipe(switchMap(() => this.refreshToken())
               .subscribe()
Stavm
  • 7,833
  • 5
  • 44
  • 68
  • It didn't work out. Sorry for misleading you, the actual problem is that `next.handle(...)` creates a new subscription and `auth.service.ts` doesn't re-use but it creates a second one. I think that's the issue. Look at my edit. – nop Aug 02 '21 at 09:20
0

Not sure why, but it looks like this.authService.currentUser$.pipe(switchMap(...)) outputs multiple values.

take(1) or first() would work. It's more like a patch fix, that's why I won't accept my answer. Someone might have a better idea.

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  if (!request.headers.has('Content-Type')) {
    request = request.clone({
      headers: request.headers.set('Content-Type', 'application/json')
    });
  }

  return this.authService.currentUser$.pipe(
    take(1),
    switchMap((currentUser) => {
      console.log('authorize attribute applied');

      const headers = request.headers.set('Authorization', `Bearer ${currentUser.accessToken}`);
      return next.handle(request.clone({ headers }));
    })
  );
}
nop
  • 4,711
  • 6
  • 32
  • 93