Serverless API with OAuth2 authentication using AWS API Gateway, Lambda, and Cognito

Context:

Any organisation building a serverless API based architecture that handles sensitive data, security has always been a thing, to build a common security layer around these APIs, basically on the edge so that all the APIs are secured. There are multiple ways to build API security like writing some resource policy, API keys installing some agents in front of APIs which can make policy decisions etc. One of the most widely used protocols for Authorization is OAuth2. AWS API Gateway provides built-in support to secure APIs using AWS Cognito OAuth2 scopes.

A brief about OAuth 2.0:

Amazon Cognito uses the OAuth 2.0 protocol to authorize access to secure resources. OAuth 2.0 uses access tokens to grant access to resources. An access token is simply a string that stores information about the granted permissions. This token is usually valid for a short period of time, usually up to one hour, and can be refreshed using a password or a special refresh token.

A refresh token is usually obtained using password authentication. It’s valid for a longer time, sometimes indefinitely, and its whole purpose is to generate new access tokens. The refresh token can be used to generate an unlimited number of access tokens, until it expires or is manually disabled. With Amazon Cognito, the access token is referred to as an ID token, and it’s valid for 60 minutes. A refresh token is obtained as part of the user-pool app client (more on that later) and can be valid for up to 10 years.

For our serverless aws api gateway we will use AWS Cognito OAuth2 scopes and resource policy as two layers of the security.

Below is the architecture diagram:

  1. Invoke AWS Cognito /oauth2/token endpoint with grant_type as client_credentials. Refer https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html
  2. If the request is valid, AWS Cognito will return a JWT (JSON Web Token) formatted access_token
  3. Pass this token in Authorization header for all API calls.
  4. API Gateway makes a call to AWS Cognito to validate the access_token and make sure the API request to the API Gateway is from the IPs which is mentioned in the API gateway resource policy otherwise it will DENY the request.
  5. AWS Cognito returns token validation response.
  6. If the token is valid, API Gateway will validate the OAuth2 scope in the JWT token and ALLOW or DENY API call. This is entirely handled by API Gateway once configuration is in place.
  7. Perform the actual API call whether it is a Lambda function or custom web service application.
  8. Return the results from Lambda function.
  9. Return results to API Gateway.
  10. If there are no issues with the Lambda function, API Gateway will return a HTTP 200 with response data to the client application.

There are few prerequisites for setting up this integration:

  1. AWS Account — business or free tier.
  2. Knowledge on AWS API Gateway, S3 and AWS Cognito services
  3. Knowledge on OAuth2 protocol
  4. Knowledge on CloudFormation or Terraform.

NOTE: Make sure you create all of the resources in the same Region.

We have to perform the below steps for this integration:

  1. Create an AWS Cognito user pool and configure OAuth agents.
  2. Deploy a sample micro webservice application in AWS Lambda
  3. Create API Gateway and Configure Cognito Authorizer in API Gateway

Step 1: Create AWS Cognito user pool and setup an OAuth application.

We’ll start by creating the Amazon Cognito user pool that’ll manage our OAuth2 scope, the registration process, and many other security features.

  • Creating the user pool
  • User pool app client
  • User pool client resources
  • User pool Domain

Use below cloudformation template to create cognito user pool with OAuth2.0

<cognitopool.yml>

