Aws-mobile-appsync-sdk-js: How to use createAuthLink with setContext to allow multiple auth clients

Created on 4 Mar 2020  路  10Comments  路  Source: awslabs/aws-mobile-appsync-sdk-js

Do you want to request a feature or report a bug?
Feature question

What is the current behavior?
After following the README for integration with ApolloClient, I had it working great with 1 client (https://github.com/awslabs/aws-mobile-appsync-sdk-js#using-authorization-and-subscription-links-with-apollo-client-no-offline-support)

I am trying to swap between IAM unauthenticated auth ,and cognito authenticated auth when users sign in/out. I am able to get each working individually, but cannot get both working.

I have tried using setContext, which would return an AuthLink, as well as ApolloLink.from, but it would always return 401. An example would be great!

I think the issue I am stumbling on is with IAM, I am not sure what to put in the header, whereas for cognito auth I can simply do:

    return {
      headers: {
        ...headers,
        Authorization: jwtToken,
      },
    };

Which versions and which environment (browser, react-native, nodejs) / OS are affected by this issue? Did this work in previous versions?
aws-appsync-auth-link: 2.0.1

question pending-close-response-required auth-link

Most helpful comment

ApolloLink is flexible enough to allow you to switch between multiple AuthLink instances created by aws-appsync-auth-link (you can cache them using link context). You can create as many as you need as switch between them based on whether the user is signed in.

Here's my (Apollo v3-beta based) prototype, wrapped up in a useApolloClient react hook to make it easy to consume. It uses Hub events to automatically handle sign in/out events. I'm new to Apollo and haven't written tests or used this in a real app (yet), so YMMV (feedback appreciated).

amplify-auth-link.js

import { ApolloLink } from "@apollo/client"
import { setContext } from "@apollo/link-context"
import { onError } from "@apollo/link-error"
import Auth from "@aws-amplify/auth"
import { Hub } from "@aws-amplify/core"
import {
  AUTH_TYPE,
  createAuthLink as awsCreateAuthLink,
} from "aws-appsync-auth-link"

// To keep things simple, only support a single instance.
let amplifyAuthLink = null
let region
let url

// Create an ApolloLink that uses IAM/Cognito based on sign-in state.
// Uses a cached AuthLink created by aws-appsync-auth-link under the covers.
export const createAuthLink = (appSyncConfig) => {
  region = appSyncConfig.region
  url = appSyncConfig.url
  return cachedAmplifyAuthLink.concat(
    new ApolloLink((operation, forward) =>
      operation.getContext().amplifyAuthLink.request(operation, forward)
    ),
    resetToken
  )
}

// Create an AWS AuthLink that uses Cognito, suitable for signed-in users.
const createCognitoAuthLink = (session) =>
  awsCreateAuthLink({
    auth: {
      jwtToken: session.getIdToken().getJwtToken(),
      type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS,
    },
    region,
    url,
  })

// Create an AWS AuthLink that uses IAM, suitable for non signed-in users.
const createIamAuthLink = () =>
  awsCreateAuthLink({
    auth: {
      credentials: () => Auth.currentCredentials(),
      type: AUTH_TYPE.AWS_IAM,
    },
    region,
    url,
  })

// An ApolloLink that uses context to cache the amplifyAuthLink instance.
const cachedAmplifyAuthLink = setContext(() => {
  if (amplifyAuthLink) {
    return { amplifyAuthLink }
  }

  // Asynchronously initialise and cache amplifyAuthLink.
  return Auth.currentSession()
    .then((session) => {
      amplifyAuthLink = createCognitoAuthLink(session)
      return { amplifyAuthLink }
    })
    .catch((error) => {
      // Amplify throws when not signed in.
      amplifyAuthLink = createIamAuthLink()
      return { amplifyAuthLink }
    })
})

// An ApolloLink that reverrts to using IAM when 401 is encountered.
// TODO: Decide if this is desirable.
const resetToken = onError(({ networkError }) => {
  if (networkError?.name == "ServerError" && networkError?.statusCode == 401) {
    amplifyAuthLink = createIamAuthLink()
  }
})

// Add Hub auth listeners, to detect sign-in/out.
export const addListeners = () => {
  const handleAuthEvents = ({ payload }) => {
    switch (payload.event) {
      case "signIn":
        amplifyAuthLink = createCognitoAuthLink(payload.data.signInUserSession)
        break
      case "signOut":
        amplifyAuthLink = createIamAuthLink()
        break
      case "configured":
      case "signIn_failure":
      case "signUp":
      default:
        break
    }
  }
  Hub.listen("auth", handleAuthEvents)
  return handleAuthEvents
}

// Remove Hub auth listeners.
export const removeListeners = (handler) => Hub.remove("auth", handler)

use-apollo-client.js

import { ApolloClient, HttpLink, InMemoryCache, concat } from "@apollo/client"
import React from "react"

import {
  addListeners,
  createAuthLink,
  removeListeners,
} from "./amplify-auth-link"

const createApolloClient = (appSyncConfig) =>
  new ApolloClient({
    cache: new InMemoryCache(),
    link: concat(
      createAuthLink(appSyncConfig),
      new HttpLink({ uri: appSyncConfig.url })
    ),
  })

export const useApolloClient = (appSyncConfig) => {
  const [client] = React.useState(() => createApolloClient(appSyncConfig))
  React.useEffect(() => {
    const handler = addListeners()
    return () => removeListeners(handler)
  })
  return client
}

Index.js

import React from "react"
import { ApolloProvider } from "@apollo/client"

import { useApolloClient } from "./use-apollo-client"

Amplify.configure({ Auth: {...} })

const appSyncConfig = { region: "us-east-1", url: "..." }

const Index = () => {
  const client = useApolloClient(appSyncConfig)
  return (
    <ApolloProvider client={client}>
      ...
    </ApolloProvider>
  )
}
export default Index

All 10 comments

@VicFrolov

If you use Amplify you can do something like is mentioned here

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

Amplify.configure(awsconfig);

const client = new AWSAppSyncClient({
  url: awsconfig.aws_appsync_graphqlEndpoint,
  region: awsconfig.aws_appsync_region,
  auth: {
    type: AUTH_TYPE.AWS_IAM,
    credentials: () => Auth.currentCredentials(),
  },
});

@elorzafe thanks for the reply, but as mentioned I am looking on accomplishing this with ApolloClient, and am already doing this successfully with 1 auth, the issue is changing the type and credentials dynamically.

if users are not registered, they are authenticated as guests via IAM

  auth: {
    type: AUTH_TYPE.AWS_IAM,
    credentials: () => Auth.currentCredentials(),
  }

After they signup/login, they are authenticated via

auth: {
  jwtToken: async () =>
    Auth.currentSession()
      .then(value => value.getIdToken().getJwtToken())
  type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS,
}

I can get both ways working individually, but I am not able to swap between the two on users logging in and out. Would be great to get a working example in the docs. This is why I mentioned setContext, one way would be to use the header there, however getting the token for IAM users is not trivial with the SDK.

Hi @VicFrolov , I'm working on exactly the same thing today :)

Can you perhaps share what we have around trying to use setContext? Maybe I can also try and get it working.

I can also get both authentication types working independantly - just not sure how to dynamically switch between the two when the user eventually logs in.

Just FYI, I ended up just going with two ApolloClients, and switching between them in my store.

I did achieve it before but with v3 it's not works for appsync sockets:

import AWSAppSyncClient, {createAppSyncLink} from 'aws-appsync';
import {PureQueryOptions} from 'apollo-client';
import {ApolloQueryResult, MutationOptions, QueryOptions} from 'apollo-client';
import {map} from 'lodash';
import '../polyfills/server-fetch';
import {InMemoryCache} from 'apollo-cache-inmemory';
import {setContext} from 'apollo-link-context';
import {ApolloLink} from 'apollo-link';
import {createHttpLink} from 'apollo-link-http';
import {logError, logResponse} from './apolloLogHelpers';
import {getAccessToken, getDefaultQueryParams, getMeteorTokenString} from '../helpers/apiHelper';
import AppSyncConfig from '../../aws.config';

const credentials = {
  url: AppSyncConfig.API_URL,
  region: AppSyncConfig.REGION,
  auth: {
    type: AppSyncConfig.AUTHENTICATION_TYPE,
    apiKey: AppSyncConfig.API_KEY
  }
};
const httpLink = createAppSyncLink({
  ...credentials,
  resultsFetcherLink: ApolloLink.from([
    setContext((_request, previousContext) => ({
      headers: {
        ...previousContext.headers,
        Authorization: getAccessToken() || getMeteorTokenString()
      }
    })),
    createHttpLink({
      uri: AppSyncConfig.API_URL
    })
  ]),
  complexObjectsCredentials: (): null => null
});

export const apolloClient = new AWSAppSyncClient(
  {
    ...credentials,
    disableOffline: true
  },
   {
     link: httpLink,
     cache: new InMemoryCache()
   }
);

export default apolloClient;

@nicokruger thanks for sharing this solution! It's a good hack, but I didn't like the idea of having two different states of cache for one session.

Here is a solution using setContext from Apollo. The extra hoop required to jump is signing IAM tokens with sigv4, and get the necessary headers:

Setting headers in Apollo dynamically, and creating the client

// TODO: cache token
const authLinkWithContext = setContext(async (operation, forward) => {
  let token; // authenticated user
  let unauthenticateddHeader; // unauthenticated (guest) user

  try {
    const session = await Auth.currentSession();
    token = session?.getIdToken()?.getJwtToken();
  } catch (error) {
    // no-op, this catches a thrown error: no current user
  }

  if (!token) {
    try {
      const credentials = await Auth.currentCredentials();
      unauthenticateddHeader = await getHeadersForIamAuth(
        { credentials, region, url },
        operation
      );
    } catch (error) {
      // tslint:disable-next-line
      console.log(error);
    }
  }

  const headers = token
    ? { Authorization: token || '' }
    : { ...unauthenticateddHeader };

  return {
    ...forward,
    headers,
  };
});

export const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: ApolloLink.from([authLinkWithContext, new HttpLink({ uri: url })]),
});

