Andrii Romanchak
Andrii Romanchak

Reputation: 778

Angular 2 Refresh access token on 401 error and repeat initial request

TLDR: My task is to complete 3 requests instead of 1 and return the last response as a response to the first request without any additional modifications of the request initiator.

I have extended the Angular Http class to automatically append authorization headers to all of my requests and implement my own authorization error handling.

It looks like this:

  request(url: string | Request, options?: RequestOptionsArgs): Observable<Response> {

    // ... append some headers

    super.request(url, options).catch((error: Response) => {
      if (error.status === 401 || error.status === 403 ) {
        // todo: Send refreshToken request to get new credentials
        // todo: Send current request again with new credentials

        // todo: If request is completed properly pretend everything was fine and return response
      }
    });
  }

I want to catch authorization errors, fix them by sending token refresh request and return proper response to the initial request.

There's a lot of code using http now and I don't want to change it so the fixed response has to be returned as the initial would have been without anybody knowing about it.

One of the approaches was to use synchronous requests but it's not a good idea I think.

Could you tell please if the solution is possible and how can I achieve it?

PS. There may be a problem when another request is executed while the token is being refreshed and crash into an authorization causing one more token refresh. But this is not that important now.

Upvotes: 3

Views: 3028

Answers (1)

Andrii Romanchak
Andrii Romanchak

Reputation: 778

The aim was achieved mostly by using flatMap to compose requests.

Key functions:

  • Check if request request returns 401
  • If 401: tries to fix renew necessary tokens and sends request again
  • The subscriber knows nothing about error if it's fixed

It's designed to work with the REST authentication model which includes:

  • guest token - for unauthorized users (gToken)
  • auth token - for authorized users - (aToken)
  • refresh token - to refresh expired aToken (refresh_token)

Most likely you will need to rewrite requests to fit your backend but here's a well-commented Services to be provided instead of default Http:

import {Injectable} from '@angular/core';
import {
  Http, XHRBackend, RequestOptions, RequestOptionsArgs, Request, Response, RequestMethod,
  Headers
} from "@angular/http";
import { Observable } from "rxjs";
import { StorageService } from "../storage.service";
import { AppService } from "./app.service";

@Injectable()
export class HttpClientService extends Http {

  private autoAppendHeadersDefault = true;

  constructor(
    backend: XHRBackend,
    defaultOptions: RequestOptions,
    private storageService: StorageService,
    private appState: AppService,
  ) {
    super(backend, defaultOptions);
    this.autoAppendHeadersDefault = this.appState.hoodConfig.HTTP_AUTO_APPEND_HEADERS;
  }

  request(url: string | Request, options?: RequestOptionsArgs, disableTryFix = false): Observable<Response> {

    // Checking if the request needs headers to be appended
    let assetRequest = false;
    if(url instanceof Request) {
      if(url.url.startsWith("/assets")) {
        assetRequest = true;
      }
    }

    // Appending headers
    if(!assetRequest && this.appState.hoodConfig.HTTP_AUTO_APPEND_HEADERS && url instanceof Request) {

      // append aToken || gToken
      let token = this.storageService.get('aToken');
      if('undefined' === typeof token || !token) {
        token = this.storageService.get('gToken');
      }

      if('undefined' !== typeof token && token) {
        url.headers.set('Authorization', `Bearer ${token}`);
      } else {
        // neither aToken nor gToken are set
        if(disableTryFix) {
          this.removeAllTokens();
          return Observable.throw({error: "Can't reauth: 01"});
        }
        return this.tryFixAuth().flatMap(
          (res:any) => {
            res = res.json();
            this.storageService.set('gToken', res.access_token);
            return this.request(url, options, true);
          }
        );
      }

      // headers appended to every request
      if(!url.headers.get('Content-Type')) {
        url.headers.append('Content-Type', 'application/json');
      }
    }
    this.appState.hoodConfig.HTTP_AUTO_APPEND_HEADERS = this.autoAppendHeadersDefault;

    return super.request(url, options).catch((error: Response) => {
      if (error.status === 401 /* || error.status === 403 */ ) {

        if(disableTryFix) {
          this.removeAllTokens();
          this.navigateOnAuthFail();
          return Observable.throw({error: "Can't reauth: 02"});
        }

        return this.tryFixAuth().flatMap(
          (res: any) => {
            res = res.json();

            if('undefined' !== typeof res.refresh_token)
            {
              // got aToken & refresh_token
              this.storageService.set('aToken', res.access_token);
              this.storageService.set('refresh_token', res.refresh_token);
            }
            else if('undefined' !== typeof res.access_token)
            {
              // got only gToken
              this.storageService.set('gToken', res.access_token);
            }
            else
            {
              console.log('tryFix: nothing useful returned')
              // got no aToken, no gToken, no refresh_token
            }

            // retry request
            return this.request(url, options, true);
          }
        );
      }

      // handle invalid refresh_token
      if(disableTryFix && error.status === 400) {
        console.log('Wrong refresh token (400)');
        this.storageService.remove('refresh_token');
        this.storageService.remove('aToken');
        this.navigateOnAuthFail();
        // handle invalid refresh token
      }
      return Observable.throw(error);
    });
  }

