How to set up Auth with Metamask using Amazon Cognito (part 2)

jamesmillerblog

James Miller

Posted on June 24, 2023

How to set up Auth with Metamask using Amazon Cognito (part 2)

Code samples that demonstrate how to set up Amazon Cognito Auth with Metamask in a DApp.

Intro

In my previous blog post, I talked through the theory of how to implement web3 log in to a DApp using MetaMask and AWS, the below gif demonstrates the end result.

In this post I’ll talk through some code samples that enable this Auth process, step-by-step!

Important note

The below code samples are experiments I make in my spare time and are not recommendations of how to implement a production web app. If you choose to use these examples in your code base, be sure to test thoroughly and audit as appropriate.

Code samples that enable Web3 Auth with MetaMask and AWS

I’ll be referring to this diagram that I created in my previous blog post, in order to understand the below code samples its important you read it before continuing with the rest of this blog post.

In this section I’ll show the code behind each of the numbered items in the above diagram.

Here is the full repo, use this url if you want to get a version of it running.

Generating Credentials with the Metamask Wallet

1. Get account address

The first step is to retrieve the users public address for their account.

A wallet has a number of addresses returned in an array, the active account is the first item in the array, so we return this as the address to log in with.


const accounts = await window.ethereum.request({
    method: "eth_requestAccounts",
});

const address = accounts[0];
Enter fullscreen mode Exit fullscreen mode

2. Get / create nonce

Once the users account address has been retrieved, we then need to get a nonce (random string of characters) that we can then use for the user to sign and prove they are the owner of the account that created the signature.

To do this, we pass the address into a function on the Front End, that then passes POST’s this address to the Back End.

Once the Back End receives this address, check if a nonce already exists (if it doesn’t it will generate one), then return that nonce back to the Front End.

Front End

const { nonce } = await getUserNonce(address);

const getUserNonce = async (address) =>
  await axios({
    method: "post",
    url: `${httpApiURL}/signup/nonce`,
    data: {
      address: address,
    },
    headers: {
      "Content-Type": "application/json",
    },
  }).then(
    (res) => {
      const data = JSON.parse(res.data.body);
      return data;
    },
    (error) => {
      console.log(error);
    }
);
Enter fullscreen mode Exit fullscreen mode

Back End

"use strict";
const AWS = require("aws-sdk");
const dynamoDb = new AWS.DynamoDB.DocumentClient();
const crypto = require("crypto");

const { nonce_table_id } = process.env;

module.exports.handler = async (event, context, callback) => {
  const response = {
    statusCode: 200,
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Credentials": true,
    },
    body: JSON.stringify(await data(event.body.address)),
  };
  callback(null, response);
};

const data = async (address) => {
  const nonce = crypto.randomBytes(16).toString("hex");
  const putParams = {
    Item: {
      address: address,
      nonce: nonce,
    },
    TableName: nonce_table_id,
  };

  if (!await getData(address)) {
    await dynamoDb.put(putParams).promise();
  }
  return await getData(address);
};

const getData = async (address) => {
  const getParams = {
    TableName: nonce_table_id,
  };
  const users = await dynamoDb.scan(getParams).promise();
  let chosenUser;
  for (let x = 0; x < users.Items.length; x++) {
    if (users.Items[x].address == address) {
      chosenUser = users.Items[x];
    }
  }
  return chosenUser;
};
Enter fullscreen mode Exit fullscreen mode

3. Sign message with nonce & private key

Back on the Front End, we call a function called web3.eth.person.sign() — and pass the nonce and the users address into it, this will trigger a Metamask pop up to appear with these parameters encrypted.


const signature = await web3.eth.personal.sign(
    web3.utils.sha3(`Welcome message, nonce: ${nonce}`),
    address
);
Enter fullscreen mode Exit fullscreen mode

This Metamask pop up (see below) will have a ‘Sign’ button, which when clicked will cryptographically generate a signature using your private key and the props that you passed into the web3.eth.personal.sign function.

Please note, the private key itself is not actually seen at any point during this process and is not visible in the codebase. In clicking the ‘Sign’ button, you are allowing Metamask to confirm that you own the private key that is associated with the public address you’re signing, this enables the creation of a cryptographically valid signature.

4. Log in with signature & public address

Now you have a valid signature, you pass this to the Back End, along with the nonce and your public address via a POST method.


const {
    AccessKeyId, SecretKey, SessionToken, Expiration
} = await login(nonce, signature, address);

const login = (nonce, signature, address) =>
  axios({
    method: "post",
    url: `${httpApiURL}/signup/login`,
    data: {
      nonce: nonce,
      signature: signature,
      address: address,
    },
    headers: {
      "Content-Type": "application/json",
    },
  }).then(
    (res) => {
      const data = JSON.parse(res.data.body);
      return data;
    },
    (error) => {
      console.log(error);
    }
  );
Enter fullscreen mode Exit fullscreen mode

5. Validate signature

Once the Back End receives your signature, nonce and address — it can then start the process of validating the signature.

You do this by manually generating a cryptographic hash that contains your nonce, then passing that into the web3.eth.accounts.recover function along with the signature you generated in the previous step, all going well this will then return the public address of the account that signed the transaction.

If this returned address matches the address that you sent from the Front End to the Back End, then we can consider the public address of the account that was received by the Back End to be sent with a valid signature and it can now have AWS credentials generated for it.