When usingcreateAuthLink, iAM signing is handled by iamBasedAuth. I copied the same logic, but removed any operation,forward and context logic, just headers are needed.

export const getHeadersForIamAuth = async (
  { credentials, region, url },
  operation
) => {
  const service = SERVICE;

  const creds =
    typeof credentials === 'function' ? credentials.call() : credentials || {};

  if (creds && typeof creds.getPromise === 'function') {
    await creds.getPromise();
  }

  const { accessKeyId, secretAccessKey, sessionToken } = await creds;

  const { host, path } = Url.parse(url);

  const formatted = {
    ...formatAsRequest(operation, {}),
    service,
    region,
    url,
    host,
    path,
  };

  const { headers } = Signer.sign(formatted, {
    access_key: accessKeyId,
    secret_key: secretAccessKey,
    session_token: sessionToken,
  });

  return {
    ...headers,
    [USER_AGENT_HEADER]: USER_AGENT,
  };
};

I imported their Signer, formatAsRequest function, and USER_AGENT

Now I can use both cognito and IAM authentication with Apollo.

It would be great if createAuthLink was able to take multiple auth types.

@hmelenok you are using AWSAppSyncClient, not ApolloClient

ApolloLink is flexible enough to allow you to switch between multiple AuthLink instances created by aws-appsync-auth-link (you can cache them using link context). You can create as many as you need as switch between them based on whether the user is signed in.

