Ruham
Ruham

Reputation: 769

GraphQL Mutation works in Playground but not in Client Code

Seen similar questions here, but can't figure out what I'm doing wrong. I've got this mutation that refreshes the refreshToken and the access token:

type Auth {
    user: User!
    token: String!
    refreshToken: String!
}

type Mutation {
    refreshTokens: Auth!
}

The resolver:

Mutation: {
    refreshTokens: async (root, args, { req }, info) => {
      const result = await issueNewToken(req);
      return result;
    },
}

Auth helper:

issueNewToken: async (req) => {
    try {
      const token = req.headers.rt;
      if (token) {
        const decoded = await jwt.verify(token, jwtRefreshTokenSecret);
        let user = await User.findById(decoded.id);

        if (!user) {
          throw new AuthenticationError("No user found.");
        }
        let tokens = await authFunctions.issueToken(user);
        return { ...tokens, user };
      }
    } catch (err) {
      throw new AuthenticationError("Invalid Refresh Token.");
    }
  },

And then the function that needs to refresh the tokens if expired:

const refreshTokens = async () => {
  const {
    data: {
      refreshTokens: {
        user: { _id: id, address: address },
        token: token,
        refreshToken: refreshToken,
      },
    },
  } = await renewTokenApiClient.mutate({
    mutation: REFRESH_TOKENS,
  });

  localStorage.setItem("refreshtoken", refreshToken);
  localStorage.setItem("token", token);
};

And the mutation:

export const REFRESH_TOKENS = gql`
  mutation refreshTokens {
    refreshTokens {
      user {
        _id
        address
      }
      token
      refreshToken
    }
  }
`;

Now, this works in the playground. When I pass the refresh token in the http header, I get the updated token and refreshToken. However, the refreshTokens function above returns

"Cannot return null for non-nullable field Mutation.refreshTokens."

What am I missing here?

UPD:

The issue is indeed with the operation not getting proper headers. After some documentation and other questions on stackoverflow, I formed the following function, however, the operation is still not getting the headers, even though I seem to set them in the header after refreshing the tokens (which works properly).

import {
  ApolloClient,
  createHttpLink,
  fromPromise,
  from,
} from "@apollo/client";
import { onError } from "@apollo/client/link/error";
import { REFRESH_TOKENS } from "../mutations/User";
import { setContext } from "@apollo/client/link/context";

import cache from "../cache";

let isRefreshing = false;
let pendingRequests = [];

const setIsRefreshing = (value) => {
  isRefreshing = value;
};

const addPendingRequest = (pendingRequest) => {
  pendingRequests.push(pendingRequest);
};

const headerLink = setContext((_, { headers }) => {
  const token = localStorage.getItem("refreshtoken");
  return {
    headers: {
      ...headers,
      rt: token ? token : "",
    },
  };
});

const renewTokenApiClient = new ApolloClient({
  link: from([headerLink, createHttpLink({ uri: "/graphql" })]),
  cache,
  credentials: "include",
});

const resolvePendingRequests = () => {
  pendingRequests.map((callback) => {
    console.log(callback);
    callback();
  });
  pendingRequests = [];
};

const refreshTokens = async () => {
  const {
    data: {
      refreshTokens: {
        user: { _id: id, address: address },
        token: token,
        refreshToken: refreshToken,
      },
    },
  } = await renewTokenApiClient.mutate({
    mutation: REFRESH_TOKENS,
  });

  localStorage.setItem("refreshtoken", refreshToken);
  localStorage.setItem("token", token);
};

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (const err of graphQLErrors) {
        switch (err?.message) {
          case "jwt expired":
            const oldHeaders = operation.getContext().headers;

            if (!isRefreshing) {
              setIsRefreshing(true);
              const headers = {
                ...operation.getContext().headers,
              };
              return fromPromise(
                refreshTokens()
                  .then((refreshTokensResult) => {
                    let headers = {
                      //readd old headers
                      oldHeaders,
                      //switch out old access token for new one
                      authorization: `Bearer ${refreshTokensResult.token}`,
                      rt: refreshTokensResult.refreshToken,
                    };

                    operation.setContext({
                      headers,
                    });

                    console.log(operation, "ops");
                  })
                  .catch(() => {
                    resolvePendingRequests();
                    setIsRefreshing(false);

                    localStorage.clear();

                    return forward(operation);
                  })
              ).flatMap(() => {
                resolvePendingRequests();
                setIsRefreshing(false);

                console.log(operation);

                return forward(operation);
              });
            } else {
              return fromPromise(
                new Promise((resolve) => {
                  addPendingRequest(() => resolve());
                })
              ).flatMap(() => {
                return forward(operation);
              });
            }
        }
      }
    } else if (networkError) console.log(`[Network error]: ${networkError}`);
  }
);

export default errorLink;

UPD2: Found the solution and posted the answer.

Upvotes: 0

Views: 876

Answers (2)

Ruham
Ruham

Reputation: 769

Ended up with this, which works. refreshTokens() sets the new tokens in the localStorage.

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (const err of graphQLErrors) {
        switch (err?.message) {
          case "jwt expired":
            // Let's refresh token through async request
            return new Observable((observer) => {
              refreshTokens()
                .then(() => {
                  operation.setContext(({ headers = {} }) => ({
                    headers: {
                      ...headers,
                    },
                  }));
                })
                .then(() => {
                  const subscriber = {
                    next: observer.next.bind(observer),
                    error: observer.error.bind(observer),
                    complete: observer.complete.bind(observer),
                  };

                  // Retry last failed request
                  forward(operation).subscribe(subscriber);
                })
                .catch((error) => {
                  // No refresh or client token available, we force user to login
                  localStorage.clear();
                  isLoggedInVar(false);
                  observer.error(error);
                });
            });
        }
      }
    } else if (networkError) console.log(`[Network error]: ${networkError}`);
  }
);

Upvotes: 0

Sytten
Sytten

Reputation: 320

Looking at the code for the issueNewToken, there is a path that will return undefined if the token is not present in the headers. It is not clear how you create the renewTokenApiClient but a header needs to be specified somewhere to be sent with the request, it is not like a cookie that is sent automatically.

I would suggest checking the network tab to see if the header is present in the request. If it is not, then depending on the client use there are ways to inject it. For apollo client, you will need to create a link to inject it.

import { setContext } from '@apollo/client/link/context';

const authenticationLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('token');
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : "",
    }
  }
});

Upvotes: 1

Related Questions