How to set up Auth with Metamask using Amazon Cognito (part 2)
James Miller
Posted on June 24, 2023
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];
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);
}
);
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;
};
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
);
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);
}
);
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();
};
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();
};
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();
};
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();
}
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",
});
};
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!"
),
};
};
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
Posted on June 24, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.