In today’s technology landscape, high-performance, real-time communication has become a requirement for many applications. While ultra-low latency solutions exist for niche use cases, WebSockets have emerged as an attractive option for most real-time communication needs. However, implementing and scaling a WebSocket-based service can be significantly more complex compared to traditional request-response based HTTP APIs.
WebSocket API in Amazon API Gateway is a fully managed service that greatly simplifies the complexity of implementing and scaling WebSocket-based services. By seamlessly integrating with AWS Lambda, it provides a serverless solution for real-time communication that can effortlessly scale to handle millions of concurrent connections.
A key feature of API Gateway is built-in authentication and authorisation. However, while REST APIs and HTTP APIs in API Gateway offer built-in JWT and Amazon Cognito authorisers, WebSocket APIs have a more limited set of options. As outlined in the API Gateway documentation, WebSocket APIs only support two types of authorisers: IAM authorisation and Lambda request authorisers.
When using a Lambda authoriser, a Lambda function is triggered whenever a new connection is established. This function has access to all the connection details, including headers and query parameters. Although Lambda authorisers require a bit more effort to set up, they provide the most flexible option by allowing you to implement custom authorisation logic within the Lambda function, tailored to your application’s specific requirements.
In this article, we’ll demonstrate how to create a WebSocket request authoriser using Rust that integrates with JWT tokens issued by Amazon Cognito.
However, the ultimate aim of this article extends beyond the specific implementation. By walking through this exercise, we hope to provide you with a sense of the strengths of the Rust ecosystem for serverless development on AWS in a broader context (if you aren’t already persuaded).
The code for this article is on GitHub.
Project outline
In this article, we will implement two Lambda functions using Rust. The first is a simple WebSocket message handler that logs incoming messages. The more interesting function is the authoriser function, which accepts a JWT token provided as a query parameter. This authoriser function decodes and verifies the token against the JSON Web Keys obtained from the authorisation server (Cognito in our case).
The two Lambda functions are implemented as separate Rust binaries, leveraging a Cargo Workspace to maintain a clear separation between the two crates. The Rust ecosystem provides excellent support for AWS Lambda development. We utilise Cargo Lambda for seamless compilation and take advantage of crates such as lambda_runtime and aws_lambda_events to streamline the implementation process. These tools and libraries significantly simplify the development workflow and enable efficient integration with the AWS Lambda platform.
Crate | Description |
---|---|
Cargo Lambda | Cargo plugin for building and deploying Lambda functions implemented in Rust. |
lambda_runtime | Lambda runtime implementation that can invoke tower services in response to Lambda events. |
aws_lambda_events | Lambda event types implemented using serde for seamless type safety. |
In addition to the Lambda function implementations, the GitHub repository includes an infrastructure folder housing the AWS Cloud Development Kit (CDK) code responsible for deploying the Lambda functions and the WebSocket API Gateway. While we won’t delve into the intricacies of the CDK code within the scope of this article, it serves as a valuable reference for those interested in leveraging CDK for their deployment needs. The CDK code in the repository provides a practical example of how to define and manage the necessary AWS resources using infrastructure as code principles.
The dummy message handler
Although the dummy message handler doesn’t do much, it’s worthwhile going through it as it contains the fundamental building blocks of a Lambda function.
The main function follows the standard structure of a Tokio runtime’s entry point. Before invoking the lambda_runtime::run function to start the Lambda runtime, the main function provides an opportunity to perform any necessary setup tasks. This can include configuring the tracing subscriber, initialising dependencies, or applying global settings. By utilising the main function for setup, we can ensure that the Lambda function is properly initialised and configured before it begins processing events.
The handler function passed to the runtime takes a LambdaEvent
(a type provided by lambda_runtime),
which can wrap an arbitrary serde Deserialize
type. The return
type must be a Result
with the Ok
variant wrapping a serde
Serialize
type.
The statusCode
here isn’t used, but it demonstrates the use of
serde
types for responses.
The authoriser Lambda
The authoriser Lambda function retrieves configuration parameters from the environment variables. These parameters are then utilised to validate the provided JWT token. Based on the token’s claims and the verification result, the Lambda function generates an IAM policy response. This response determines the permissions and access rights associated with the authenticated user.
To gain a better understanding of the authoriser Lambda’s implementation, let’s examine each module in detail in a bottom up manner.
Loading the configuration
To successfully decode and verify the JWT token, we require two crucial pieces of information. The first is the URL that provides access to the JSON Web Key Set (JWKS) associated with the Cognito user pool. The JWKS contains the necessary key(s) to verify the integrity and authenticity of the JWT. The second piece of information is the expected audience for which the token should have been issued. In the context of Cognito, the audience corresponds to the ID of the user pool client.
Environment variables offer a flexible and widely-used mechanism for configuring Lambda functions with dynamic parameters.
However, retrieving environment variables individually using the std::env
module can be cumbersome and lead to repetitive code.
The serde_env
crate provides a convenient and efficient way to deserialise environment variables directly into structured data types,
eliminating the need for manual variable retrieval and parsing.
Decoding the token
The decode.rs
module plays a crucial role in the authentication process by handling the
complex task of decoding and verifying the JWT token.
It leverages the functionality provided by the jsonwebtoken crate to ensure the token’s validity and extract relevant information.
The decoding and verification logic itself is not specific to AWS API Gateways, making it reusable in other contexts.
However, one aspect that is particular to Cognito is the extraction of the username from the cognito:username
field within the token’s payload.
The JWK keys are fetched using the reqwest crate from the endpoint provided by Cognito, which is specific to the user pool. Since the keys remain relatively stable and do not change frequently, it is acceptable to cache them within the Lambda function to optimise performance and reduce unnecessary network calls.
The verify_claims
function utilises the retrieved keys to validate the integrity
and authenticity of the token.
Additionally, it performs crucial checks to ensure that the token has not expired and that
it was issued for the intended audience.
In this implementation, we focus on extracting the Cognito username from the token’s claims.
However, with minor modifications to the code, we can easily extract any other relevant field
that Cognito includes in the token, enabling flexibility and extensibility based on the specific
requirements of the application.
Generating an authoriser response
API Gateway expects the authoriser to return an IAM policy document that specifies the
actions the user is permitted to perform.
In our implementation, we will construct a straightforward policy that includes either a blanket Allow
or Deny
statement,
depending on the outcome of the token verification process.
The response object returned by the authoriser consists of two key components: the IAM policy document and a custom context. The custom context is derived from the claims extracted from the JWT during the verification process. This additional contextual information can be utilised by the API Gateway and downstream services to make informed decisions based on the user’s attributes and permissions.
The authorise
function accepts a token as input and leverages the verify_claims
function,
which we previously implemented, to validate the token’s authenticity and integrity.
Upon successful verification, the function maps the extracted claims to an appropriate response object.
In the event of any errors during the verification process, the function generates a Deny
response,
indicating that the user is not authorised to perform the requested actions.
The implementation of the authorise function takes advantage of the types provided by the aws_lambda_events crate to construct a well-formed IAM policy response. The strong type safety offered by Rust, combined with the pre-defined types from the crate, proves invaluable in this context. It significantly reduces the time and effort required to decipher the often sparse and ambiguous documentation provided by AWS regarding the expected request and response schemas for API Gateway authorisers. By leveraging these types, we can ensure that our implementation adheres to the required format and structure, minimising the chances of errors and simplifying the development process.
The entrypoint
The outer structure of the authoriser Lambda is similar to the message handler’s.
The ApiGatewayWebsocketProxyRequest
type provides a straightforward way to extract the
auth
query parameter. This parameter contains the token, which we process
using the authorise
function we implemented.
The infrastructure code
The infrastructure for this project is defined using AWS Cloud Development Kit (CDK). At the time of writing, CDK does not offer native support for Rust, so the stack is implemented using TypeScript. While providing an exhaustive overview of the entire stack is beyond the scope of this post, there are a few noteworthy points worth highlighting.
Rust functions utilising lambda_runtime run perfectly fine on Amazon Linux
runtimes, specifically provided.al2
and provided.al2023
.
Cargo Lambda handles the cross-compilation process seamlessly,
allowing the use of either ARM64
or x86_64
architectures.
However, it is crucial to ensure that the architecture specified in Cargo Lambda aligns
with the runtime configuration of the Lambda function.
Prior to deploying the stack, it is necessary to recompile the binaries with the latest codebase. While this step could be performed separately, I have opted to invoke Cargo Lambda as a subprocess within the CDK code itself. This approach guarantees that the deployed code is always up to date, eliminating the need for manual intervention.
CDK can be reasonably fast at uploading new code, but for an even faster feedback loop, you may want to leverage the built-in deployment functionality of Cargo Lambda during development. By utilising this feature, the deployment time can be significantly reduced to mere seconds, including the compilation process. This rapid iteration cycle greatly enhances developer productivity and enables faster testing and refinement of the application.
With the code deployed, you can test the WebSocket connection using wscat
.
Conclusion
Rust proves to be a brilliant choice for developing Lambda functions, and its outstanding performance, particularly in terms of minimal cold start times, makes it an ideal fit for building real-time WebSocket APIs. The language’s inherent efficiency and low-level control enable developers to create highly optimised and responsive serverless applications.
Throughout this post, we have delved into the implementation of a JWT authoriser function specifically designed to integrate with Amazon Cognito for use in Amazon WebSocket APIs. This fills a gap in the native functionality of these APIs, which do not provide built-in support for Cognito or JWT authorisation out of the box. By leveraging Rust’s capabilities, we have demonstrated how to overcome this limitation and create a secure and efficient authorisation mechanism.
However, beyond the specific implementation details, the broader aim of this article is to showcase the remarkable strengths of the Rust ecosystem in the context of serverless API development on AWS. Rust’s robustness, performance, and growing set of libraries and frameworks tailored for serverless environments make it a compelling choice for building scalable and reliable APIs. By harnessing the power of Rust, developers can unlock new possibilities and deliver high-quality, performant serverless applications on the AWS platform.
David Steiner
I'm a software engineer and architect focusing on performant cloud-native distributed systems.