  private tryFixAuth(): Observable<Response> {
    console.log('Trying to fix auth');

    if(this.storageService.get('refresh_token'))
    {
      return this.refreshToken();
    }
    else if(this.storageService.get('aToken'))
    {
      // no refresh_token, but aToken
      // since aToken is dead it's not useful
      this.storageService.remove('aToken');
    }
    else
    {
      // no aToken, no refresh_token
      // possibly there's a gToken
      // since the request is trying to fix itself (is failed) the gToken is most likely not valid
      return this.guestToken();
    }
  }

  // sends request with refresh_token to get new aToken
  // the request returns only aToken and refresh_token, no gToken
  private refreshToken(): Observable<Response> {

    // is called only when refresh_token is set
    let refreshToken = this.storageService.get('refresh_token');

    // check refresh_token in case it's not checked before
    if('undefined' === typeof refreshToken || !refreshToken || refreshToken == 'undefined') {
      this.storageService.remove('refresh_token');
      // there's no refresh_token saved
      return Observable.throw({error: "Refresh token is not set"});
    }

    // form refresh_token request
    const headers = new Headers();
    headers.append('Authorization', `Bearer ${this.storageService.get('gToken')}`);
    headers.append('Content-Type', 'application/json');

    const url = `${this.appState.config.WEBSITE_ENDPOINT}/oauth/v2/token`;
    const localData = JSON.stringify({
      "client_id": this.appState.config.CLIENT_ID,
      "client_secret": this.appState.config.CLIENT_SECRET,
      "grant_type": 'refresh_token',
      "refresh_token": refreshToken
    });

    this.appState.hoodConfig.HTTP_AUTO_APPEND_HEADERS = false;

    // refresh_token request
    return this.request(
      new Request({
        method: RequestMethod.Post,
        url: url,
        headers: headers,
        body: localData
      }),
      null, true);
  }

  // sends request to get new gToken
  private guestToken(): Observable<Response> {
    const url = `${
      this.appState.config.WEBSITE_ENDPOINT}/oauth/v2/token?client_id=${
      this.appState.config.CLIENT_ID}&client_secret=${
      this.appState.config.CLIENT_SECRET}&grant_type=client_credentials`;
    this.appState.hoodConfig.HTTP_AUTO_APPEND_HEADERS = false;
    return super.get(url);
  }


  // Aux methods

  private navigateOnAuthFail() {
    console.warn('Page is going to be refreshed');

    // redirect to auth is performed after reload by authGuard
    // it's possible to add some warning before reload
    window.location.reload();
  }

  private removeAllTokens() {
    this.storageService.remove('aToken');
    this.storageService.remove('gToken');
    this.storageService.remove('refresh_token');
  }
}

Upvotes: 2

Related Questions