This is part of a series of articles on SaintsXCTF Version 2.0. The first article in the series provides an overview of the application. You DO NOT need to read prior articles in the series to fully understand this article.
My SaintsXCTF application has multiple APIs. One of these APIs, auth.saintsxctf.com, is hosted on AWS API Gateway and AWS Lambda. Its main purpose is to provide an authentication mechanism for the application. In this article, I discuss endpoints within this API and how they provide application authentication using JWTs.
- Architectural Overview
- AWS Infrastructure
- Kubernetes Infrastructure
- React Web Application Overview
- Web Application React and TypeScript
- Web Application Redux State Configuration
- Web Application Cypress E2E Tests
- Web Application JSS Modular Design
- Flask Python API
- Flask API Testing
- Function API Using API Gateway & Lambda
- Auth API Using API Gateway & Lambda
- Database Client on Kubernetes
The authentication API consists of two REST API endpoints backed by AWS Lambda functions and two standalone AWS Lambda functions.
AWS infrastructure for the API is created using Terraform. This Terraform configuration exists in my saints-xctf-infrastructure repository. Infrastructure is created and destroyed using Jenkins jobs. In this article I focus on the functionality of the authentication API instead of the infrastructure, so I will not discuss infrastructure or CI/CD processes in any more depth.
The idea behind this API is to have authentication logic separated from main application logic. You can think of this API as a microservice for user authentication. The authentication mechanism for the application is JWT tokens. Users pass their username and password in exchange for a JWT token, which is used in subsequent API requests. Behind the scenes, the application API checks whether a JWT token passed in an HTTP request is valid. If the token is valid, the API request is processed and a response is returned. If the token is invalid, an error message is returned. JWTs expire after a certain amount of time, and the RSA key used to generate JWTs is rotated weekly for additional security.
The authentication API exposes two endpoints to users: /token and /authenticate. Both endpoints are backed by AWS Lambda functions. The API has two additional AWS Lambda functions designed for internal usage:
SaintsXCTFRotate. Let's take a look at all these AWS Lambda functions in more detail.
The /token API endpoint is used to exchange user credentials for a JWT. /token accepts a JSON request body containing a username and a password. The following CURL request shows how to properly use the endpoint.
In the request body,
my_password are replaced with a user's username and password, respectively. The response body will look something like the following, with
j.w.t replaced with a valid JWT.
As previously mentioned, each endpoint is backed by an AWS Lambda function. The function for the /token endpoint is shown below, and exists in a function.py file within my saints-xctf-auth repository.
lambda_handler() is the entrypoint to the AWS Lambda function. Within
lambda_handler(), the first two lines extract the JSON request body fields into
client_secret variables. The function works in both development and production environments, as determined by an
ENV environment variable. The environment is retrieved with the line
env = os.environ['ENV']. The environment is important because different databases and RSA keys are used in different environments.
Next comes a series of helper function invocations. First,
get_jwt_private_key(secretsmanager, env) retrieves an RSA private key from AWS Secrets Manager. This private key is used to create JWTs. Second,
get_rds_credentials(secretsmanager, env) retrieves MySQL database credentials. The database is queried to determine if the username and password sent to the API are valid. The third and final helper function,
create_database_session(rds, db_secret, env), uses the database credentials to start a session with the application's MySQL database.
Optionally, the /token endpoint takes an email instead of a username. This option is expressed by an
if statement and a
email_pattern.match(client_id) condition. If an email is used, the user in the database is retrieved with
session.query(User).filter_by(email=client_id).first(). Otherwise, the user is retrieved with
session.query(User).filter_by(username=client_id).first(). In both cases, a SQLAlchemy model class
User is utilized to query the database. I wrote about SQLAlchemy and model classes in an article on my SaintsXCTF API. The
User model class exists in a User.py file and is shown below.
If the database query for a user with a certain username or email returns no records, the AWS Lambda function returns
None. Otherwise, it checks if the user's password matches the one in the API request body (
client_secret). Passwords are encrypted in my database using Bcrypt, so a
bcrypt.checkpw() function is used to check for password equality.
If the password submitted in the API request is invalid,
None is returned. Otherwise, a JWT is created and returned using the
The /authenticate API endpoint is used to validate a JWT and accepts a JSON request body containing a JWT. The following CURL request shows how to properly use the endpoint.
In the request body,
my_jwt is replaced with the user's JWT. The response body looks like the following JSON. In this example,
true is returned due to a valid JWT.
The AWS Lambda function for /authenticate is a bit simpler than the one for /token. Again,
lambda_handler() is the entrypoint to the AWS Lambda function. The first line in
lambda_handler() gets the JWT token and places it in a
token variable. Again, AWS Secrets Manager is used to get the RSA keys used to validate the JWT. The RSA keys are stored in a
The remainder of the code attempts to decode the JWT token using a
jwt.decode() function. If the JWT token is deemed valid, the function returns
True. Otherwise, there are two possible scenarios, both of which cause the function to return
False. The first scenario is when the token expired, resulting in a
jwt.ExpiredSignatureError exception. The second is when the token is invalid, which can result in any number of exceptions.
The "Rotate" AWS Lambda function is used internally by the authentication API. It's purpose is to rotate the RSA public and private keys, which are used to create and decode JWTs. In my application, key rotation occurs every seven days. RSA key rotation is done to defend the application from malicious users attempting to gain access to the keys. If malicious users gain access to the keys, they can create their own JWTs. These JWTs would be valid to my application, even though my application did not create them. By rotating the RSA keys, the damage is limited because the malicious user will only have access to valid RSA keys until the next rotation. After a key rotation occurs, existing RSA keys are no longer able to create or validate JWT tokens. The malicious user will no longer be able to cause harm to the application.
The "Rotate" AWS Lambda function is written in Python and exists in a function.py file within my saints-xctf-auth repository. It is based off a sample AWS Lambda function for rotating secrets1. More information on rotating secrets using AWS Secrets Manager and AWS Lambda can be found in the AWS documentation2.
The "Authorizer" AWS Lambda function is used as an API Gateway authorizer3. API Gateway authorizers enable custom authentication for API Gateway endpoints, such as the ones in my fn.saintsxctf.com API.
This authorizer checks if a JWT token passed in an API request is valid for my application. If the token is valid, requests are forwarded to API endpoints in AWS API Gateway. If the token is invalid, requests are denied. The function is very similar to the one behind my /authenticate endpoint, except this time it is for internal use by API Gateway, not publicly accessible over HTTP.
Similar to the /authenticate endpoint, the result of
jwt.decode() determines the output of the AWS Lambda function. However, instead of returning a boolean to determine whether a JWT is valid, the authorizer returns a Python dictionary. This dictionary represents a JSON IAM policy that is used to invoke API Gateway endpoints. If the JWT is valid, the IAM policy defined in
allow_policy() is returned. If the JWT is invalid, the IAM policy defined in
deny_policy() is returned. The policy in
allow_policy() allows access to API endpoints while the policy in
deny_policy() denies access.
These four functions fulfill all the authentication needs of my application! Potential enhancements to this approach include making the JWT tokens OAuth2 or OpenID Connect protocol compliant3.
Using AWS API Gateway and AWS Lambda is an effective way to create a basic authentication API for an application. The main drawback of this approach is AWS Lambda cold starts and the expenses involved with mitigating them. Otherwise, its a great way to separate authentication logic into its own serverless microservice. All the code discussed in this article is available in my saints-xctf-auth repository.