benyogyerek
benyogyerek

Reputation: 426

Angular interceptor one token call at a time

I have an Angular (v6 still, unfortunately) interceptor that should add API tokens to HTTP calls, and if a call fails with 401, should refresh the token. I also have a service that acquires tokens and caches them.

public intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  // some business logic to get e.g. clientId (depends on request parameters)

  return from(this.getTokenFor(clientId, requestedScope, forceRefresh)).pipe(
    flatMap(token => this.handleRequest(token, request, next)),
    catchError(error => {
      // If this is a cached token, it could be expired/revoked
      // Try refreshing and retrying the request below, if this could be the case
      if (error.status !== 401 || forceRefresh) {
        return throwError(error);
      }

      return from(this.getTokenFor(clientId, requestedScope, true))
        .pipe(flatMap(token => this.handleRequest(token, request, next)));
    }));
}

this.handleRequest adds the Authorization header and forwards the event to next. How can I achieve, that getTokenFor (which returns a promise of string) is only called once at a time, even for different HTTP requests? Subsequent calls should wait for a previous to complete before calling the promise again.

Upvotes: 1

Views: 671

Answers (2)

maxime1992
maxime1992

Reputation: 23803

I think the safest bet in that case is to have an external service as a singleton, that'll run only one refresh request at a time, and otherwise return the previously fetched token.

@Injectable()
export class Auth {
  private requestToken$ = new Subject<{
    clientId;
    requestedScope;
    forceRefresh;
  }>();
  private token$: Observable<string>;

  public getTokenFor(clientId, requestedScope, forceRefresh) {
    requestToken$.next({ clientId, requestedScope, forceRefresh });
    return this.token$;
  }

  constructor() {
    this.token$ = this.requestToken$.pipe(
      exhaustMap(() => {
        return concat(
          of(null)
          // your getTokenFor implementation
        );
      }),
      filter(x => !!x),
      shareReplay({ bufferSize: 1, refCount: false })
    );
  }
}

Some details about the above:

  • The getTokenFor method only pass the parameters to a subject that is managed outside the method. The reason for this is because if we were to manage the creation of an observable in the method, everytime you'd call it you'd get a new instance which isn't what we want
  • exhaustMap is the operator to go with here because if there are attemps to refresh the token while it's still happening, it won't do it. Only 1 instance can run to return the token
  • We start with of(null) in the concat so that it clears up any previous token we have and then triggers the fetch of the new one (this will put on hold all the calls that are trying to get a token)
  • filter(x => !!x), because we start by returning null to signal that a a refresh is happening, we filter this out here and only keep a real result (a token). This will make all the observables down stream wait for the token
  • shareReplay({ bufferSize: 1, refCount: false }) is so that if any http call happens after another one, we get the same result as the previous one (either waiting for a token or the token itself). As for the refCount false, this could be a real issue with a memory leak in a lot of cases but in that specific case, I don't think you'll ever want to shutdown your auth service so it's fine (otherwise add a takeUntil or something like this)

Then you can inject that service in your interceptor and use the getTokenFor method safely. If one call triggers a token refresh, this call and all the new ones will be put on hold until the refreshed token is here at which point they'll resume

Upvotes: 2

Vibin Thomas
Vibin Thomas

Reputation: 71

You can achieve this by using the retryWhen rxjs operator in your interceptor:

 public intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    // some business logic to get e.g. clientId (depends on request parameters)

    return from(this.getTokenFor(clientId, requestedScope, forceRefresh)).pipe(
      flatMap(token => this.handleRequest(token, request, next)),
      retryWhen(error =>
        // handling the errors from the endpoint
        error.pipe(
          concatMap((err, count) => {
            // handing the 401 issues
            if (count <= retryCount) {
              // first time execute refresh token logic...
              return from(this.getTokenFor(clientId, requestedScope, true)).pipe(flatMap(token => this.handleRequest(token, request, next)));
            }
            // Error logging after retrying
            if (count > retryCount && err?.status === 401) {

            }

            return throwError({ error: err });
          })
        )
      ));
  }

On top of the interceptor, you can set the retry limit

const retryCount = 1;

Upvotes: 0

Related Questions