Concurrency problems when using refresh token interceptor and NgRx pattern in Angular

56 Views Asked by At

I am using Angular 17 with the NgRx pattern. I'm trying to implement an interceptor to append the correct headers to requests made to the backend.

In particular, the logic to follow, according to the REST API model, is as follows:

All calls, except for those for "login", "register", "reset-password", must have the headers Authorization, Accept-Language, and Timezone-Offset correctly set. All calls, except for the one for the "refresh-token" endpoint, must be preceded by a call to the "refresh-token" endpoint if the token has expired and update the store with the new token received. What problem am I encountering? It seems there is an issue of concurrency or synchronization possibly caused by dispatching the new token. What happens is that, with multiple calls being made simultaneously and all requiring the token to be refreshed first if expired, I find myself having saved a token in the store that is not in sync with the one saved in the database after the refresh. Therefore, calls to the "refresh-token" endpoint result in a "400 - Session Not Found" error, blocking any further calls.

I tried implementing a request queue but without success.

Here's the code:


@Injectable()
export class HttpHeaderRequestInterceptor implements HttpInterceptor {
  constructor(
    private _accountAPI: AccountAPI,
    private _store: Store,
    private _authUtils: AuthUtils
  ) {}

  intercept(
    request: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    const refreshEndpoint = 'refresh-token';
    const excludedEndpoints = ['login', 'register', 'reset-password'];

    if (request.url.includes(refreshEndpoint)) {
      // For "refresh-token" endpoint, set only the Authorization header
      return this.setAuthorizationHeader(request, next);
    } else if (
      excludedEndpoints.some((endpoint) => request.url.includes(endpoint)) ||
      !request.url.includes('api')
    ) {
      // For "login", "register", or "reset-password" endpoints, do not modify the request
      return next.handle(request);
    } else {
      // For other endpoints, call refreshToken API and modify the request
      return this.refreshTokenAndContinue(request, next);
    }
  }

  private setAuthorizationHeader(
    request: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    return from(this._store.select(selectAuthToken)).pipe(
      take(1),
      switchMap((token) => {
        const updatedRequest = request.clone({
          setHeaders: {
            Authorization: `Bearer ${token}`,
          },
        });

        return next.handle(updatedRequest);
      })
    );
  }

  private refreshTokenAndContinue(
    request: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    const refreshToken$ = this._accountAPI.refreshToken();
    const currentLang$ = this._store.select(selectLangState);
    return combineLatest([refreshToken$, currentLang$]).pipe(
      take(1),
      switchMap(([tokenResponse, lang]) => {
        const tokenExists = tokenResponse.headers.get('Authorization');
        const token = tokenExists ? tokenExists.split(' ')[1].trim() : '';
        const tokenExpiration =
          this._authUtils.getAuthTokenExpirationDate(token);
        this._store.dispatch(
          accountActions.refreshTokenSuccess({
            authToken: token,
            tokenExpiration,
          })
        );

        const timeZoneOffset = new Date().getTimezoneOffset().toString();
        const updatedRequest = request.clone({
          setHeaders: {
            Authorization: `Bearer ${token}`,
            'Accept-Language': lang || DEFAULT_LANG,
            'Timezone-Offset': timeZoneOffset,
          },
        });

        return next.handle(updatedRequest);
      })
    );
  }
}

Refresh token call updates the DB only when token is expired.

This is the backend code for the service (in .NET):

public string RefreshToken(string token) { Log.Information("AuthenticationService - RefreshToken");

    if (string.IsNullOrEmpty(token))
    {
        throw new AppostoException(_localizer["sessionNotFound"]);
    }

    EntityResult<TokenAccount> tokenAccount = TokenUtil.GetTokenAccount(token);
    if (!tokenAccount.IsCorrect)
    {
        throw new AppostoException(_localizer["sessionNotFound"]);
    }

    ExpressionStarter<Account> predicates = PredicateBuilder
        .New<Account>()
        .And(account => account.Id == tokenAccount.Entity.Id);

    Account? account = _dynamicRepository.FindEntity(predicates);
    if (account == null || account.DateDelete != null || !account.Active || ("Bearer " + account.Token) != token)
    {
        throw new AppostoException(_localizer["sessionNotFound"]);
    }

    if (!tokenAccount.Entity.IsExpired && tokenAccount.Entity.TokenValid)
    {
        return token;
    }

    EntityResult<string> newToken = TokenUtil.GetJwtToken(tokenAccount.Entity.Id, tokenAccount.Entity.Role, tokenAccount.Entity.User, tokenAccount.Entity.ImageLink, _jwtSettings);
    if (!newToken.IsCorrect)
    {
        throw new AppostoException(newToken.Errors);
    }

    account.Token = newToken.Entity;
    account.DateUpdate = DateTime.UtcNow;
    account.LastLogin = DateTime.UtcNow;

    _dynamicRepository.Update(account);
    _dynamicRepository.SaveChanges();

    return "Bearer " + newToken.Entity;
}
0

There are 0 best solutions below