guides
Guide

How to Integrate Magic’s Passwordless Authentication With AWS Amplify

Magic Staff · December 27, 2021
info

This guide is a guest post by Tomoaki Imai, CTO at Knot, Inc, as part of our Guest Author program.

AWS Amplify is a fully managed framework that lets developers quickly build full-stack web applications on AWS. It has built-in authentication support, which uses Amazon Cognito under the hood. Because Amplify meant to use its tools as much as possible, you need to make a few customizations to your codebase if you want to introduce passwordless authentication.

In this article, I will share a way to integrate Magic into your Amplify web application if you want to jump into the code first, head over to this link.

https://github.com/tomoima525/magic-amplify-authentication

#Overall architecture

Here's the overall authentication flow in a nutshell. Let me briefly explain.

  • User inputs an email address and requests credentials from Magic through Email
  • Using didtoken and issuer id received as a callback, the client application calls Lambda function through API for Authentication
  • Backend Lambda function accesses Cognito Federated Identity and returns OpenID token(Token and Identity) to the client-side
  • The client application then signup with OpenID. Cognito Federated Identity authorize users to access AWS services.

Also, note that the Amplify session expires every hour; Amplify refreshes the token when using the default authentication. With a custom federated Provider, you need to update that token every time it expires(see this document and this issue). Before I explain each implementation, let's make sure we understand the concept and tools of the Cognito authentication process.

#Amazon Cognito

Amazon Cognito is a tool that provides authentication(Sign-in, Sign-up) and authorization to access AWS services. It has two main functionalities:

  • Cognito User Pools A complete set of user directory services to handle user registration, authentication, and account recovery.
  • Cognito Federated Identity Pools A tool to authorize your users to use AWS services. It provides access tokens to control accesses for authenticated users.

By default, Amplify uses both functionalities. However, when we implement passwordless authentication, we can not use Cognito User Pools as it only supports a traditional email/password or Social Provider like Google or Facebook. Then how can we authenticate our users?

The answer is : Developer authenticated identity.

#Developer authenticated identities

Developer authenticated identity can integrate a custom identity provider into your authentication process and still allow Cognito to manage user data and access AWS services. Developer authenticated identity retrieves OpenID Connect token using identity provided from the third-party.

So in short, we can authenticate users and obtain access tokens from Developer authenticated identity by using Magic as an identity provider!

If you want to know more about Developer authenticated identity and OpenID Connect, take a look at these documents below.

#Backend implementation

Now let's dive into the actual implementation. We assume that you have setup Amplify in your project and generated Api key on Magic. We will go through these steps:

  • Step 1 - Setup Amplify Auth
  • Step 2 - Setup Cognito Federated Identity
  • Step 3 - Implement Lambda function for retrieving OpenID Connect token
  • Step 4 - Add API Gateway

#Step 1 - Setup Amplify Auth

We are starting by implementing the authentication service using Amplify.