AWSTemplateFormatVersion: '2010–09–09'
Description: 'Creating cognito user pool and API client for Ouath2'
Parameters:
IdTokenValidity:
Description: The ID token time limit in minutes. After this limit expires, your user can't use their ID token.
Type: String
Default: 60
AccessTokenValidity:
Description: The access token time limit in minutes. After this limit expires, your user can't use their access token.
Type: String
Default: 60
InputApplicationTagName:
Description: Tag used for the resources.
Type: String
Default: CheckSMSOptInAuthorizer3
InputUserPoolName:
Description: Name of UserPool resource.
Type: String
Default: checksmsoptinauthorizer3
InputRefreshTokenValidity:
Description: The time limit, in days, after which the refresh token is no longer
valid and cannot be used.
Type: String
Default: 30
InputClientName:
Description: The client name for the user pool client.
Type: String
Default: CheckSMSOptInAuthorizer3
Resources:
UserPoolResource:
Type: AWS::Cognito::UserPool
Properties:
UserPoolName:
Ref: InputUserPoolName
UserPoolTags:
application:
Ref: InputApplicationTagName
UserPoolClientResource:
Type: AWS::Cognito::UserPoolClient
Properties:
AllowedOAuthFlows:
- client_credentials
GenerateSecret: 'true'
AllowedOAuthFlowsUserPoolClient: 'true'
ClientName:
Ref: InputClientName
PreventUserExistenceErrors: ENABLED
RefreshTokenValidity:
Ref: InputRefreshTokenValidity
AccessTokenValidity:
Ref: AccessTokenValidity
IdTokenValidity:
Ref: IdTokenValidity
TokenValidityUnits:
AccessToken: minutes
IdToken: minutes
RefreshToken: days
SupportedIdentityProviders:
- COGNITO
AllowedOAuthScopes:
- Fn::Join:
- "/"
- - Ref: InputUserPoolName
- post
UserPoolId:
Ref: UserPoolResource
DependsOn:
- UserPoolResourceServerResource
UserPoolResourceServerResource:
Type: AWS::Cognito::UserPoolResourceServer
Properties:
UserPoolId:
Ref: UserPoolResource
Identifier:
Ref: InputUserPoolName
Name:
Ref: InputUserPoolName
Scopes:
- ScopeName: post
ScopeDescription: post
UserPoolDomainResource:
Type: AWS::Cognito::UserPoolDomain
Properties:
Domain:
Ref: InputUserPoolName
UserPoolId:
Ref: UserPoolResource
Outputs:
UserPoolARN:
Value:
Fn::GetAtt:
- UserPoolResource
- Arn
UserPoolProviderURL:
Value:
Fn::GetAtt:
- UserPoolResource
- ProviderURL
UserPoolProviderName:
Value:
Fn::GetAtt:
- UserPoolResource
- ProviderName
UserPoolID:
Value:
Ref: UserPoolResource    

Above yml will create the cognito user pool with cognito OAuth 2.0 in your AWS infrastructure.

Step 2: Create Lambda function:

  • Name: name of the function
  • IAM role:

SecretsManagerReadWrite

AWSLambdaBasicExecutionRole

S3 Read Only Access

AmazonAPIGatewayInvokeFullAccess

  • Runtime: Python 3.7
  • Timeout: 15 min
  • Environment variable
  • Vpc Config

Below given CloudFormation will create the lambda function with mentioned configuration:

<lambda.yml>

