apollographql / apollo-link

:link: Interface for fetching and modifying control flow of GraphQL requests
https://www.apollographql.com/docs/link/
MIT License
1.44k stars 344 forks source link

apollo-link-error - how to async refresh a token? #646

Open crazy4groovy opened 6 years ago

crazy4groovy commented 6 years ago

Issue Labels

Question

The precise scenario I'm trying to accomplish is mentioned here:

https://github.com/apollographql/apollo-link/tree/master/packages/apollo-link-error#retrying-failed-requests

            operation.setContext({
              headers: {
                ...oldHeaders,
                authorization: getNewToken(),
              },
            });

However, if a token is expired, an async resfreshToken should be called and "waited on", before getNewToken can return a valid auth token. I think.

My question is, how to do an async resfreshToken call. I've tried await refreshToken() (which resolves a promise when it's complete), but from my logged stack traces it seems that this messes with RxJS quite a lot. I'm an RxJS n00b, any help is greatly appreciated!

thymikee commented 6 years ago

If you're more familiar with promises, you can use fromPromise helper

import { fromPromise } from 'apollo-link';

return fromPromise(refreshToken().then(token => {
  operation.setContext({
    headers: {
      ...oldHeaders,
      authorization: token,
    },
  });
  return forward(operation);
}))
shift-keshav-pudaruth commented 6 years ago

@thymikee Tried your solution and it fails with the following message:

Uncaught (in promise) Error: Network error: Error writing result to store for query:
 query UserProfile($id: ID!) {
  UserProfile(id: $id) {
    id
    email
    first_name
    last_name
    activated
    created_at
    updated_at
    last_active
    roles {
      id
      name
      __typename
    }
    permissions {
      name
      value
      __typename
    }
    profile {
      address
      secondary_email
      phone {
        id
        number
        type {
          id
          name
          __typename
        }
        __typename
      }
      __typename
    }
    __typename
  }
}
Cannot read property 'UserProfile' of undefined
    at new ApolloError (ApolloError.js:43)
    at QueryManager.js:327
    at QueryManager.js:759
    at Array.forEach (<anonymous>)
    at QueryManager.js:758
    at Map.forEach (<anonymous>)
    at QueryManager.webpackJsonp../node_modules/apollo-client/core/QueryManager.js.QueryManager.broadcastQueries (QueryManager.js:751)
    at QueryManager.js:254

Further inspection shows that the Apollo link's onError is called twice when using the above code. Even limiting the refresh token promise to be run once, doesn't fix the error.

What happens is:

