2
votes

I'm trying to set up a refresh token strategy to refresh JWT in angular 9 with GraphQL and apollo client when my first request returns a 401.

I have set up a new angular module for graphql where I'm creating my apolloclient. Everything works great even with authenticated requests but I need to have my normal refresh token strategy work as well (re-make and return the original request after refresh token cycle completes). I have found only a few resources to help with this and I've gotten really close - the only thing I'm missing is returning the observable from my refresh token observable.

Here is the code that would think should work:

    import { NgModule } from '@angular/core';
import { HttpLinkModule, HttpLink } from 'apollo-angular-link-http';
import { AuthenticationService } from './authentication/services/authentication.service';
import { ApolloLink } from 'apollo-link';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { ApolloModule, APOLLO_OPTIONS } from 'apollo-angular';
import { onError } from 'apollo-link-error';

export function createApollo(httpLink: HttpLink, authenticationService: AuthenticationService) {

  const authLink = new ApolloLink((operation, forward) => {
    operation.setContext({
      headers: {
        Authorization: 'Bearer ' + localStorage.getItem('auth_token')
      }
    });
    return forward(operation);
  });

  const errorLink = onError(({ forward, graphQLErrors, networkError, operation }) => {
    if (graphQLErrors) {
      graphQLErrors.map(({ message, locations, path }) =>
        {
         if (message.toLowerCase() === 'unauthorized') {
          authenticationService.refreshToken().subscribe(() => {
            return forward(operation);
          });
         }
        }
      );
    }
  });

  return {
    link: errorLink.concat(authLink.concat(httpLink.create({ uri: 'http://localhost:3000/graphql' }))),
    cache: new InMemoryCache(),
  };
}


@NgModule({
  exports: [ApolloModule, HttpLinkModule],
  providers: [
    {
      provide: APOLLO_OPTIONS,
      useFactory: createApollo,
      deps: [HttpLink, AuthenticationService]
    }
  ]
})
export class GraphqlModule { }

I know that my request is working the second time because if I log out the result from the forward(operation) observable inside my authenticationService subscription, I can see the results after the initial 401 failure.

 if (message.toLowerCase() === 'unauthorized') {
  authenticationService.refreshToken().subscribe(() => {
    return forward(operation).subscribe(result => {
      console.log(result);
    });
  });
 }

the above shows me the data from the original request, but it's not being passed up to my component that originally called the graphql.

I'm far from an expert with observables but I'm thinking I need to do some kind of map (flatmap, mergemap etc) to make this return work correctly, but I just don't know.

Any help would be greatly appreciated

TIA

EDIT #1: this is getting me closer as it's now actually subscribing to my method in AuthenticationService (I see results in the tap())

    const errorLink = onError(({ forward, graphQLErrors, networkError, operation }) => {
    if (graphQLErrors) {
      if (graphQLErrors[0].message.toLowerCase() === 'unauthorized') {
        return authenticationService.refreshToken()
        .pipe(
          switchMap(() => forward(operation))
        );
      }
    }
  });

I'm now seeing this error being thrown:

core.js:6210 ERROR TypeError: You provided an invalid object where a stream was expected. You can provide an Observable, Promise, Array, or Iterable.

EDIT #2: Including screenshot of onError() function signature: enter image description here

EDIT #3 Here is the final working solution in case someone else comes across this and needs it for angular. I don't love having to update my service method to return a promise, and then convert that promise into an Observable - but as @Andrei Gătej discovered for me, this Observable is from a different namespace.

import { NgModule } from '@angular/core';
import { HttpLinkModule, HttpLink } from 'apollo-angular-link-http';
import { AuthenticationService } from './authentication/services/authentication.service';
import { ApolloLink } from 'apollo-link';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { ApolloModule, APOLLO_OPTIONS } from 'apollo-angular';
import { onError } from 'apollo-link-error';
import { Observable } from 'apollo-link';


export function createApollo(httpLink: HttpLink, authenticationService: AuthenticationService) {

  const authLink = new ApolloLink((operation, forward) => {
    operation.setContext({
      headers: {
        Authorization: 'Bearer ' + localStorage.getItem('auth_token')
      }
    });
    return forward(operation);
  });

  const errorLink = onError(({ forward, graphQLErrors, networkError, operation }) => {
    if (graphQLErrors) {
      if (graphQLErrors.some(x => x.message.toLowerCase() === 'unauthorized')) {
        return promiseToObservable(authenticationService.refreshToken().toPromise()).flatMap(() => forward(operation));
      }
    }
  });

  return {
    link: errorLink.concat(authLink.concat(httpLink.create({ uri: '/graphql' }))),
    cache: new InMemoryCache(),
  };
}

const promiseToObservable = (promise: Promise<any>) =>
    new Observable((subscriber: any) => {
      promise.then(
        value => {
          if (subscriber.closed) {
            return;
          }
          subscriber.next(value);
          subscriber.complete();
        },
        err => subscriber.error(err)
      );
    });


