How To Build a Secure B2B and B2C Serverless Application with DynamoDB, AWS Lambda API Gateway, and AWS Cognito
Hello 👋🏼,
My name is Andrei, I am a Senior #AWS Architect & Developer Consultant / Contractor / Freelancer.
This post will guide you through creating an AWS-authenticated #serverless solution 😎 by implementing AWS #Cognito + AWS #APIGateway + AWS #Lambda (for the processing layer) + AWS #DyanmoDB (for the data storage layer).
As Christmas 🎄 is just 2 months away, the application we'll build is about the Christmas market event. (tiny houses around the city with Christmas theme products).
________________________
⚠️ Prerequisites:
________________________
🛠 Architecture:
The SAM template is available on the following link but let’s first check the architecture. (I like drawio to "draw" architectures).
For production, I suggest using one API Gateway and Cognito for B2C and another for B2B.
- /v1/b2b/christmas-markets/city/{city}:GET
- /v1/b2b/christmas-markets/{marketId}/user/{userId}:PATCH
- /v1/b2c/christmas-markets/user/{userId}:GET
- /v1/b2c/christmas-markets/user/{userId}:POST
- /v1/b2c/christmas-markets/{marketId}/user/{userId}:PATCH
- /v1/b2c/christmas-markets/{marketId}/user/{userId}:DELETE
2. Cognito user pool with B2C & B2B clients
3. The lambda function will be used to handle all the APIs through a dispatching logic.
4. #IAMRole will be used by the lambda to send logs to #cloudwatch & execute CRUD operations on the DynamoDB Table
5. DynamoDB table, perfect for serverless architectures thanks to its scalability and availability, as long as your data entities are not connected (no JOINs).
Table characteristics:
Partition & Sort Key: userId & marketId
GSI (Global Secondary Index): city
Item structure:
{
userId: string;
marketId: string;
marketName: string;
marketDescription: string;
marketAveragePrice: number;
marketApproved: boolean;
city: string;
}
________________________
🚀 Deploying the architecture:
To successfully deploy the architecture, after you have cloned the repository and installed the AWS CLI & AWS SAM, we need to configure access & secrets keys.
You can create an IAM User with AdministratorAccess managed policy, (only for this guide)
Save the access keys inside the AWS credentials files: ~/.aws/credentials
[default]
aws_access_key_id=<token>
aws_secret_access_key=<token>
If you specify a different value than the default, add the key --profile [profile_name] when executing the deployment command.
If you want to update the architecture names in order to avoid any duplicate errors, check the main.yaml of the cloudformation template
Once you have configured the credentials, open the terminal and run the command to deploy the architecture. Add the “—guided” key the first time you run the template.
sam build -t cloudformation/main.yaml && sam deploy --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND --guided
Recommended by LinkedIn
Congratulations! You have released a completely #serverless architecture!
Time to have a one-minute party! 🥳
________________________
🔀 Application flow:
As specified above, one lambda function handles all the APIs of this feature.
I highly recommend using one lambda for all the CRUD operations of the feature.
As the lambda is connected to the API Gateway as a Proxy Integration, the lambda receives inside the event a lot of information, the most important for our use case:
resource/path
As the application is not the most important part of the guide, I have used plain javascript.
I will post soon a separate guide, with the same architecture but using Typescript instead of plain javascript
________________________
👨🏻💻 Lambda code:
All the processing flow is executed inside the handler.
By using a switch constructor of the resource path & HTTP verb, dynamoDB methods are executed based on the API.
const uuid = require('uuid')
const AWS = require('aws-sdk');
exports.handler = async (event) => {
const dynamoDBName = process.env.DynamoDBTableName;
const dynamoDBClient = new AWS.DynamoDB.DocumentClient({
apiVersion: "2012-08-10"
});
console.info(event);
const transformedEvent = {
resource: event.resource,
httpMethod: event.httpMethod,
pathParameters: event.pathParameters,
body: event.body !== null ? JSON.parse(event.body) : null
}
const api = transformedEvent.resource+':'+transformedEvent.httpMethod;
console.info('api: '+api);
// dispatch logic
switch(api) {
case '/v1/b2b/christmas-markets/city/{city}:GET': {
const params = {
TableName: dynamoDBName,
IndexName: "city",
KeyConditionExpression: "city = :city",
ExpressionAttributeValues: {
':city': transformedEvent.pathParameters.city
}
}
try {
const item = await dynamoDBClient.query(params).promise();
return {
body: JSON.stringify(item.Items),
statusCode: 200
};
} catch (error) {
throw new Error('DynamoDB Processing Failed: '+ JSON.stringify(error));
}
}
case '/v1/b2b/christmas-markets/{marketId}/user/{userId}:PATCH': {
const params = {
TableName: dynamoDBName,
Key: {
userId: transformedEvent.pathParameters.userId,
marketId: transformedEvent.pathParameters.marketId,
},
ReturnConsumedCapacity: 'TOTAL'
}
try {
const response = await dynamoDBClient.get(params).promise();
const item = response.Item;
item.marketApproved = transformedEvent.body.approved;
await dynamoDBClient.put({
TableName: dynamoDBName,
Item: item,
ReturnConsumedCapacity: 'TOTAL'
}).promise();
return {
body: JSON.stringify({message: 'success'}),
statusCode: 200
};
} catch (error) {
throw new Error('DynamoDB Processing Failed: '+ JSON.stringify(error));
}
}
case '/v1/b2c/christmas-markets/user/{userId}:GET': {
const params = {
TableName: dynamoDBName,
KeyConditionExpression: '#userId = :userId',
ExpressionAttributeNames: {
'#userId': 'userId'
},
ExpressionAttributeValues: {
':userId': transformedEvent.pathParameters.userId
},
ReturnConsumedCapacity: 'TOTAL'
}
try {
const item = await dynamoDBClient.query(params).promise();
return {
body: JSON.stringify(item.Items),
statusCode: 200
};
} catch (error) {
throw new Error('DynamoDB Processing Failed: '+ JSON.stringify(error));
}
}
case '/v1/b2c/christmas-markets/user/{userId}:POST': {
const item = {
marketApproved: false,
userId: event.pathParameters.userId,
marketId: uuid.v4(),
marketName: transformedEvent.body.marketName,
marketDescription: transformedEvent.body.marketDescription,
marketAveragePrice: transformedEvent.body.marketAveragePrice,
city: transformedEvent.body.city
}
const params = {
TableName: dynamoDBName,
Item: item,
ReturnConsumedCapacity: 'TOTAL'
}
await dynamoDBClient.put(params).promise();
return {
body: JSON.stringify({message: 'success'}),
statusCode: 200
};
}
case '/v1/b2c/christmas-markets/{marketId}/user/{userId}:PATCH': {
const params = {
TableName: dynamoDBName,
Key: {
userId: transformedEvent.pathParameters.userId,
marketId: transformedEvent.pathParameters.marketId,
},
ReturnConsumedCapacity: 'TOTAL'
}
try {
const response = await dynamoDBClient.get(params).promise();
const item = response.Item;
item.marketName = transformedEvent.body.marketName;
item.marketDescription = transformedEvent.body.marketDescription;
item.marketAveragePrice = transformedEvent.body.marketAveragePrice;
item.marketApproved = false;
await dynamoDBClient.put({
TableName: dynamoDBName,
Item: item,
ReturnConsumedCapacity: 'TOTAL'
}).promise();
return {
body: JSON.stringify({message: 'success'}),
statusCode: 200
};
} catch (error) {
throw new Error('DynamoDB Processing Failed: '+ JSON.stringify(error));
}
}
case '/v1/b2c/christmas-markets/{marketId}/user/{userId}:DELETE': {
const params = {
TableName: dynamoDBName,
Key: {
userId: transformedEvent.pathParameters.userId,
marketId: transformedEvent.pathParameters.marketId
},
ReturnConsumedCapacity: 'TOTAL'
}
try {
await dynamoDBClient.delete(params).promise();
return {
body: JSON.stringify({message: 'success'}),
statusCode: 200
};
} catch (error) {
throw new Error('DynamoDB Processing Failed: '+ JSON.stringify(error));
}
}
default:
throw new Error('Method not implemented')
}
};
;
________________________
🧪 Testing the APIs:
APIs can be exported and imported inside #Postman for testing.
A small frontend app will be available soon in the next guide 😊
The B2C client token can be retrieved by using the Launch Hosted UI.
The B2B client can be retrieved from the oauth2 endpoint: https://[domain].auth.eu-west-1.amazoncognito.com/oauth2/token
curl --location --request POST 'https://[domain].auth.eu-west 1.amazoncognito.com/oauth2/token'
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'grant_type: client_credentials' \
--header 'scope: B2BIdentifier/ApproveChristmasMarkets' \
--header 'Authorization: Basic clientId+CLIENTsecret' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id=[clientId]' \
--data-urlencode 'scope=B2BIdentifier/ApproveChristmasMarkets'\
The B2B token is used only for the B2B endpoints while the B2C token is used only for the B2C endpoints.
________________________
🧼 Clean the environment:
In order to completely remove the architecture, you can delete the #Cloudformation template from Cloudformation and #S3 bucket used to upload the wSAM build.
_______________________
That’s it! A simple AWS serverless authenticated application! 🎉
I hope I could help you through the journey into the AWS world. 😎
This is my first article and I wanted to share with you information that is useful like building a serverless enterprise solution - an early Christmas gift 🎅🏻.
In the following weeks, I will post new articles (based on application design) using this architecture and application flow.
If you need any help or have questions, feel free to drop me a message! 📩
🌍 Senior Java Backend Developer • Contractor • Freelancer | Implementing clean, maintainable and scalable web apps
3yGreat article Andrei!! 🏆🔥