DEV Community

SeongKuk Han
SeongKuk Han

Posted on • Updated on

React Apollo: JWT & Refresh Token

These days, a lot of services choose JWT(JSON Web Token) as their authentication. When you implement JWT, you would issue an access token and a refresh token.


AccessToken and RefreshToken

  • AccessToken has a short expiration time(like 10~15min) and represents the authorization to access APIs.
  • RefreshToken is used for issuing a new access token and has a longer expiration time than the access token.

Thanks to refresh tokens, you can manage more safe access tokens.
You might ask that 'What if a refresh token is leaked?'. There are many strategies that make us safer. like RTR(Refresh Token Rotation).
To put it simply, refresh API issues an access token and a refresh token and expires the refresh token. they assume tokens must've leaked if refresh tokens are used more than once.

I recommend reading this documentation auth0-refresh-token-rotation.

I won't talk about JWT in this post anymore, let's move on.


Refresh Token Implementation

I made a test server using NestJS. There are three resolvers and two guards.

Guards

  • JwtAuthGuard: Authorize if the access token is valid in the Authorization header.
  • JwtRefreshAuthGuard: Authorize if the refresh token is valid in the Authorization header.

Both tokens will be passed in the Authorization header in each request and will be stored in localStorage.
For better security, you can use cookie, with the httpOnly attribute and the SameSite attribute.

APIs

  • createToken: issues an access token and a refresh token.
  • ping: returns true if an access token is verified otherwise returns 401 error.
  • refreshToken: returns an access token if a refresh token is verified otherwise returns 401 error

DTOs

import { ObjectType, Field } from '@nestjs/graphql';

@ObjectType()
export class CreateTokenResponse {
  @Field()
  accessToken: string;

  @Field()
  refreshToken: string;
}

@ObjectType()
export class RefreshTokenResponse {
  @Field()
  accessToken: string;
}

Enter fullscreen mode Exit fullscreen mode

Resolvers

@Resolver()
export class AuthResolver {
  constructor(private readonly authService: AuthService) {}

  @Mutation(() => CreateTokenResponse)
  async createToken(): Promise<CreateTokenResponse> {
    return this.authService.createToken();
  }

  @UseGuards(JwtAuthGuard)
  @Query(() => Boolean)
  async ping() {
    return true;
  }

  @UseGuards(JwtRefreshAuthGuard)
  @Mutation(() => RefreshTokenResponse)
  async refreshToken(): Promise<RefreshTokenResponse> {
    return this.authService.refreshToken();
  }
}
Enter fullscreen mode Exit fullscreen mode

Scenario

Explanation of the scenario

In this scenario, there are six steps.

  1. Request createToken and get an access token and a refresh token from the server
  2. Request pass with an expired access token and get 401 error
  3. Request refreshToken
  4. Get a new access token
  5. Retry the failed request
  6. Success!

For the scenario, I set the expiration time of the access token to be 5s.


React Apollo Client

Types and Queries


/**
 * Types
 */
interface Tokens {
  accessToken: string;
  refreshToken: string;
}

interface AccessToken {
  accessToken: string;
}

/**
 * Queries
 */
const CREATE_TOKEN = gql`
  mutation createToken {
    createToken {
      accessToken
      refreshToken
    }
  }
`;

const REFRESH_TOKEN = gql`
  mutation refreshToken {
    refreshToken {
      accessToken
    }
  }
`;

const PING = gql`
  query ping {
    ping
  }
`;
Enter fullscreen mode Exit fullscreen mode

Page


/**
 * React Components
 */

function App() {
  const [createToken, { data: createTokenData }] = useMutation<{
    createToken: Tokens;
  }>(CREATE_TOKEN);
  const [ping] = useLazyQuery(PING, {
    fetchPolicy: 'network-only',
  });

  const requestToken = () => {
    createToken();
  };

  const sendPing = () => {
    ping();
  };

  useEffect(() => {
    if (!createTokenData) return;

    const { accessToken, refreshToken } = createTokenData.createToken;

    // Save tokens in localStorage
    localStorage.setItem('accessToken', accessToken);
    localStorage.setItem('refreshToken', refreshToken);
  }, [createTokenData]);

  return (
    <Container>
      <button type="button" onClick={requestToken}>
        login
      </button>
      <button type="button" onClick={sendPing}>
        ping
      </button>
    </Container>
  );
}

function ApolloWrapper() {
  return (
    <ApolloProvider client={client}>
      <App />
    </ApolloProvider>
  );
}

/**
 * Styles
 */

const Container = styled.div`
  display: flex;
  flex-direction: column;
  row-gap: 12px;
  padding: 24px;

  > button {
    width: 200px;
    height: 24px;
  }
`;

export default ApolloWrapper;
Enter fullscreen mode Exit fullscreen mode

There are two buttons. one is for createToken and another one is for pass.

Requesting refreshToken and retrying failed request


/**
 * Apollo Setup
 */

function isRefreshRequest(operation: GraphQLRequest) {
  return operation.operationName === 'refreshToken';
}

// Returns accesstoken if opoeration is not a refresh token request
function returnTokenDependingOnOperation(operation: GraphQLRequest) {
  if (isRefreshRequest(operation))
    return localStorage.getItem('refreshToken') || '';
  else return localStorage.getItem('accessToken') || '';
}

const httpLink = createHttpLink({
  uri: 'http://localhost:3000/graphql',
});

const authLink = setContext((operation, { headers }) => {
  let token = returnTokenDependingOnOperation(operation);

  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    },
  };
});

const errorLink = onError(
  ({ graphQLErrors, networkError, operation, forward }) => {
    if (graphQLErrors) {
      for (let err of graphQLErrors) {
        switch (err.extensions.code) {
          case 'UNAUTHENTICATED':
            // ignore 401 error for a refresh request
            if (operation.operationName === 'refreshToken') return;

            const observable = new Observable<FetchResult<Record<string, any>>>(
              (observer) => {
                // used an annonymous function for using an async function
                (async () => {
                  try {
                    const accessToken = await refreshToken();

                    if (!accessToken) {
                      throw new GraphQLError('Empty AccessToken');
                    }

                    // Retry the failed request
                    const subscriber = {
                      next: observer.next.bind(observer),
                      error: observer.error.bind(observer),
                      complete: observer.complete.bind(observer),
                    };

                    forward(operation).subscribe(subscriber);
                  } catch (err) {
                    observer.error(err);
                  }
                })();
              }
            );

            return observable;
        }
      }
    }

    if (networkError) console.log(`[Network error]: ${networkError}`);
  }
);

const client = new ApolloClient({
  link: ApolloLink.from([errorLink, authLink, httpLink]),
  cache: new InMemoryCache(),
});

// Request a refresh token to then stores and returns the accessToken.
const refreshToken = async () => {
  try {
    const refreshResolverResponse = await client.mutate<{
      refreshToken: AccessToken;
    }>({
      mutation: REFRESH_TOKEN,
    });

    const accessToken = refreshResolverResponse.data?.refreshToken.accessToken;
    localStorage.setItem('accessToken', accessToken || '');
    return accessToken;
  } catch (err) {
    localStorage.clear();
    throw err;
  }
};
Enter fullscreen mode Exit fullscreen mode

It distinguishes if a request is for refreshToken or not through operation.operationName.
The point is that you can implement the retrying request logic in onError with Observable.
Return an Observable object in onError then in the function, get a new access token and retry a request using forward
Make sure the order of links is right as you want.


You can see the result as a gif image and code in this repository.

That's it, I hope it'll be helpful for someone.

Happy Coding!

Discussion (0)