AWSTemplateFormatVersion: '2010–09–09'
Description: Cloudformation Template to create and configure Epsilon Proxy Lambda function to check sms optin/out preference.
Parameters:
LambdaName:
Type : String
Description: Name of the lambda function
Default: CallPCMSMSOptInCheckService_customerkey
VpcId:
Description: VpcId to deploy into
Type: 'AWS::EC2::VPC::Id'
s3bucket :
Type: String
Description: S3 bucket where lambda source code zip file is placed
Default: CallPCMSMSOptInCheckService
s3bucketKey:
Type: String
Description: path and name of zip file for lambda source code
Default: prod/CallPCMSMSOptInCheckService_customerkey.zip
subnetid1:
Type: 'AWS::EC2::Subnet::Id'
Description: Select the first subnet for lambda function
subnetid2 :
Type: 'AWS::EC2::Subnet::Id'
Description: Select the second subnet for lambda function
apikeySecretManagerName:
Type: String
Description: arn parameter of api keys secret manager name. Lambda will be only reading this secret.
Default: AEP/PCM/APIKeys-MceAwr
AccessTokenSecretManagerName:
Type: String
Description: arn parameter of AccessToken secret manager name. Lambda will be reading and writing this secret.
Default: AEP/PCM/AccessToken-8WKFDS
Resources:
InstanceSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName: Security Group for Epsilon Lambda
GroupDescription: Security group for Lambda function
VpcId: !Ref VpcId
IAMPolicy:
Type: 'AWS::IAM::Policy'
DependsOn: LambdaRole
Properties:
PolicyName: checksms-lambda-role-policy
PolicyDocument: {
"Version": "2012–10–17",
"Statement": [
{
"Effect": "Allow",
"Action": "logs:CreateLogGroup",
"Resource": {"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"}
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": [
{"Fn::Sub": "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${LambdaName}:*"}
]
},
{
"Effect": "Allow",
"Action": [
"ec2:CreateNetworkInterface",
"ec2:DescribeNetworkInterfaces",
"ec2:DeleteNetworkInterface"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue"
],
"Resource": {"Fn::Sub": "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${apikeySecretManagerName}"}
},
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject"
],
"Resource": {"Fn::Sub": "arn:aws:s3:::${s3bucket}" }
},
{
"Effect": "Allow",
"Action": [
"secretsmanager:DescribeSecret",
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue"
],
"Resource": {"Fn::Sub": "arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${AccessTokenSecretManagerName}" }
}
]
}
Roles:
- !Ref LambdaRole
LambdaRole:
Type: AWS::IAM::Role
DependsOn: InstanceSecurityGroup
Properties:
RoleName: checksms-lambda-role
AssumeRolePolicyDocument:
Statement:
- Action:
- sts:AssumeRole
Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Version: 2012–10–17
LambdaFunction:
Type: AWS::Lambda::Function
DependsOn: IAMPolicy
Properties:
FunctionName: !Ref LambdaName
Description: LambdaFunction for epsilon
Runtime: python3.7
Code:
S3Bucket: !Ref s3bucket
S3Key: !Ref s3bucketKey
Handler: lambda_function.lambda_handler
MemorySize: 512
Timeout: 900
Role:
Fn::GetAtt:
- LambdaRole
- Arn
VpcConfig:
SecurityGroupIds:
- !Ref InstanceSecurityGroup
SubnetIds:
- !Ref subnetid1
- !Ref subnetid2
Outputs:
LambdaRoleARN:
Description: Role for Lambda execution.
Value:
Fn::GetAtt:
- LambdaRole
- Arn
Export:
Name:
Fn::Sub: LambdaRole
LambdaFunctionName:
Value:
Ref: LambdaFunction
LambdaFunctionARN:
Description: Lambda function ARN.
Value:
Fn::GetAtt:
- LambdaFunction
- Arn
Export:
Name:
Fn::Sub: LambdaARN-prod 



Please change variables in the cloudformation template as per your requirement.

Step 2: Create API Gateway with Authorizer and resource policy:

Given code will create below resources:

  • API Gateway Resource
  • API Gateway Method
  • Cognito Authorizer
  • Stage
  • Resource policy
  • Deploy

<api-gateway.yml>

