aws-mobile-appsync-sdk-js icon indicating copy to clipboard operation
aws-mobile-appsync-sdk-js copied to clipboard

How to use createAuthLink with setContext to allow multiple auth clients

Open VicFrolov opened this issue 4 years ago • 14 comments

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

VicFrolov avatar Mar 04 '20 00:03 VicFrolov

@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 avatar Apr 15 '20 18:04 elorzafe

@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.

VicFrolov avatar Apr 16 '20 05:04 VicFrolov

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.

nicokruger avatar Apr 17 '20 05:04 nicokruger

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

nicokruger avatar Apr 19 '20 06:04 nicokruger

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;

hmelenok avatar Apr 29 '20 15:04 hmelenok

@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.

VicFrolov avatar May 03 '20 03:05 VicFrolov

@hmelenok you are using AWSAppSyncClient, not ApolloClient

VicFrolov avatar May 03 '20 03:05 VicFrolov

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 avatar Jul 13 '20 05:07 patspam

@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 avatar Mar 30 '21 03:03 spencergrimes

@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.

ramicaza avatar May 01 '21 21:05 ramicaza

I have found a "solution" that's simple and seems to work. It might be a good idea to cache the jwtToken somewhere instead of using Auth.currentSession() from aws on every request.

Ideas for improvements are much appreciated

import {
    ApolloClient,
    InMemoryCache,
    HttpLink,
    ApolloLink,
} from "@apollo/client";
import { setContext } from 'apollo-link-context';
import { Auth } from 'aws-amplify';
import { AuthOptions, AUTH_TYPE, createAuthLink } from 'aws-appsync-auth-link';
import { createSubscriptionHandshakeLink } from 'aws-appsync-subscription-link';

import appSyncConfig from "./aws-exports";

const url = appSyncConfig.aws_appsync_graphqlEndpoint;

const region = appSyncConfig.aws_appsync_region;

const auth: AuthOptions = {
    type: AUTH_TYPE.API_KEY,
    apiKey: appSyncConfig.aws_appsync_apiKey,
};

const httpLink = new HttpLink({ uri: url });

// Hack for removing x-api-key (api key authentication) and adding jwt token (cognito oAuth user)
// as the Authorization header won't be recognised by the aws api if the x-api-key is present
const authLink = setContext(() => new Promise((resolve) => {
    Auth.currentSession()
        .then(session => {
            const token = session.getIdToken().getJwtToken();

            // Resolve with jwt token in header
            resolve({
                headers: { Authorization: token, 'x-api-key': '' }
            });
        }).catch(() => {
            // Resolve with default api key
            resolve({})
        })
}));

const link = ApolloLink.from([
    (authLink as unknown) as ApolloLink,
    createAuthLink({ url, region, auth }),
    createSubscriptionHandshakeLink({ url, region, auth }, httpLink),
]);

const client = new ApolloClient({
    link,
    cache: new InMemoryCache(),
});

export default client;

It took me quite a while to get working, so I hope it can help someone else :)

emolr avatar Aug 05 '22 04:08 emolr

Hi folks! Do you know if it's possible to set the systemClockOffset to address clock skew issues? I can't seem to find a way to pass one to the auth middleware to allow some of my clients with skewed clocks to still use our system.

AlessioVallero avatar Aug 06 '22 17:08 AlessioVallero

@emolr thank you soooooo soooo much for your solution 🙏

shawngustaw avatar Aug 04 '23 21:08 shawngustaw

@emolr thank you soooooo soooo much for your solution 🙏

You're very welcome. I'm glad it was helpful

emolr avatar Aug 04 '23 23:08 emolr