Here's my (Apollo v3-beta based) prototype, wrapped up in a useApolloClient react hook to make it easy to consume. It uses Hub events to automatically handle sign in/out events. I'm new to Apollo and haven't written tests or used this in a real app (yet), so YMMV (feedback appreciated).

amplify-auth-link.js

import { ApolloLink } from "@apollo/client"
import { setContext } from "@apollo/link-context"
import { onError } from "@apollo/link-error"
import Auth from "@aws-amplify/auth"
import { Hub } from "@aws-amplify/core"
import {
  AUTH_TYPE,
  createAuthLink as awsCreateAuthLink,
} from "aws-appsync-auth-link"

// To keep things simple, only support a single instance.
let amplifyAuthLink = null
let region
let url

// Create an ApolloLink that uses IAM/Cognito based on sign-in state.
// Uses a cached AuthLink created by aws-appsync-auth-link under the covers.
export const createAuthLink = (appSyncConfig) => {
  region = appSyncConfig.region
  url = appSyncConfig.url
  return cachedAmplifyAuthLink.concat(
    new ApolloLink((operation, forward) =>
      operation.getContext().amplifyAuthLink.request(operation, forward)
    ),
    resetToken
  )
}

// Create an AWS AuthLink that uses Cognito, suitable for signed-in users.
const createCognitoAuthLink = (session) =>
  awsCreateAuthLink({
    auth: {
      jwtToken: session.getIdToken().getJwtToken(),
      type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS,
    },
    region,
    url,
  })

