date | title | template | thumbnail | slug | categories | tags | |||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
2020-05-30 |
Securing AWS API Gateway Endpoints (Cognito, IAM, API Keys) |
post |
../thumbnails/serverless.png |
serverless-securing-authenticate-aws-api-gateway-endpoints-cognito-iam-api-key |
|
|
Considering that not all Lambda functions should be public, different APIs in a system will require different levels of authentication and access.
We'll take a look at securing Lambda functions at API Gateway using IAM and Cognito authorizers, and setting up usage quotas with API keys.
Public APIs are endpoints that don't require the user to be authenticated first.
In this example, the GET
endpoint is public.
Authenticated APIs are endpoints that require the user to be authenticated first.
These are generally API endpoints that may have functionality that updates the system state on a user's behalf:
- Updating a user Profile
- Place and managing orders
In this example, the POST
endpoint should be authenticated with Cognito using Cognito User Pool, or Cognito Federated Identities.
Internal APIs are endpoints that will only be utilized by internal systems and microservices and are not consumed by the client app.
These are generally API endpoints that:
- Process data or files
- Manage push messaging to the client
- Handle email or SMS notifications
In this example, the Fanout
Lambda is only called internally and should be authenticated with IAM permissions.
AWS Cognito manages user sign-ups and authentication and also has the functionality to synchronize user profiles across devices.
Cognito User Pool is a managed identity service that handles registration / registration verification / authentication and password policies.
During user authentication, Cognito provides temporary credentials to use to access other AWS resources or APIs in API Gateway.
When a user registers and confirms their email, the client talks with Cognito User Pool:
- Client: Registers a new account.
- Cognito User Pool: Sends a verification token.
- Client: Confirms the verification token.
- Cognito User Pool: Completes the registration process.
Client Cognito User Pool API Gateway AWS Lambda
| | | |
| Register | | |
| New Account | | |
| -------------------βΊ | | |
| | | |
| β------------------- | | |
| Send Verification | | |
| Token | | |
| (Email / SMS) | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| Confirm | | |
| Verification | | |
| Token | | |
| -------------------βΊ | | |
| | | |
| β------------------- | | |
| Registration | | |
| Complete | | |
| | | |
After a successful sign-in, Cognito User Pool returns a JWT token. This token needs to be passed in future HTTP headers for authentication in API Gateway.
-
Client: Signs in with username and password.
-
Cognito User Pool:
- Authenticates the user with username and password.
- Returns an ID token with JWT.
-
Client: Includes the JWT in the header of HTTP requests to API Gateway that are secured with the Cognito authorizer.
Client Cognito User Pool API Gateway AWS Lambda
| | | |
| Sign In | | |
| -------------------βΊ | | |
| | | |
| β------------------- | | |
| ID Token | | |
| (JWT) | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| HTTP POST { authorization: ... } | |
| ----------------------------------------βΊ | |
| | | ------------------βΊ |
| | | β------------------ |
| β---------------------------------------- | |
| | 200 {...} | |
Cognito Federated Identities allows authentication with a supported identity provider (Google, Facebook, Twitter, etc).
The auth token issued by an auth provider is exchanged for temporary AWS IAM credentials, which can be used to access other AWS services.
- App / Client authenticates with a 3rd party identity provider
- The identity provider returns an auth token
- The auth token is sent to Cognito Federated Identities
- Cognito Federated Identities validates the auth token with the identity provider
- If the auth token is valid, Cognito will issue a temporary AWS IAM credential to the Client
- The client can now access other AWS services using the temporary AWS IAM credential
Identity Providers
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| |
| Google Cognito User Pool Facebook Twitter |
| |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β² | Issues β²
| | Auth Token |
| | |
| | |
Authenticate | | | Validate
(Login) | βΌ Send | Auth Token
βββββββββββββββββββ Auth Token ββββββββββββββββββ
| | --------------------βΊ | Cognito |
| App / Client | | Federated |
| | β-------------------- | Identities |
βββββββββββββββββββ Temporary IAM ββββββββββββββββββ
| Credential
| Use
| IAM Credential
|
βΌ AWS Services
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| |
| API Gateway S3 DynamoDB SNS Kinesis ... |
| |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
To require that the caller be authenticated with Cognito to invoke your Lambda Function, create the Cognito authorizer as CloudFormation resource, and set the authorizer for the lambda function to Cognito User Pool.
Note that we'll also have to add a new Cognito User Pool resource, CognitoUserPool
, and add the web and server clients.
Construct the CognitoAuthorizer
as a CloudFormation resource and then reference it in each function. The ProviderARNs
attribute for the CognitoAuthorizer
resource will point to the ARN of the CognitoUserPool
resource.
resources:
Resources:
CognitoAuthorizer:
Type: AWS::ApiGateway::Authorizer
Properties:
AuthorizerResultTtlInSeconds: 300
IdentitySource: method.request.header.Authorization
Name: Cognito
RestApiId: !Ref ApiGatewayRestApi
Type: COGNITO_USER_POOLS
ProviderARNs:
- !GetAtt CognitoUserPool.Arn
functions:
search-stores:
handler: functions/search-stores.handler
events:
- http:
path: /stores/search
method: post
authorizer:
type: COGNITO_USER_POOLS
authorizerId: !Ref CognitoAuthorizer
resources:
Resources:
CognitoUserPool:
Type: AWS::Cognito::UserPool
Properties:
AliasAttributes:
- email
UsernameConfiguration:
CaseSensitive: false
AutoVerifiedAttributes:
- email
Policies:
PasswordPolicy:
MinimumLength: 8
RequireLowercase: true
RequireNumbers: true
RequireUppercase: true
RequireSymbols: true
Schema:
- AttributeDataType: String
Mutable: true
Name: given_name
Required: true
StringAttributeConstraints:
MinLength: "1"
- AttributeDataType: String
Mutable: true
Name: family_name
Required: true
StringAttributeConstraints:
MinLength: "1"
- AttributeDataType: String
Mutable: true
Name: email
Required: true
StringAttributeConstraints:
MinLength: "1"
WebCognitoUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
ClientName: web
UserPoolId: !Ref CognitoUserPool
ExplicitAuthFlows:
- ALLOW_USER_SRP_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
PreventUserExistenceErrors: ENABLED
ServerCognitoUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
ClientName: server
UserPoolId: !Ref CognitoUserPool
ExplicitAuthFlows:
- ALLOW_ADMIN_USER_PASSWORD_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
PreventUserExistenceErrors: ENABLED
provider:
name: aws
runtime: nodejs12.x
functions:
search-stores:
handler: functions/search-stores.handler
events:
- http:
path: /stores/search
method: post
authorizer:
type: COGNITO_USER_POOLS
authorizerId: !Ref CognitoAuthorizer
resources:
Resources:
CognitoUserPool:
Type: AWS::Cognito::UserPool
Properties:
AliasAttributes:
- email
UsernameConfiguration:
CaseSensitive: false
AutoVerifiedAttributes:
- email
Policies:
PasswordPolicy:
MinimumLength: 8
RequireLowercase: true
RequireNumbers: true
RequireUppercase: true
RequireSymbols: true
Schema:
- AttributeDataType: String
Mutable: true
Name: given_name
Required: true
StringAttributeConstraints:
MinLength: "1"
- AttributeDataType: String
Mutable: true
Name: family_name
Required: true
StringAttributeConstraints:
MinLength: "1"
- AttributeDataType: String
Mutable: true
Name: email
Required: true
StringAttributeConstraints:
MinLength: "1"
WebCognitoUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
ClientName: web
UserPoolId: !Ref CognitoUserPool
ExplicitAuthFlows:
- ALLOW_USER_SRP_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
PreventUserExistenceErrors: ENABLED
ServerCognitoUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
ClientName: server
UserPoolId: !Ref CognitoUserPool
ExplicitAuthFlows:
- ALLOW_ADMIN_USER_PASSWORD_AUTH
- ALLOW_REFRESH_TOKEN_AUTH
PreventUserExistenceErrors: ENABLED
CognitoAuthorizer:
Type: AWS::ApiGateway::Authorizer
Properties:
AuthorizerResultTtlInSeconds: 300
IdentitySource: method.request.header.Authorization
Name: Cognito
RestApiId: !Ref ApiGatewayRestApi
Type: COGNITO_USER_POOLS
ProviderARNs:
- !GetAtt CognitoUserPool.Arn
Outputs:
CognitoUserPoolId:
Value: !Ref CognitoUserPool
CognitoUserPoolArn:
Value: !GetAtt CognitoUserPool.Arn
CognitoUserPoolWebClientId:
Value: !Ref WebCognitoUserPoolClient
CognitoUserPoolServerClientId:
Value: !Ref ServerCognitoUserPoolClient
plugins:
- serverless-pseudo-parameters
To require that the caller submit IAM access keys to be authenticated to invoke your Lambda Function, set the authorizer to aws_iam
.
Generally, Lambdas that are only accessed by your infrastructure (and are not intended to be called by the client directly), should be restricted access by IAM role-based permissions
IAM authorization also makes sense as the caller will already be running within AWS and will already have an IAM role.
In order to invoke a Lambda that is secured with an IAM authorizer, we'll need to sign and prepare our requests using AWS Signature Version 4.
Add the aws4 NPM package.
$ yarn add aws4
Add the execute-api:Invoke
to the IAM execution role in the iamRoleStatements
property:
Quick Note:
execute-api:invoke
permission allows calling API Gateway endpoints.lambda:InvokeFunction
permission allows directly invoking lambdas, bypassing API gateway.
provider:
name: aws
runtime: nodejs12.x
iamRoleStatements:
- Effect: Allow
Action: execute-api:Invoke
Resource: arn:aws:execute-api:#{AWS::Region}:#{AWS::AccountId}:#{ApiGatewayRestApi}/${self:provider.stage}/GET/stores
In this example, we're going to have the get-index
function call the get-stores
function through API Gateway:
To require that the caller submit the IAM user's access keys to be authenticated to invoke your Lambda Function, use the aws_iam
authorizer for get-stores
endpoint.
We'll also need the URL of the /stores
API Gateway endpoint, so we're passing the URL in as an environment variable, stores_api
:
functions:
get-index:
handler: functions/get-index.handler
events:
- http:
path: /
method: get
environment:
stores_api: https://#{ApiGatewayRestApi}.execute-api.#{AWS::Region}.amazonaws.com/${self:provider.stage}/stores
get-stores:
handler: functions/get-stores.handler
events:
- http:
path: /stores
method: get
authorizer: aws_iam
The aws4
package prepares the opts
object with a headers
property using the URL of the get-stores
function.
We pass this authentication header into our HTTP request to the get-stores
URL:
const http = require("axios");
const aws4 = require("aws4");
const URL = require("url");
const storesApi = process.env.stores_api;
const getStores = async () => {
const url = URL.parse(storesApi);
const opts = {
host: url.hostname,
path: url.pathname,
};
aws4.sign(opts);
const httpReq = http.get(storesApi, {
headers: opts.headers,
});
return (await httpReq).data;
};
module.exports.handler = async (event, context) => {
const stores = await getStores();
const response = {
statusCode: 200,
body: stores,
};
return response;
};
We can test and see that the get-stores
endpoint is now secure by trying to hit the /stores
API endpoint directly:
$ curl -X GET https://xxxxxxxxxxxx.execute-api.us-east-1.amazonaws.com/dev/stores
{
message: "Missing Authentication Token"
}
We can also use API keys and Usage Plans to restrict a client's access on selected APIs to an agreed-upon request rate and quota.
It should be noted that API keys are designed for rate-limiting individual clients rather than for authentication and authorization.
Note: API key quotas apply to all APIs and Stages. The request rate and quota assigned to an API key apply to all the APIs AND the stages covered by the current usage plan.
In this example, we're going to have the get-index
function call the get-stores
function through API Gateway:
To require that the caller pass an API key to invoke your Lambda Function, set the private
boolean property to the http
event object for the get-stores
endpoint.
We'll also need the URL of the /stores API Gateway endpoint, so we're passing the URL in as an environment variable, stores_api:
functions:
get-index:
handler: functions/get-index.handler
events:
- http:
path: /
method: get
environment:
stores_api: https://#{ApiGatewayRestApi}.execute-api.#{AWS::Region}.amazonaws.com/${self:provider.stage}/stores
get-stores:
handler: functions/get-stores.handler
events:
- http:
path: /stores
method: get
private: true
We'll also need to create a Usage Plan to specify the rate limit and quota for the number of requests a client can make, and associate the API keys with a usage plan.
You can set up a general Usage Plan that all API keys will use, but I like to have specific Usage Plan categories and assign keys to each specific category.
In this example, the Usage Plan category names are:
free
paid
And we've generated two API key names.
freeKey
, assigned to thefree
Usage Plan category.paidKey
, assigned to thepaid
Usage Plan category.
In our implementation, we're specifying the API key names and allowing AWS to generate the actual keys for each API key.
provider:
name: aws
runtime: nodejs12.x
apiKeys:
- free:
- freeKey
- paid:
- paidKey
usagePlan:
- free:
quota:
limit: 5000
offset: 2
period: MONTH
throttle:
burstLimit: 200
rateLimit: 100
- paid:
quota:
limit: 50000
offset: 1
period: MONTH
throttle:
burstLimit: 2000
rateLimit: 1000
After you redeploy the stack, the API key name and values will be returned in the Serverless CLI output:
$ sls deploy
Serverless: Packaging service...
...
Serverless: Stack update finished...
Service Information
service: stores-service
stage: dev
region: us-east-1
stack: stores-service-dev
resources: 33
api keys:
freeKey: xxxxxxxxxxxxx
paidKey: xxxxxxxxxxxxx
endpoints:
GET - https://xxxxxxxxx.execute-api.us-east-1.amazonaws.com/dev/
GET - https://xxxxxxxxx.execute-api.us-east-1.amazonaws.com/dev/stores
POST - https://xxxxxxxxx.execute-api.us-east-1.amazonaws.com/dev/stores/search
functions:
get-index: stores-service-dev-get-index
get-stores: stores-service-dev-get-stores
search-stores: stores-service-dev-search-stores
layers:
None
Now that the get-stores
Lambda has the private
boolean, API calls to get-stores
now need to pass an API key in the x-api-key
HTTP header of the request.
const http = require("axios");
const storesApi = process.env.stores_api;
const getStores = async () => {
const httpReq = http.get(storesApi, {
headers: {
"x-api-key": "xxxxxxxxx",
},
});
return (await httpReq).data;
};
module.exports.handler = async (event, context) => {
let stores = await getStores();
// ...
const response = {
statusCode: 200,
body: stores,
};
return response;
};