@NgModule({
  exports: [ApolloModule, HttpLinkModule],
  providers: [
    {
      provide: APOLLO_OPTIONS,
      useFactory: createApollo,
      deps: [HttpLink, AuthenticationService]
    }
  ]
})
export class GraphqlModule { }
2
Hi, I am getting circular dependency issue as there are GqlApis calls in AuthService and GqlModule uses AuthService. How did you solve that ?All2Pie
@All2Pie same here! Did you solve it?Lindeberg
@Lindeberg Yep, check my answer.All2Pie

2 Answers

2
votes

I'm not quite familiar with GraphQL, but I think this should work fine:

if (message.toLowerCase() === 'unauthorized') {
return authenticationService.refreshToken()
  .pipe(
    switchMap(() => forward(operation))
  );
}

Also, if you'd like to know about how mergeMap(and concatMap) work, you can have a look at this answer.

switchMap keeps only one active inner observable and as soon as an outer value comes in, the current inner observable will be unsubscribed and a new one will be created, based on the newly arrived outer value and the provided function.

2
votes

Here is my implementation for anyone seeing this in future

Garaphql Module:

import { NgModule } from '@angular/core';
import { APOLLO_OPTIONS } from 'apollo-angular';
import {
  ApolloClientOptions,
  InMemoryCache,
  ApolloLink,
} from '@apollo/client/core';
import { HttpLink } from 'apollo-angular/http';
import { environment } from '../environments/environment';
import { UserService } from './shared/services/user.service';
import { onError } from '@apollo/client/link/error';
import { switchMap } from 'rxjs/operators';

const uri = environment.apiUrl;

let isRefreshToken = false;
let unHandledError = false;

export function createApollo(
  httpLink: HttpLink,
  userService: UserService
): ApolloClientOptions<any> {
  const auth = new ApolloLink((operation, forward) => {
    userService.user$.subscribe((res) => {
      setTokenInHeader(operation);
      isRefreshToken = false;
    });

    return forward(operation);
  });

  const errorHandler = onError(
    ({ forward, graphQLErrors, networkError, operation }): any => {
      if (graphQLErrors && !unHandledError) {
        if (
          graphQLErrors.some((x) =>
            x.message.toLowerCase().includes('unauthorized')
          )
        ) {
          isRefreshToken = true;

          return userService
            .refreshToken()
            .pipe(switchMap((res) => forward(operation)));
        } else {
          userService.logOut('Other Error');
        }

        unHandledError = true;
      } else {
        unHandledError = false;
      }
    }
  );

  const link = ApolloLink.from([errorHandler, auth, httpLink.create({ uri })]);

  return {
    link,
    cache: new InMemoryCache(),
    connectToDevTools: !environment.production,
  };
}

function setTokenInHeader(operation) {
  const tokenKey = isRefreshToken ? 'refreshToken' : 'token';
  const token = localStorage.getItem(tokenKey) || '';
  operation.setContext({
    headers: {
      token,
      Accept: 'charset=utf-8',
    },
  });
}

@NgModule({
  providers: [
    {
      provide: APOLLO_OPTIONS,
      useFactory: createApollo,
      deps: [HttpLink, UserService],
    },
  ],
})
export class GraphQLModule {}

UserService/AuthService:

import { BehaviorSubject, Observable, throwError } from 'rxjs';
import { User, RefreshTokenGQL } from '../../../generated/graphql';
import jwt_decode from 'jwt-decode';
import { Injectable, Injector } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, tap } from 'rxjs/operators';
import { AlertService } from './alert.service';

@Injectable({
  providedIn: 'root',
})
export class UserService {
  private userSubject: BehaviorSubject<User>;
  public user$: Observable<User>;

  constructor(
    private router: Router,
    private injector: Injector,
    private alert: AlertService
  ) {
    const token = localStorage.getItem('token');
    let user;
    if (token && token !== 'undefined') {
      try {
        user = jwt_decode(token);
      } catch (error) {
        console.log('error', error);
      }
    }
    this.userSubject = new BehaviorSubject<User>(user);
    this.user$ = this.userSubject.asObservable();
  }

  setToken(token?: string, refreshToken?: string) {
    let user;

    if (token) {
      user = jwt_decode(token);
      localStorage.setItem('token', token);
      localStorage.setItem('refreshToken', refreshToken);
    } else {
      localStorage.removeItem('token');
      localStorage.removeItem('refreshToken');
    }

    this.userSubject.next(user);
    return user;
  }

  logOut(msg?: string) {
    if (msg) {
      this.alert.addInfo('Logging out...', msg);
    }

    this.setToken();
    this.router.navigateByUrl('/auth/login');
  }

  getUser() {
    return this.userSubject.value;
  }

  refreshToken() {
    const refreshTokenMutation = this.injector.get<RefreshTokenGQL>(
      RefreshTokenGQL
    );

    return refreshTokenMutation.mutate().pipe(
      tap(({ data: { refreshToken: res } }) => {
        this.setToken(res.token, res.refreshToken);
      }),
      catchError((error) => {
        console.log('On Refresh Error: ', error);
        this.logOut('Session Expired, Log-in again');
        return throwError('Session Expired, Log-in again');
      })
    );
  }
}