1) Initial query is executed 2) It fails and runs apollo's link onError 3) ?? It runs apollo's link onError again 4) Promise to refresh token, in onError finishes executing and is resolved. 5) (The initial query isn't executed a second time after the promise is successful) 6) Initial query returns result containing data as undefined

Here's to hoping someone finds a solution to this, else we'll have to revert to using long-lived access tokens rather than refreshing them upon expiry.

thymikee commented 6 years ago

If your token retrieving logic is correct, onError should only be called once. Looks like you have issues with your token query

shift-keshav-pudaruth commented 6 years ago

@thymikee Switched out async request with a dummy promise. Still fails with the above message and the initial query is not run twice. All tokens are valid at time of test.

Code:

return fromPromise(
    new Promise((resolve) => {
        let headers = {
            //readd old headers
            ...operation.getContext().headers,
            //switch out old access token for new one
            authorization: `Bearer  mynewaccesstoken`,
        };
        operation.setContext({
            headers
        });
        return resolve(forward(operation));
    })
)

Edit: Removed the fromPromise and it works correctly. Somehow, the link stack's processing ends before returning the result so forward(operation) isn't executed.

shift-keshav-pudaruth commented 6 years ago

After analysing the fromPromise code and the commit #172 , the fromPromise can only be used in conjecture with an Apollo Link object.

Upon researching a solution, i finally stumbled upon this project: apollo-link-token-refresh

My apollo link stack is now as follows:

[
   refreshTokenLink,
   requestLink,
   batchHttpLink
]

refreshTokenLink is always called to check on the access token before executing any response to the graphql endpoint and works like a charm.

Unfortunately, this assumes that the call to the graphql endpoint must always be authenticated (which it is, in my case).

leethree commented 6 years ago

It looks like onError callback doesn't accept aync function or Promise returns. See code https://github.com/apollographql/apollo-link/blob/59abe7064004b600c848ee7c7e4a97acf5d230c2/packages/apollo-link-error/src/index.ts#L60-L74

This issue was reported before: #190

I think it would work better if apollo-link-error can deal with Promise, similar to what apollo-link-retry do here: #436

bogdansoare commented 6 years ago

having the same issue, using apollo with react native I need to remove some token from AsyncStorage onError so it needs to be an async function

blocka commented 6 years ago

This solution worked for me: https://stackoverflow.com/a/51321068/60223

crazy4groovy commented 6 years ago

I solved this by creating a utility promiseToObservable.js:

import { Observable } from 'apollo-link';

export default promise =>
  new Observable((subscriber) => {
    promise.then(
      (value) => {
        if (subscriber.closed) return;
        subscriber.next(value);
        subscriber.complete();
      },
      err => subscriber.error(err)
    );
    return subscriber; // this line can removed, as per next comment
  });

and then

import { onError } from 'apollo-link-error';
import promiseToObservable from './promiseToObservable';

export default (refreshToken: Function) =>
  onError(({
    forward,
    graphQLErrors,
    networkError = {},
    operation,
    // response,
  }) => {
    if (networkError.message === 'UNAUTHORIZED') { // or whatever you want to check
      // note: await refreshToken, then call its link middleware again!
      return promiseToObservable(refreshToken()).flatMap(() => forward(operation));
    }
  });
1999 commented 6 years ago

@crazy4groovy thanks for your example, it really helps. However, there's a small issue with it: subscriber is invalid return value according to Observable typings: it should rather be ZenObservable.SubscriptionObserver:

export declare type Subscriber<T> = ZenObservable.Subscriber<T>;

export declare const Observable: {
    new <T>(subscriber: Subscriber<T>): Observable<T>;
};

export declare namespace ZenObservable {
    interface SubscriptionObserver<T> {
        closed: boolean;
        next(value: T): void;
        error(errorValue: any): void;
        complete(): void;
    }

    type Subscriber<T> = (observer: SubscriptionObserver<T>) => void | (() => void) | Subscription;
}

i.e. it's safe to return undefined instead. I guess it should be mentioned in project's README file.

UPD: I added a PR about this: https://github.com/apollographql/apollo-link/pull/825

nvuhung commented 5 years ago

This issue ranks high on Google so I'm sharing my solution here to help out some folks: https://gist.github.com/alfonmga/9602085094651c03cd2e270da9b2e3f7

I have tried your solution but I'm facing new problem:

Argument of type '(this: Observable<{}>, observer: Subscriber<{}>) => Observable<{}> | Promise<{}>' is not assignable to parameter of type '(this: Observable<{}>, subscriber: Subscriber<{}>) => TeardownLogic'.
  Type 'Observable<{}> | Promise<{}>' is not assignable to type 'TeardownLogic'.
    Type 'Observable<{}>' is not assignable to type 'TeardownLogic'.
      Property 'unsubscribe' is missing in type 'Observable<{}>' but required in type 'Unsubscribable'
jakec-dev commented 5 years ago

How are you guys storing the new auth token once it's refreshed?

Sure, I can set new headers in the retry request, however the original access token (which I'm storing in cookies) doesn't get updated which means that every single request to the server will be using the old access token (and subsequently will need to be refreshed yet again).

For some reason I'm getting the following error message whenever I try to update the cookies during the refresh (I created a new issue here about it):

Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client
    at ServerResponse.setHeader (_http_outgoing.js:470:11)
    at setCookie (/root/SimplyTidyAdmin/node_modules/nookies/dist/index.js:98:17)
    at /root/SimplyTidyAdmin/.next/server/static/CAhshxrRWHVF6Gzbce~pU/pages/_app.js:1273:63
    at process._tickCallback (internal/process/next_tick.js:68:7)
HRK44 commented 5 years ago

@StupidSexyJake maybe this would help you https://stackoverflow.com/questions/55356736/change-apollo-client-options-for-jwt-token I run into a similar issue on how to update the token

aakay commented 5 years ago

Hi, thanks @crazy4groovy. I've tried your solution but I'm still having the problem, that the middleware where I append the token to graphql request is called before the new token is set to the request. Hence, the header still has the invalid token.

A bit background info: We get a network error, when token is invalid, and via a refresh token, we can get a new one and retry. But since middleware is called before the refresh token is gathered and set to local storage, it still has the invalid one. Refresh token logic works fine, since we then get the new token set in the end. I've debugged the issue a bit and timing is as follows:

Here's a snippet of these parts (skipping onRefreshtoken. It's an async function, returning a Promise):

  const promiseToObservable = (promise: Promise<any>) =>
    new Observable((subscriber: any) => {
      promise.then(
        value => {
          console.log(subscriber);
          if (subscriber.closed) return;
          subscriber.next(value);
          subscriber.complete();
        },
        err => subscriber.error(err)
      );
    });
  const authMiddleware = setContext((operation: GraphQLRequest) => {
    const token = localStorage.getItem('ca_token');
    return {
      headers: {
        ...(token && !isSkipHeader(operation)
          ? { authorization: `Bearer ${token}` }
          : {})
      }
    };
  });
const errorLink = onError(
    ({
      networkError,
      graphQLErrors,
      operation,
      forward
    }: ErrorResponse): any => {
      if (networkError) {
        switch (networkError.statusCode) {
          case 401:
            console.warn('Refreshing token and trying again');
            // await refreshToken, then call its link middleware again
            return promiseToObservable(onRefreshToken(client.mutate)).flatMap(() => forward(operation));
          default:
            // Handle all other errors here. Irrelevant here.
        }
      }
      if (graphQLErrors) {
         // Handle gql errors, irrelevant here.
      }
    }
  );

Could you please tell me what I missing here? Thanks a lot in advance...

aakay commented 5 years ago

OK, sorry for the confusion, if any...

I've found the answer and it's a stupid one after looking for it for hours and finding - of course - after posting here: during the initialisation of apollo client, I've swapped middleware and error link. Now it works. Error link should be first, obviously.. old: link: from([authMiddleware, errorLink, /* others */]) new: link: from([errorLink, authMiddleware, /* others */])

Sorry again..

shaxaaa commented 5 years ago

Hello guys,

I have the following problem using onError for refresh tokens. For the purpose of SSR using nextjs i am gathering data from all graphql queries, but what happens when we have 2 queries for example and each of them ends up with an error because jwt token is expired. Then it fires twice the onError and we are calling twice for refresh tokens which is expensive. I can't figure out where the problem might come from. Here is the code that I'm using. Can you please help with this.

https://gist.github.com/shaxaaa/15817f1bcc7b479f3c541383d2e83650

baleeds commented 5 years ago

I wrestled with this problem for a bit, but I finally got it working. I threw a package together.

https://github.com/baleeds/apollo-link-refresh-token

The primary difference between this package and the one called apollo-link-token-refresh is that this package will wait for a network error before attempting a refresh.

Let me know if you guys have ideas for changes.

Here's the basic usage:

const refreshTokenLink = getRefreshTokenLink({
  authorizationHeaderKey: 'Authorization',
  fetchNewAccessToken,
  getAccessToken: () => localStorage.getItem('access_token'),
  getRefreshToken: () => localStorage.getItem('refresh_token'),
  isAccessTokenValid: accessToken => isTokenValid(accessToken),
  isUnauthenticatedError: graphQLError => {
    const { extensions } = graphQLError;
    if (
      extensions &&
      extensions.code &&
      extensions.code === 'UNAUTHENTICATED'
    ) {
      return true;
    }
    return false;
  },
});
WilsonLau0755 commented 5 years ago

I solved this by creating a utility promiseToObservable.js:

import { Observable } from 'apollo-link';

export default promise =>
  new Observable((subscriber) => {
    promise.then(
      (value) => {
        if (subscriber.closed) return;
        subscriber.next(value);
        subscriber.complete();
      },
      err => subscriber.error(err)
    );
    return subscriber; // this line can removed, as per next comment
  });

and then

import { onError } from 'apollo-link-error';
import promiseToObservable from './promiseToObservable';

export default (refreshToken: Function) =>
  onError(({
    forward,
    graphQLErrors,
    networkError = {},
    operation,
    // response,
  }) => {
    if (networkError.message === 'UNAUTHORIZED') { // or whatever you want to check
      // note: await refreshToken, then call its link middleware again!
      return promiseToObservable(refreshToken()).flatMap(() => forward(operation));
    }
  });

I use it and found it still use old token after refresh token request. so, i try as follow:

return promiseToObservable(refreshToken()).flatMap((value) => {
  operation.setContext(({ headers = {} }) => ({
    headers: {
      // re-add old headers
      // ...headers,
      Authorization: `JWT ${value.token}`
    }
  }));
  return forward(operation)
});

and It works. However, It still has a problem that if i add the ...headers(means re-add old headers), there is something wrong before the forward request sented: ERROR Error: Network error: Cannot read property 'length' of null I think the Authorization in ...headers may conflicts with new Authorization.

the problem above is in apollo-angular "apollo-angular-link-http": "^1.6.0", and not in apollo-client "apollo-link-http": "^1.5.16", while link-error is the same"apollo-link-error": "^1.1.12",

Sceat commented 4 years ago

another syntax :eyes:

import Vue from 'vue'
import { Observable } from 'apollo-link'
import { onError } from 'apollo-link-error'

const onGraphqlError = async ({ graphQLErrors = [], observer, operation, forward }) => {
  // here you could call the refresh query in case you receive an expired error
  for (let error of graphQLErrors)
    observer.next(forward(operation)) // this line would retry the operation
}

const onNetworkError = async ({ observer, networkError, operation, forward }) => { }

export const errorHandler = opt => new Observable(async observer => {
  try {
    const payload = { ...opt, observer }
    await Promise.all([onGraphqlError(payload), onNetworkError(payload)])
    if (observer.closed) return
    observer.complete()
  } catch (error) {
    observer.error(error)
  }
})
odlainepre commented 4 years ago

Hi! Im using full websocket transport, need to request token query. No idea how to do that. I want to do a receive request when the server responds that the accessToken has expired.

import { onError } from "apollo-link-error";

import gql from 'graphql-tag'

// Client: VUE APOLLO
const q = {
    query: gql`query token { token { accessToken } }`,
    manual: true,
    result({ data, loading }) {
        if (!loading) {
            console.log(data)
        }
    },
}

const link = onError(({ graphQLErrors, networkError, operation, response, forward }) => {

    if (networkError) {

        switch (networkError.message) {
            case 'accessTokenExpired':
                console.log('accessTokenExpired')
                return forward(q) // NOT WORKS, NEED HELP
            case 'unauthorized':
                return console.log('unauthorized')
            default:
                return forward(operation)
        }
    }

    return forward(operation)
})

export default link
Sceat commented 4 years ago

@nikitamarcius we posted workarounds above, take a look at observables

Ramyapriya24 commented 4 years ago

I am unable to update the token can anybody provided the working example

adrianolsk commented 4 years ago

@Ramyapriya24 here is the code I am using.

import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';
import { setContext } from '@apollo/link-context';
import AuthService from 'services/auth-service' // this is my implementation

const asyncAuthLink = setContext(async () => {
    // this is an async call, it will be done before each request
    const { token } = await AuthService.getCredentials();
    return {
      headers: {
        authorization: token
      },
    };
  },
);

const httpLink = new HttpLink({
  uri: 'http://localhost:4000/graphql',
});

export const apolloClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: asyncAuthLink.concat(httpLink),
});
Ramyapriya24 commented 4 years ago