$ amplify add auth
 Do you want to use the default authentication and security configuration? Manual configuration
 Select the authentication/authorization services that you want to use: User Sign-Up, Sign-In, connected with AWS IAM controls (Enables per-user Storage features for images or other content, Analy
tics, and more)
 Provide a friendly name for your resource that will be used to label this category in the project: magicAuth
 Enter a name for your identity pool. magic33015299_identitypool_33015299
 Allow unauthenticated logins? (Provides scoped down permissions that you can control via AWS IAM) Yes
 Do you want to enable 3rd party authentication providers in your identity pool? No
 Provide a name for your user pool: magic33015299_userpool_33015299
 Warning: you will not be able to edit these selections.
 How do you want users to be able to sign in? Email
 Do you want to add User Pool Groups? No
 Do you want to add an admin queries API? No
 Multifactor authentication (MFA) user login options: OFF
 Email based user registration/forgot password: Enabled (Requires per-user email entry at registration)
 Specify an email verification subject: Your verification code
 Specify an email verification message: Your verification code is {####}
 Do you want to override the default password policy for this User Pool? No
 Warning: you will not be able to edit these selections.
 What attributes are required for signing up? Email
 Specify the app's refresh token expiration period (in days): 30
 Do you want to specify the user attributes this app can read and write? No
 Do you want to enable any of the following capabilities?
 Do you want to use an OAuth flow? No
 Do you want to configure Lambda Triggers for Cognito? No
✅ Successfully added auth resource magicAuth locally

Here we setup User Pool and some sign in details as Amplify requires it regardless. Then push this setup with amplify push --y.

#Step 2 - Setup Cognito Federated Identity

We will add our custom authentication provider into Cognito Federated Identity. Open AWS website and go to Amazon Cognito Console and click Federated Identities section.

Press "Edit identity pool", then find Authentication providers and add your identifier name in Custom tab. Make sure to save changes. Identifier can be anything but should be unique. We'll be using this later.

#Step 3 - Implement Lambda function for retrieving OpenID Connect token

Now let's create a lambda function in which we check the validity of did generated by Magic and obtain OpenId Connect Token.

$ amplify add function
? Select which capability you want to add: Lambda function (serverless function)
? Provide an AWS Lambda function name: magicAuthentication
? Choose the runtime that you want to use: NodeJS
? Choose the function template that you want to use: Hello World

Available advanced settings:
- Resource access permissions
- Scheduled recurring invocation
- Lambda layers configuration
- Environment variables configuration
- Secret values configuration

? Do you want to configure advanced settings? Yes
? Do you want to access other resources in this project from your Lambda function? No
? Do you want to invoke this function on a recurring schedule? No
? Do you want to enable Lambda layers for this function? No
? Do you want to configure environment variables for this function? No
? Do you want to configure secret values this function can access? Yes
? Enter a secret name (this is the key used to look up the secret value): MAGIC_PUB_KEY
? Enter the value for MAGIC_PUB_KEY: [hidden]
? What do you want to do? I'm done
Use the AWS SSM GetParameter API to retrieve secrets in your Lambda function.
More information can be found here: <https://docs.aws.amazon.com/systems-manager/latest/APIReference/API_GetParameter.html>
? Do you want to edit the local lambda function now? Yes
Edit the file in your editor: /Users/tomoima525/workspace/aws/amplify/magic-test/amplify/backend/function/magicAuthentication/src/index.js
? Press enter to continue
Successfully added resource magicAuthentication locally.

MAGIC_PUB_KEY is the key you find in Magic Console.

Now let's write some code. First, install @magic-sdk/admin which we use for the did validation. Run the command below at amplify/backend/function/magicAuthentication/src/.

yarn add @magic-sdk/admin

Here's the code we need.

const AWS = require("aws-sdk");
const { Magic } = require("@magic-sdk/admin");
const cognitoidentity = new AWS.CognitoIdentity({ apiVersion: "2014-06-30" });

const getSecret = async () => {
  return new AWS.SSM()
    .getParameters({
      Names: ["MAGIC_PUB_KEY"].map((secretName) => process.env[secretName]),
      WithDecryption: true,
    })
    .promise();
};

exports.handler = async (event) => {
  const { Parameters } = await getSecret();
  const magic = new Magic(Parameters[0].Value);

  const { didToken, issuer } = JSON.parse(event.body);
  try {
    // Validate didToken sent from the client
    magic.token.validate(didToken);

    const param = {
      IdentityPoolId: process.env.IDENTITY_POOL_ID,
      Logins: {
        // The identifier name you set at Step 2
        [`com.magic.link`]: issuer,
      },
      TokenDuration: 3600, // expiration time of connected id token
    };

    // Retrieve OpenID Connect Token
    const result = await cognitoidentity
      .getOpenIdTokenForDeveloperIdentity(param)
      .promise();

    const response = {
      statusCode: 200,
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers": "*",
        "Access-Control-Allow-Methods":
          "DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT",
      },
      body: JSON.stringify(result),
    };
    return response;
  } catch (error) {
    const response = {
      statusCode: 500,
      body: JSON.stringify(error.message),
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Headers": "*",
        "Access-Control-Allow-Methods":
          "DELETE,GET,HEAD,OPTIONS,PATCH,POST,PUT",
      },
    };
    return response;
  }
};