// Create an AWS AuthLink that uses IAM, suitable for non signed-in users.
const createIamAuthLink = () =>
  awsCreateAuthLink({
    auth: {
      credentials: () => Auth.currentCredentials(),
      type: AUTH_TYPE.AWS_IAM,
    },
    region,
    url,
  })

// An ApolloLink that uses context to cache the amplifyAuthLink instance.
const cachedAmplifyAuthLink = setContext(() => {
  if (amplifyAuthLink) {
    return { amplifyAuthLink }
  }

  // Asynchronously initialise and cache amplifyAuthLink.
  return Auth.currentSession()
    .then((session) => {
      amplifyAuthLink = createCognitoAuthLink(session)
      return { amplifyAuthLink }
    })
    .catch((error) => {
      // Amplify throws when not signed in.
      amplifyAuthLink = createIamAuthLink()
      return { amplifyAuthLink }
    })
})

// An ApolloLink that reverrts to using IAM when 401 is encountered.
// TODO: Decide if this is desirable.
const resetToken = onError(({ networkError }) => {
  if (networkError?.name == "ServerError" && networkError?.statusCode == 401) {
    amplifyAuthLink = createIamAuthLink()
  }
})

// Add Hub auth listeners, to detect sign-in/out.
export const addListeners = () => {
  const handleAuthEvents = ({ payload }) => {
    switch (payload.event) {
      case "signIn":
        amplifyAuthLink = createCognitoAuthLink(payload.data.signInUserSession)
        break
      case "signOut":
        amplifyAuthLink = createIamAuthLink()
        break
      case "configured":
      case "signIn_failure":
      case "signUp":
      default:
        break
    }
  }
  Hub.listen("auth", handleAuthEvents)
  return handleAuthEvents
}

// Remove Hub auth listeners.
export const removeListeners = (handler) => Hub.remove("auth", handler)

use-apollo-client.js

import { ApolloClient, HttpLink, InMemoryCache, concat } from "@apollo/client"
import React from "react"

import {
  addListeners,
  createAuthLink,
  removeListeners,
} from "./amplify-auth-link"

const createApolloClient = (appSyncConfig) =>
  new ApolloClient({
    cache: new InMemoryCache(),
    link: concat(
      createAuthLink(appSyncConfig),
      new HttpLink({ uri: appSyncConfig.url })
    ),
  })

export const useApolloClient = (appSyncConfig) => {
  const [client] = React.useState(() => createApolloClient(appSyncConfig))
  React.useEffect(() => {
    const handler = addListeners()
    return () => removeListeners(handler)
  })
  return client
}

Index.js

import React from "react"
import { ApolloProvider } from "@apollo/client"

import { useApolloClient } from "./use-apollo-client"

Amplify.configure({ Auth: {...} })

const appSyncConfig = { region: "us-east-1", url: "..." }

const Index = () => {
  const client = useApolloClient(appSyncConfig)
  return (
    <ApolloProvider client={client}>
      ...
    </ApolloProvider>
  )
}
export default Index

@patspam the one thing that is unclear to me in your solution is what the input of Amplify.configure should be in the index.js file. What parameters are you using to initialize the Amplify instance?

Is it using the IAM credentials since that's the fallback, or are you somehow instantiating Amplify configure without indicating an auth type?

@spencergrimes I believe the initial call to Amplify.configure is not even technically needed and all it does is configure @amplify/auth to connect to the right place (in this example a specific cognito user pool). In our app we don't even call this until the user goes through the signin process.

In @patspam's (excellent) example the initial call to Auth.currentSession() will throw an error if Amplify.configure has not been called or if the user is not signed in. This error will be caught and then createIamAuthLink() is called to fallback to the IAM credentials.

When the user signs in, Auth.currentSession no longer throws. In order to sign in you'll have to eventually call Amplify.configure or Auth.configure plus Auth.signIn() in your app. Hope this helps.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

mwarger picture mwarger  路  3Comments

yhenni1989 picture yhenni1989  路  3Comments

peterservisbot picture peterservisbot  路  3Comments

JonathanHolvey picture JonathanHolvey  路  4Comments

ciocan picture ciocan  路  4Comments