@adrianolsk can you provide the service code wrote

import AuthService from 'services/auth-service' // this is my implementation const { token } = await AuthService.getCredentials();

when I am trying to import the service I am getting errors

adrianolsk commented 4 years ago

That is my service, it just read the AsyncStorage from react-native, so after login I set the value there and before each request the code just grab the info and set in the header, you could do the same, or using localStorage if you are on the web.

Where are you storing the information you want to use?

you can just use this

//save the token after login or when it refreshes
localStorage.setItem('token', yourToken);

and use it

const asyncAuthLink = setContext(() => {
    // grab token from localStorage
    const token = localStorage.getItem('token');
    return {
      headers: {
        authorization: token
      },
    };
  },
);
Ramyapriya24 commented 4 years ago

@adrianolsk thanks for the explanation but I am using angular I am unable to import the service in grapqh.module.ts file I am getting errors when I am using the service

can anyone know how to use the service in module.ts file without using class and constructor

Thanks

bzhr commented 4 years ago

I'm trying to use fromPromise for async refresh the token. Basically following the third box from this post

I'm successfully getting and storing the tokens, but neither of catch or filter or flatMap gets called. I'm not sure how to debug this, so some suggestions will be helpful.

if (token && refreshToken) {
  return fromPromise(
    getNewToken(client)
      .then(({ data: { refreshToken } }) => {
        console.log("Promise data: ", refreshToken);
        localStorage.setItem("token", refreshToken.token);
        localStorage.setItem("refreshToken", refreshToken.refreshToken);
        return refreshToken.token;
      })
      .catch((error) => {
        // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
        console.log("Error after setting token: ", error);
        return;
      })
  )
    .filter((value) => {
      console.log("In filter: ", value);
      return Boolean(value);
    })
    .flatMap(() => {
      console.log("In flat map");
      // retry the request, returning the new observable
      return forward(operation);
    });
}
dandv commented 4 years ago

