Integrating Amazon Cognito Authentication with the Apollo GraphQL Client

In my last post, I described how we can produce a GraphQL service that stores data in a multi-user manner, suitable for a SaaS-type application.  The data is tagged with the user ID while it is being stored, and the user is only allowed to see their own data.  While this provides the appropriate capabilities on the server-side, we still need to deal with the client side.

The “normal” instructions for integrating the Apollo client look like this:

import React from 'react';
import { StackNavigator } from 'react-navigation';
import { navigatorConfig } from './src/screens';

// AWS AppSync and Apollo Client Libraries
import AWSAppSyncClient from 'aws-appsync';
import { Rehydrated } from 'aws-appsync-react';
import { ApolloProvider } from 'react-apollo';
import appSyncConfig from './src/AppSync';

// AWS Amplify Configuration
import Amplify from 'aws-amplify';
import { withAuthenticator } from 'aws-amplify-react-native';
import awsconfig from './src/aws-exports';

Amplify.configure(awsconfig);

const client = new AWSAppSyncClient({
  url: appSyncConfig.graphqlEndpoint,
  region: appSyncConfig.region,
  auth: {
    type: appSyncConfig.authenticationType,
    apiKey: appSyncConfig.apiKey
  }
});

class App extends React.Component {
  constructor(props) {
    super(props);
  }

  render() {
    const Navigator = StackNavigator(navigatorConfig);

    return (
      <ApolloProvider client={client}>
        <Rehydrated>
          <Navigator/>
        </Rehydrated>
      </ApolloProvider>
    );
  }
};

export default withAuthenticator(App);

This is the top-level component in the React Native app. The client is instantiated with an API Key and then used in the Apollo Client to broker connections to AWS Appsync. In addition, the app is being authenticated with Amazon Cognito via the AWS Amplify library.

However, we are no longer using an API Key. Instead, we need to submit the JWT Token that comes back from Amazon Cognito. This is a part of the signInUserSession available via the Amplify Authenticator. Thus, we can add the following to the render() method of our component:

    const signInUserSession = this.props.authData.signInUserSession;
    let accessToken = null;
    if (signInUserSession) {
      accessToken = signInUserSession.accessToken.jwtToken;
      console.log(`accessToken = ${accessToken}`);
    }

We can now move the client inside the render() method as well. Our render() method becomes the following:

  render() {
    const Navigator = StackNavigator(navigatorConfig);

    const signInUserSession = this.props.authData.signInUserSession;
    let accessToken = null;
    if (signInUserSession) {
      accessToken = signInUserSession.accessToken.jwtToken;
      console.log(`accessToken = ${accessToken}`);
    }

    const client = new AWSAppSyncClient({
      url: appSyncConfig.graphqlEndpoint,
      region: appSyncConfig.region,
      auth: {
        type: appSyncConfig.authenticationType,
        apiKey: appSyncConfig.apiKey,
        jwtToken: accessToken
      }
    });

    return (
      <ApolloProvider client={client}>
        <Rehydrated>
          <Navigator/>
        </Rehydrated>
      </ApolloProvider>
    );
  }

This works because the withAuthenticator() higher-order component only re-renders the main component when the authentication state changes. At this point, the access token changes as well, and the client is refreshed to use the new access token.

The problem with this is that you have to re-render the entire app whenever authentication state changes. This may be a good thing. It may also be a bad thing because you have to pass some sort of event handler to every single component that deals with authentication that changes the state of the top-level component. That, erm, sucks. It may or may not be a performance nightmare, but it is going to be a nightmare coding problem to ensure every component that needs the event handler has it.

Fortunately, there is a better way. Let’s go back to our original code. We can pass the JWT in as a function callback:

import AWSAppSyncClient from 'aws-appsync';
import { Rehydrated } from 'aws-appsync-react';
import { ApolloProvider } from 'react-apollo';
import appSyncConfig from './src/AppSync';

import Amplify, { Auth } from 'aws-amplify';
import { withAuthenticator } from 'aws-amplify-react-native';
import awsconfig from './src/aws-exports';

Amplify.configure(awsconfig);

const client = new AWSAppSyncClient({
  url: appSyncConfig.graphqlEndpoint,
  region: appSyncConfig.region,
  auth: {
    type: appSyncConfig.authenticationType,
    apiKey: appSyncConfig.apiKey,
    jwtToken: async () => (await Auth.currentSession()).getAccessToken().getJwtToken()
  }
});

With this definition, whenever the AppSync client needs to do a call to the backend, it will grab the current authentication token from the Amplify module and use that.

The end result – you can place your AWS Amplify authentication code anywhere and never have to re-render the main application.