AWSTemplateFormatVersion: 2010–09–09
Description: API Gateway
Parameters:
LamdaArn:
Type: String
Description: lambda function arn
Default: 'arn:aws:lambda:us-east-1:318493754102:function:nfl-lambda'
apiGatewayName:
Type: String
Description: name of the api gateway
Default: CheckSMSOptIn
AppFunction:
Type: String
Description: Name of the lambda function
apiGatewayStageName:
Type: String
Description: name of the stage in the api gateway
AllowedPattern: '[a-z0–9]+'
Default: prod
CognitoUserPoolArn:
Description: ARN of the Cognito User Pool
Type: String
Default: 'arn:aws:cognito-idp:us-east-1:996656702859:userpool/us-east-1_wl3f0KSB5'
CognitoAppClientScope:
Description: Scope of the resource in cognito User Pool
Type: String
Default: 'checksmsoptinauthorizer3/post'
Resources:
apiGateway:
Type: AWS::ApiGateway::RestApi
Properties:
Description: nfl api gateway
Policy:
Version: 2012–10–17
Statement:
- Effect: Allow
Principal: '*'
Action: 'execute-api:Invoke'
Resource: 'execute-api:/*/*/*'
- Effect: Deny
Principal: '*'
Action: 'execute-api:Invoke'
Resource: 'execute-api:/*/*/*'
Condition:
NotIpAddress:
'aws:SourceIp':
- 20.42.2.0/23
- 20.42.4.0/26
- 20.42.64.0/28
- 20.49.111.0/29
- 40.71.14.32/28
- 40.78.229.96/28
- 20.41.2.0/23
- 20.41.4.0/26
- 20.44.17.80/28
- 20.49.102.16/29
- 40.70.148.160/28
- 52.167.107.224/28
EndpointConfiguration:
Types:
- REGIONAL
Name: !Ref apiGatewayName
checksmsck:
Type: 'AWS::ApiGateway::Resource'
Properties:
RestApiId: !Ref apiGateway
ParentId: !GetAtt
- apiGateway
- RootResourceId
PathPart: checksms_ck
apiGatewayRootMethod:
Type: AWS::ApiGateway::Method
DependsOn: AppFunctionPermission
Properties:
RestApiId: !Ref apiGateway
HttpMethod: POST
AuthorizationType: COGNITO_USER_POOLS
AuthorizationScopes:
- !Ref CognitoAppClientScope
AuthorizerId: !Ref AuthorizersCognitoUserPools
Integration:
Type: AWS
IntegrationHttpMethod: POST
Uri: !Join
- ''
- - !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/
- !Ref LamdaArn
- '/invocations'
ResourceId: !Ref checksmsck
AppFunctionPermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref AppFunction
Action: lambda:InvokeFunction
Principal: apigateway.amazonaws.com
SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${apiGateway}/*/POST/checksms_ck'
AuthorizersCognitoUserPools:
Type: AWS::ApiGateway::Authorizer
Properties:
Name: CognitoAuthorizer
Type: COGNITO_USER_POOLS
RestApiId: !Ref apiGateway
IdentitySource: method.request.header.authorizationToken
ProviderARNs:
- !Ref CognitoUserPoolArn
apiGatewayDeployment:
Type: AWS::ApiGateway::Deployment
DependsOn:
- apiGatewayRootMethod
Properties:
RestApiId: !Ref apiGateway
StageName: !Ref apiGatewayStageName 



Testing

We will be testing our APIs using Postman.

Step 1. Get the client id and client secret.

  • Go to the AWS Cognito Console and select the User Pool we just created. i.e. dev-test-API.
  • Navigate to the general settings tab and select the App Client we just created i.e. test-api. Over here, click on the button named view details.

  • It will give you the value for the app client id and app client secret.

Step 2. Get the Access token.

Navigate to the postman and go to the Authorization select type as OAuth 2.0, and give the token name same as you given in the api authorizer and give client credentials as mentioned below picture and click on get access token.

Once you get the token follow below steps.

  1. Click on create a new HTTP.
  2. Modify the URL copied from the API Gateway console to the API as mentioned in the below Image.
  3. Also, configure the authentication token to the Headers section as shown in the below image.

Then click on the send button and will get the response as mentioned in the below image.

If you will try to invoke the API with the Expired Token or Invalid Token or No Token, then they will get a response like

This concludes the setup. By creating multiple clients with different scopes, API access can be controlled per client application. Please note that in this use case, we used client_credentials grant, which is not user specific, but application specific. To make it user specific, we have to use OpenID Connect which will be a totally different configuration.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.