This chapter covers
Authentication and authorization are one of many challenges you face when developing distributed applications. The challenge lies in distributing the authorized user, along with its permissions, across all application distributed services and properly integrating third-party authentications.
This chapter shows you how to implement authentication and authorization in your serverless application by enabling it for Aunt Maria’s customers and their pizza orders. You’ll learn the difference between authentication and authorization in a serverless environment and how to implement a web authorization mechanism using AWS Cognito. Then you’ll learn how to identify your users using a social provider—specifically, Facebook.
Aunt Maria and Pierre, her mobile developer whom you so fondly remember from the previous chapter, have informed you that your API call for pizza orders is showing all pizza orders to everyone, no matter who is asking. Only employees should be able to see all orders. Customers should be able to see only their own orders. Non-customers and non-employees should not be able to see any order.
Here’s how you’ll correct this issue:
Based on your experience with Express.js or other more traditional applications, you probably want to implement authentication as a part of your API and keep your users in a database table. Though that option is feasible, we recommend another method for serverless.
Most applications need authorization, and it’s usually an email/password combination. Each authorization is implemented in a similar, if not identical, manner. Therefore, serverless providers have enabled almost literal plug-and-play authentication and authorization services to handle their vast serverless resources. In your case, Amazon has AWS Cognito—a user management and synchronization service that takes care of user authentication, authorization, access management, and user and data synchronization across services.
There are two main concepts in Amazon Cognito, each with a different responsibility:
A user pool represents a single collection of users or a user directory.
Federated identities are directories of a single user’s identities. Those identity pools keep track of each user logging in with different identity providers. To store actual user data, identity pools require Cognito user pools.
One of the key benefits of AWS Cognito is that it authorizes the requests before they hit your serverless application. It does that by setting the authorization on the API Gateway level. If the user is not authorized, it stops the requests before they hit your Lambda function and DynamoDB table, which can potentially save a lot of wasted time and money. Even though AWS Lambda is inexpensive, additional cost cutting is always welcome.
In the case of Aunt Maria’s pizzeria, you need to have both Congito identity pools and Cognito user pools. An identity pool will allow you to integrate Facebook login, and it will also give you a temporary access to your Cognito user pool without hardcoding your AWS access token and secret in the front-end and mobile applications. A user pool will manage the database of users that can order a pizza.
For Aunt Maria’s pizzeria, you need to enable your customers to authenticate via Facebook. As shown in figure 6.1, your Facebook authentication flow should have the following steps:
As shown in figure 6.2, the flow for authentication with email and password is similar:
To implement the authentication flow, as described in the previous section, you need to create both user and identity pools.
Start with a user pool. To create one, run the aws cognito-idp create-user-pool
command from your terminal. The only required option for this command is the name for your new pool. In addition to the name, add the --username-attributes
option, which specifies an email as a unique ID of your user pool. You may also want to customize the password policy by specifying the --policies
option. The default password policy requires a mix of lowercase letters, uppercase letters, numbers, and special characters. The full command for creating a new user pool is shown in the next listing.
Listing 6.1 Creating a user pool
aws cognito-idp create-user-pool ①
--pool-name Pizzeria ②
--policies "PasswordPolicy={MinimumLength=8,RequireUppercase=false, RequireLowercase=false,
RequireNumbers=false,RequireSymbols=false}" ③
--username-attributes email ④
--query UserPool.Id ⑤
--output text
The output is the ID of your new user pool, because the query
flag was provided. Keep this ID, because you’ll need it later.
Your user pool needs to have at least one client so you can connect it. You can create a client via the aws cognito-idp create-user-pool-client
command, as shown in the listing 6.2. To create a client, you need to pass your user pool ID, which you received from the previous command, and your client name. You’ll test this setup with a simple web app, so you should create a client without a client secret (which means that you’ll need to create another client for Pierre’s mobile app in the future).
Listing 6.2 Creating a client for the user pool
aws cognito-idp create-user-pool-client ①
--user-pool-id eu-central-1_userPoolId ②
--client-name PizzeriaClient ③
--no-generate-secret ④
--query UserPoolClient.ClientId ⑤
--output text
This command prints out the client ID; save it because you’ll need it in the next step.
Before implementing the Facebook authentication and permissions within your application, you need to visit the Facebook developer portal to create an application and obtain its ID.
The next step is to create an identity pool, which you can do using the aws cognito-identity create-identity-pool
command from the AWS CLI, as shown in listing 6.3. To do so, provide the identity pool name, any supported login providers (in your case, Facebook), and a Cognito identity provider. For the cognito-identity-providers
flag, you’ll need to provide the provider name and client ID and indicate whether you need a server-side token check. The provider name is in the following format: cognito-idp.<REGION>.amazonaws.com/<USER_POOL_ID>
. The client ID is what you received from the previous command, and you don’t need server-side token validation, so that value is set to false
.
Listing 6.3 Creating an identity pool
aws cognito-identity create-identity-pool ①
--identity-pool-name Pizzeria ②
--allow-unauthenticated-identities ③
--supported-login-providers graph.facebook.com=266094173886660 ④
--cognito-identity-providers ProviderName=cognito-idp.eu-central-1.amazonaws.com/
eu-central-1_qpPMn1Tip,ClientId=4q14u0qalmkangdkhieekqbjma, ServerSideTokenCheck=false ⑤
--query IdentityPoolId ⑥
--output text
After the identity pool is successfully created, you’ll need to create two roles and assign them to authenticated and unauthenticated users. If you need help with role creation, see https://aws.amazon.com/blogs/mobile/understanding-amazon-cognito-authentication-part-3-roles-and-policies/.
To set the roles, use the aws cognito-identity set-identity-pool-roles
command, which expects the identity pool ID and roles for both authenticated and unauthenticated users, as shown in the following listing. Make sure you replace the <ROLE1_ARN>
and <ROLE2_ARN>
values with the ARNs of the two roles you just created.
Listing 6.4 Add roles to the identity pool
aws cognito-identity set-identity-pool-roles ①
--identity-pool-id eu-central-1:2a3b45c6-1234-123d-1234-1e23fg45hij6 ②
--roles authenticated=<ROLE1_ARN>,unauthenticated=<ROLE2_ARN> ③
This command returns an empty response if it successfully executes.
Now that you have user and identity pools, it’s time to connect the authentication flow in your code.
Claudia, in combination with Claudia API Builder, supports all three authorization methods mentioned earlier: IAM roles, custom authorizers, and Cognito user pools. This book focuses on the last one, but the other two work in a similar way. For more information about them, see the official documentation for Claudia API Builder: https://github.com/claudiajs/claudia-api-builder/blob/master/docs/api.md#require-authorization.
To enable Cognito user pool authorization, you’ll need to register an authorizer using the registerAuthorizer
method of the Claudia API Builder instance. This method requires two attributes: the authorizer name and an object with an array of Cognito user pool ARNs. The following is a simple example usage:
api.registerAuthorizer('MyCognitoAuth', {
providerARNs: ['<COGNITO_USER_POOL_ARN>']
});
After the authorizer is registered, add an object with the cognitoAuthorizer
key and the name you used to register your authorizer as a value, as a third argument to the route definition. Your route definition should look like this:
api.post('/protectedRoute', request => {
return doSomething(request)
}, { cognitoAuthorizer: 'MyCognitoAuth' })
Apply the same to the routes in your api.js file. The routes will look similar to the ones shown in the following listing. All routes related to the orders will be protected with the Cognito authorizer, but routes for pizzas will stay public.
Listing 6.5 API with a custom authorizer
'use strict'
const Api = require('claudia-api-builder')
const api = new Api()
const getPizzas = require('./handlers/get-pizzas')
const createOrder = require('./handlers/create-order')
const updateOrder = require('./handlers/update-order')
const deleteOrder = require('./handlers/delete-order')
api.registerAuthorizer('userAuthentication', { ①
providerARNs: [process.env.userPoolArn] ②
})
// Define routes
api.get('/', () => 'Welcome to Pizza API')
api.get('/pizzas', () => {
return getPizzas()
})
api.get('/pizzas/{id}', (request) => {
return getPizzas(request.pathParams.id)
}, {
error: 404
})
api.post('/orders', (request) => {
return createOrder(request) ③
}, {
success: 201,
error: 400,
cognitoAuthorizer: 'userAuthentication' ④
})
api.put('/orders/{id}', (request) => {
return updateOrder(request.pathParams.id, request.body)
}, {
error: 400,
cognitoAuthorizer: 'userAuthentication' ④
})
api.delete('/orders/{id}', (request) => {
return deleteOrder(request.pathParams.id)
}, {
error: 400,
cognitoAuthorizer: 'userAuthentication' ④
})
api.post('delivery', (request) => {
return updateDeliveryStatus(request.body)
}, {
success: 200,
error: 400,
cognitoAuthorizer: 'userAuthentication' ④
})
module.exports = api
The last piece of the authorization puzzle is updating the route handler to use the authorizer.
For example, to update your create-order.js handler you need to do the following:
claims
.Figure 6.3 shows how the API is restricted by API Gateway and Amazon Cognito user pools.
The updated create-order.js handler is shown in the following listing.
Listing 6.6 create-order.js handler with authorization
'use strict'
const AWS = require('aws-sdk')
const docClient = new AWS.DynamoDB.DocumentClient()
const rp = require('minimal-request-promise')
function createOrder(request) { ①
console.log('Save an order', request.body)
const userData = request.context.authorizer.claims ②
console.log('User data', userData) ②
let userAddress = request.body && request.body.address ④
if (!userAddress) { ⑤
userAddress = JSON.parse(userData.address).formatted
}
if (!request.body || !request.body.pizza || userAddress)
throw new Error('To order pizza please provide pizza type and address where pizza should be delivered')
return rp.post('https://fake-delivery-api.effortlessserverless.com/delivery', {
headers: {
Authorization: 'aunt-marias-pizzeria-1234567890',
'Content-type': 'application/json'
},
body: JSON.stringify({
pickupTime: '15.34pm',
pickupAddress: 'Aunt Maria Pizzeria',
deliveryAddress: userAddress, ⑥
webhookUrl: 'https://g8fhlgccof.execute-api.eu-central-1.amazonaws.com/latest/delivery',
})
})
.then(rawResponse => JSON.parse(rawResponse.body))
.then(response => {
return docClient.put({
TableName: 'pizza-orders',
Item: {
cognitoUsername: userAddress['cognito:username'], ⑦
orderId: response.deliveryId,
pizza: request.body.pizza,
address: userAddress, ⑧
orderStatus: 'pending'
}
}).promise()
})
.then(res => {
console.log('Order is saved!', res)
return res
})
.catch(saveError => {
console.log(`Oops, order is not saved :(`, saveError)
throw saveError
})
}
module.exports = createOrder
After the code is updated, run claudia update
to deploy your API. To test the working authorization, you’ll need to implement the login/signup flow. The back-end part of adding the authorization was easy. But most of the work, including the integration of your user and identity pools, should be done on the front-end side. This part of the application is beyond the scope of this book, but you can see the working example code with a how-to guide on GitHub at https://github.com/effortless-serverless/pizzeria-web-app.
Before you run the code from that repository, however, you can confirm that unauthorized users will be rejected by running the following curl
command:
curl -o - -s -w ", status: %{http_code}
"
-H "Content-Type: application/json"
-X POST
-d '{"pizzaId":1,"address":"221B Baker Street"}' ①
https://21cioselv9.execute-api.us-east-1.amazonaws.com/latest/orders
This command should return an error and a 401 HTTP status.
The back-end part for adding authorization is easy. Most of the work, including integration of your user and identity pools, should be done on the client-side. This part of the application is beyond the scope of this book, but you can see the working example code with the how-to guide on Github here: https://github.com/effortless-serverless/pizzeria-web-app.
Now that you know how authorizers work, it’s time to try it on your own.
Your exercise for this chapter is to update the delete-order.js handler to allow users to delete only their orders.
Here are a few hints, in case you need them:
deleteOrder
function currently accepts only orderId
, you’ll need to extend it to accept authorized user details, too.deleteOrder
method should use cognito:username
from the request.context.authorizer.claims
object to check if the current user is the order owner.In case you want an additional challenge, here are two more:
First, you need to update your delete-order.js handler to accept both orderId
and the authorized user data. You also want to get the order from the database and check if it belongs to the authorized user. The following listing shows the updated delete-order.js handler.
Listing 6.7 delete-order.js handler with authorization
'use strict'
const AWS = require('aws-sdk')
const docClient = new AWS.DynamoDB.DocumentClient()
const rp = require('minimal-request-promise')
function deleteOrder(orderId, userData) { ①
return docClient.get({
TableName: 'pizza-orders',
Key: {
orderId: orderId
}
}).promise()
.then(result => result.Item)
.then(item => {
if (item.cognitoUsername !== userData['cognito:username']) ②
throw new Error('Order is not owned by your user') ③
if (item.orderStatus !== 'pending')
throw new Error('Order status is not pending')
return rp.delete(`https://fake-delivery-api.effortlessserverless.com/delivery/${orderId}`, {
headers: {
Authorization: 'aunt-marias-pizzeria-1234567890',
'Content-type': 'application/json'
}
})
})
.then(() => {
return docClient.delete({
TableName: 'pizza-orders',
Key: {
orderId: orderId
}
}).promise()
})
}
module.exports = deleteOrder
After updating your handler, you need to update the route to pass the correct data to the handler. The following listing shows an excerpt from api.js where the order ID and user data are passed to the delete-order.js handler. As you saw earlier, user data is available as claims
in the request.context.authorizer
object.
Listing 6.8 Updating the delete order route to pass user data to the handler
api.delete('/orders/{id}', (request) => {
return deleteOrder(request.pathParams.id, request.context.authorizer.claims) ①
}, {
error: 400,
cognitoAuthorizer: 'userAuthentication'
})
Now that your code is updated, simply run claudia update
to deploy it. After its completion, you can use the front-end code from the https://github.com/effortless-serverless/pizzeria-web-app repository to implement retrieving the authorization token on your front-end application or to test. If you try to delete an old order using that token, it won’t work because it was already created without authorization; the Cognito username won’t match.