2

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 { }
Blair Holmes
  • 1,521
  • 2
  • 22
  • 35

2 Answers2

3

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');
      })
    );
  }
}


All2Pie
  • 310
  • 3
  • 13
  • How you avoid circular dependency? You import GraphQLModule in AppModule, and your service provideIn: root. With this config i get "Error: NG0200: Circular dependency in DI detected". Can you give some advice? – FierceDev Jun 04 '21 at 07:47
  • 1
    @FierceDev Check my answer, I am using `Injector` to fix this issue – All2Pie Mar 25 '22 at 07:23
2

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.

Andrei Gătej
  • 11,116
  • 1
  • 14
  • 31
  • thanks for the response. I tried this (and compared it with my httpinterceptor service) as I'm using `switchMap` there exactly as you show, and it doesn't work in this `onError()` context. It's not actually subscribing to my observable in my authenticationservice class as I have output being logged in there with a `tap()`...this does work in my aforementioned httpinterceptor though. I think it's something getting hung up in my map() method. I'm investigating that now. – Blair Holmes May 09 '20 at 16:06
  • For some reason I haven't noticed everything is taking place inside an `arr.map(...)`; I'm not sure what should happen inside `errorLink`, but I think you make the cb passed to `map` to return that observable(as seen in the answer). Hmm, so you're having an array of errors. If there are more, which one should take precedence? – Andrei Gătej May 09 '20 at 16:10
  • 1
    I'm getting closer. See my edit in the OP. I don't think I need to be using `map()` here honestly (at least not for this situation). Here are the docs for `onError()` https://www.apollographql.com/docs/link/links/error/ – Blair Holmes May 09 '20 at 16:18
  • Are you covering the case when there are no errors? If `graphQLErrors` is falsy, then you should return something like: `of(null)`. – Andrei Gătej May 09 '20 at 16:21
  • tried that, no change. I just added another edit to my OP with a screenshot of VS Code hover of onError's method signature. Here is a link to what got me started down this path: https://github.com/apollographql/apollo-link/issues/646. There is a comment by @crazy4groovy that has a code snippet that I tried to use. He wrote a helper method to convert from a promise to an observable which I don't think I need to do because my AuthenticationService is already returning an Observable. – Blair Holmes May 09 '20 at 16:31
  • Hm, I think the problem is that RxJs' observables are different that those from 'apollo-link'. [src](https://github.com/apollographql/apollo-link/blob/master/packages/zen-observable-ts/src/zenObservable.ts#L25-L49). I also think you're not covering all the cases: `if (err ){ if (... === 'unauthorized') { } else { ?? } }` – Andrei Gătej May 09 '20 at 16:42
  • 1
    So `errorLink` should return an `apollo-link` Observable, not an `RxJs`'s one. – Andrei Gătej May 09 '20 at 16:49
  • 1
    YOU.... ARE..... FREAKING.... AWESOME.... I've been fighting with this for two days and you got me there. Thanks so much. I will post my updated working solution in the OP for anyone else needing this for angular. – Blair Holmes May 09 '20 at 16:54