You need to make a few tweaks in order to make this Lambda function work. First, to use CognitoIdentity from Lambda function, you need to grant access permission in the cloudforamtion file. Add below under PolicyDocument section of your lambdaexecutionpolicy in cloud-formation-template.yml

"PolicyDocument": {
  "Version": "2012-10-17",
  "Statement": [
    {
      ...
    },
    // Add this
    {
      "Effect": "Allow",
      "Action": [
        "cognito-identity:GetOpenIdTokenForDeveloperIdentity"
      ],
      "Resource": {
        "Fn::Sub": [
          "arn:aws:cognito-identity:${region}:${account}:identitypool/${region}:*",
          {
            "region": {
              "Ref": "AWS::Region"
            },
            "account": {
              "Ref": "AWS::AccountId"
            }
          }
        ]
      }
    }
  ]
}

Second, you need to pass IDENTITY_POOL_ID as an environment variable. It is a little bit tricky so let's go through this carefully.

Open team-provider-info.yml and add identityPoolId which you can find in Cognito Web console(Idenity pool).

"categories": {
      "function": {
        "magicAuth": {
          "identityPoolId": "us-west-2:2477d5cd-cxxxxx", // <- Add this
          "secretsPathAmplifyAppId": "xxdfsd",
          "deploymentBucketName": "amplify-magictest-dev-233202-deployment",
          "s3Key": "amplify-builds/magicAuth-7738665a44657a304142-build.zip"
        },

Then add these to cloud-formation-template.yml as well.

{
  "AWSTemplateFormatVersion": "2010-09-09",
  "Parameters": {
    ...
    // Add this
    "identityPoolId": {
      "Type": "String"
    }
  },
  "Resources": {
    "LambdaFunction": {
      ...
      "Properties": {
        "Environment": {
          "Variables": {
            ...
            // Add this
            "IDENTITY_POOL_ID": {
              "Ref": "identityPoolId"
            }
          }
        },
      }
    },
    ...
  }
}

Finally amplify push --y to deploy updates on Cloud.

#Step 4 - Add API Gateway

Now you need to add API Gateway to access this Lambda function.

$ amplify add api
? Select from one of the below mentioned services: REST
✔ Provide a friendly name for your resource to be used as a label for this category in the project: · magicRestApi
✔ Provide a path (e.g., /book/{isbn}): · /auth
✔ Choose a Lambda source · Use a Lambda function already added in the current Amplify project
Only one option for [Choose the Lambda function to invoke by this path]. Selecting [magicAuthFunction].
✔ Restrict API access? (Y/n) · no
✔ Do you want to add another path? (y/N) · no
✅ Successfully added resource magicRestApi locally

Make sure that you don't restrict the access as users will need to access this API before authentication.

#Frontend implementation

Let's move on to Frontend implementation. This time we use React for Frontend.

  • Step 1 - Check user session
  • Step 2 - Sign up flow
  • Step 3 - Receiving callback and authenticate
  • Step 4 - Token refresh logic

#Step 1 - Check user session

We will first check the user session. At the entry point of the app, add below

useEffect(() => {
    setUser({ loading: true });
    Auth.currentUserCredentials()
      .catch((e) => {
        console.log("=== currentcredentials", { e });
      });
    Auth.currentAuthenticatedUser()
      .then((user) => {
        magic.user
          .isLoggedIn()
          .then((isLoggedIn) => {
            return isLoggedIn
              ? magic.user
                  .getMetadata()
                  .then((userData) =>
                    setUser({ ...userData, identityId: user.id })
                  )
              : setUser({ user: null });
          })
          .catch((e) => {
            console.log("currentUser", { e });
          });
      })
      .catch((e) => {
        setUser({ user: null });
      });
  }, []);

Auth.currentUserCredentials() triggers token refresh(which we will implement in Step4). Auth.currentAuthenticatedUser() checks user info which is stored in Cognito. Then we also check if Magic authenticates this user.

#Step 2 - Add Sign up flow

If a user has not been signed up, we will redirect this user to Login screen. When a user posts their email address, we'll send them the email through Magic.

async function handleLoginWithEmail(email) {
    try {
      // Prevent login state inconsistency between Magic and the client side
      await magic.user.logout();
      // Trigger Magic link to be sent to user
      await magic.auth.loginWithMagicLink({
        email,
        redirectURI: new URL("/callback", window.location.origin).href, // optional redirect back to your app after magic link is clicked
      });
    } catch (error) {
      console.log(error);
    }
  }

This function triggers the view below(A modal screen from Magic)

#Step 3 - Receiving callback and authenticate

Callback screen is where all the authentication processes happen. You might notice that we set 1 hour ahead for expire_at. This can be any number larger than 1 hour as the token expires in 1 hour anyway.

const authenticateWithServer = async (didToken) => {
    let userMetadata = await magic.user.getMetadata();
    // Get Token and IdentityId from Cognito
    const res = await API.post(
      awsconfig.aws_cloud_logic_custom[0].name,
      "/auth",
      {
        body: {
          didToken,
          issuer: userMetadata.issuer,
        },
      }
    );

    // Federated Sign in using OpenId Token
    const credentials = await Auth.federatedSignIn(
      "developer",
      {
        identity_id: res.IdentityId,
        token: res.Token,
        expires_at: 3600 * 1000 + new Date().getTime(),
      },
      user
    );
    if (credentials) {
      // Set the UserContext to the now logged in user
      let userMetadata = await magic.user.getMetadata();
      await setUser({ ...userMetadata, identityId: credentials.identityId });
      history.push("/profile");
    }
  };

Now this user is authenticated and has access to private API and AWS resources. You will find that this user is authenticated on Cognito Dashboard.

#Step 4 - Add token update

One last step, we will add refresh token logic. As we mentioned, Amplify will not automatically refresh your token when you use your custom authentication. Fortunately you can configure Amplify to update manually.

Go to index.js (the entry point of your application) and add below.

async function refreshToken() {
  const didToken = await magic.user.getIdToken();
  const userMetadata = await magic.user.getMetadata();
  const body = JSON.stringify({
    didToken,
    issuer: userMetadata.issuer,
  });
  const res = await fetch(
    `${awsconfig.aws_cloud_logic_custom[0].endpoint}/auth`,
    {
      method: "POST",
      body,
    }
  );
  const json = await res.json();
  return {
    identity_id: json.IdentityId,
    token: json.Token,
    expires_at: 3600 * 1000 + new Date().getTime(),
  };
}

Auth.configure({
  refreshHandlers: {
    developer: refreshToken,
  },
});

We are using fetch as we just need a simple request to fetch the refreshed token and the identity. This function will refresh the token whenever 1 hour passes after the previous token update.

That's all there is to it!

#Things to keep in mind

When you consider introducing/migrating to passwordless authentication, you should be aware of a few things.

  • you no longer rely on Cognito User Pool. Your backend becomes simpler but also loses some benefits that Cognito User Pool offers. For example, you can not use group access control.
  • Cognito Federated Identities' custom provider can not be updated once you configured it. Define the identifier name carefully. If you have multiple environments, you should change those names such as com.magic.link.dev for development and com.magic.link.production for production. This way you do not share Identity pools.

#Beyond passwordless authentication: Connecting Web2 and Web3

In this article, I shared how to implement passwordless authentication using Email. But there's more to it! Magic supports various crypto wallet connections. When a wallet is connected, you can obtain a unique address. That means we can basically take the same approach that I wrote if we want to apply the access control to web3 applications. I am not going to share that in this article, but you can definitely try it out using the example repo!

Let's make some magic!