@adrianolsk: that approach seems to always refresh the token, even before it's expired, which in the case of some authentications services (e.g. Auth0's checkSession) will make an unnecessary Auth0 server roundtrip for every GraphQL request.

bzhr commented 4 years ago

I'm trying to use fromPromise for async refresh the token. Basically following the third box from this post

I'm successfully getting and storing the tokens, but neither of catch or filter or flatMap gets called. I'm not sure how to debug this, so some suggestions will be helpful.

if (token && refreshToken) {
  return fromPromise(
    getNewToken(client)
      .then(({ data: { refreshToken } }) => {
        console.log("Promise data: ", refreshToken);
        localStorage.setItem("token", refreshToken.token);
        localStorage.setItem("refreshToken", refreshToken.refreshToken);
        return refreshToken.token;
      })
      .catch((error) => {
        // Handle token refresh errors e.g clear stored tokens, redirect to login, ...
        console.log("Error after setting token: ", error);
        return;
      })
  )
    .filter((value) => {
      console.log("In filter: ", value);
      return Boolean(value);
    })
    .flatMap(() => {
      console.log("In flat map");
      // retry the request, returning the new observable
      return forward(operation);
    });
}

I've found what was the cause of the error. Not seen in the code above, but I used a map function to map each of the resulting errors. This caused onError to return nothing and the observable wasn't subscribed to the operation for token renewal.

Pretty confusing and it took me so long to figure it out. Thanks to the author of the blog post for helping me out.

drmencos commented 4 years ago

ERROR Error: Network error: Cannot read property 'length' of null

@WilsonLau0755, I had the same problem. Solved it by setting all null headers to an empty string ''.

MalteMagnussen commented 4 years ago

Why is onError not just available to use with async await?