const sigValidated = await validateSig(nonce, signature, address);

const validateSig = async (nonce, signature, address) => {
    const message = `Welcome message, nonce: ${nonce}`;
    const hash = web3.utils.sha3(message);
    const signing_address = await web3.eth.accounts.recover(hash, signature);
    return signing_address.toLowerCase() === address.toLowerCase();
};
Enter fullscreen mode Exit fullscreen mode

Before we go any further, since we have validated a user with this nonce — it has served its purpose and we will now generate a new nonce in the database for that user.

await updateNonce(address, nonce);

const updateNonce = async (address, oldNonce) => {
    const nonce = crypto.randomBytes(16).toString("hex");

    const putParams = {
      Item: {
        address: address,
        nonce: nonce,
      },
      TableName: process.env.nonce_table_id,
    };

    const setData = () => dynamoDb.put(putParams).promise();
    await module.exports.deleteData(address, oldNonce);
    return await setData();
};
Enter fullscreen mode Exit fullscreen mode

6. Generate / retrieve Cognito Identity

Since we have validated a genuine user, we now want to generate access credentials for that user.

We do this by retrieving that public address’s Amazon Cognito Identity, if one does not exist then we will generate one for them.


const { IdentityId: identityId, Token: token } = await getIdToken(address);

const getIdToken = (address) => {
    const param = {
      IdentityPoolId: process.env.cognito_identity_pool_id,
      Logins: {},
    };
    param.Logins[process.env.domain_name] = address;
    return cognitoidentity.getOpenIdTokenForDeveloperIdentity(param).promise();
};
Enter fullscreen mode Exit fullscreen mode

7. Generate Cognito Identity credentials

Using that Amazon Cognito Identity, we will now using AWS Security Token Service (STS) to generate a temporary set of Identity Access Management (IAM) credentials and then return these to the Front End.

Important to note, is you must ensure that the permissions of these temporarily generated credentials are limited in scope to your usecase.


const { Credentials: credentials } = await getCredentials(
      identityId,
      token
);

const getCredentials = (identityId, cognitoOpenIdToken) => {
    const params = {
      IdentityId: identityId,
      Logins: {},
    };
    params.Logins["cognito-identity.amazonaws.com"] = cognitoOpenIdToken;
    return cognitoidentity.getCredentialsForIdentity(params).promise();
}
Enter fullscreen mode Exit fullscreen mode

Using those credentials for Front End and Back End Auth

Now that we’ve generated credentials for a valid user and returned them to the Front End — we can now grant that user access to our DApp and to our Back End.

Front End

Once the Front End receives the credentials from the login process, it will then store these credentials in local storage and generate an AWS Client.

This AWS Client is then used to make a request to the Back End, with a signed url that is generated with the temporary AWS IAM credentials.

All going well, the result of this API call is logged in the browsers’ console.

const {
    AccessKeyId, SecretKey, SessionToken, Expiration
} = await login(nonce, signature, address);

if (AccessKeyId && SecretKey && SessionToken && Expiration) {
    const authenticatedUser = authenticateUser(
      address,
      AccessKeyId,
      SecretKey,
      SessionToken,
      Expiration
    );
    const request = await authenticatedUser.sign(`${httpApiURL}/signup/testData`, {
        method: "GET",
    });
    const response = await fetch(request);
    console.log(await response.json());
}

const authenticateUser = (
  address,
  accessKeyId,
  secretAccessKey,
  sessionToken,
  expiration
) => {
  localStorage.setItem("address", JSON.stringify(address));
  localStorage.setItem("sessionToken", JSON.stringify(sessionToken));
  localStorage.setItem("accessKeyId", JSON.stringify(accessKeyId));
  localStorage.setItem("secretAccessKey", JSON.stringify(secretAccessKey));
  localStorage.setItem("expiration", JSON.stringify(expiration));

  return new AwsClient({
    accessKeyId,
    secretAccessKey,
    sessionToken,
    region: process.env.region,
    service: "execute-api",
  });
};
Enter fullscreen mode Exit fullscreen mode

Back End

From a Back End perspective, I will demonstrate how this works with Lambda functions in Serverless Framework.

The yaml file that is used to configure the lambda function is set to be an http endpoint, expecting IAM credentials as an authorizer.

The lambda function itself just returns a simple string (assuming the credentials used in generating the signed url are correct).

// lambda.yml

testData:
  handler: testData/index.handler
events:
  - http:
      path: signup/testData
      method: get
      authorizer: aws_iam
      cors: true

// index.js

"use strict";

const headers = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Credentials": true,
};
module.exports.handler = async (event) => {
  return {
    headers,
    statusCode: 200,
    body: JSON.stringify(
      "Hello!! This is test data that you are pulling from the Back End, based on your authenticated wallet!"
    ),
  };
};
Enter fullscreen mode Exit fullscreen mode

Conclusion

SO!! Those are the practical steps to enabling Web3 Auth using Metamask and AWS!

There was a lot in there, I hope it all made sense and was helpful!!

If you want to have a play with a template that has all of this working ‘out-of-the-box’, here is a link for you to explore.

In the meantime, have fun :D


💖 💪 🙅 🚩
jamesmillerblog
James Miller

Posted on June 24, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related