Cookie authentication for AWS API Gateway WebSocket APIs
Tags: aws, security, authentication, websockets
Published: 2021-08-24 09:24:40

# Motivation
This article is intended to describe a specific case of authentication and authorization for WebsocketAPI in AWS ApiGateway when the main web app uses cookies for authentication and authorization.
On the moment of writing, I was unable to find all the information in one place and it took some trial and error to make all the pieces of this puzzle to finally fit together.

# Problem

There is a back-end which sends two cookies when user logs in to the front-end:
1. JWT(secure, http-only)
2. CSRF-TOKEN (secure)
   (*This is a common practice known as [double submit cookie](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie) pattern.*)

Here, JWT is an http-only cookie which means it is not accessible in browsers through JavaScript.
This means, we cannot open a websocket connection and send both tokens in a message.

On the other hand, APIGateway websocket API by default has a url in this format: *wss://{YOUR-API-ID}.execute-api.{REGION}.amazonaws.com/{STAGE}*,
which means no cookies will be sent to *$connect* route integration because the domain of your app will be different (let's say ui.{environment}.yourapplication.com)

I mentioned only *$connect* route here because no other routes (*$default*, *$disconnect* and any custom routes) are able to pass headers because they don't create HTTP requests.

# Solution

### Custom domain name
The first problem to solve is to make cookies be sent to the $connect route integration. This can be done using API Gateway [custom domain names][custom-domain-names] feature.
This involves creating a domainName in the format ws.{environment}.yourapplication.com or any other prefix which has the common part "{environment}.yourapplication.com" with your UI.
You will need to create
- AWS::ApiGatewayV2::DomainName,
- AWS::ApiGatewayV2::ApiMapping
- AWS::Route53::RecordSet (the CNAME)

 This part is described in the AWS documentation and details will depend on your own configuration.

Do not forget to change the domain of the cookie at the back-end to make sure it is matches the common part "{environment}.yourapplication.com" and is not host-only ([Domain attribute](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) must be set in the Set-Cookie header)

### Lambda Authorizer
AWS offers a lambda authorizer as a way to authenticate users through websockets in API Gateway, but since the back-end already has all the knowledge about how to get user info from the cookie, it seemed logical to delegate this task to the back-end.

However, if you pass the cookie directly to the HTTP or VPC integration, the back-end won't be able to cancel the connection, or send back a websocket message within the endpoint assigned to a $connect route, because the websocket connection is being established before the integration happens.
If you have a lambda integration, in theory you still can throw an auth error and websocket connection won't happen. This wasn't my case so I needed to use a lambda authorizer.

There is a clear example of a [lambda authorizer][lambda-authorizer] in the AWS documentation and well, and node.js seemed the good choice because of the short [cold start](https://docs.aws.amazon.com/whitepapers/latest/serverless-architectures-lambda/language-runtime-performance.html) time and relatively simple calculations within the function.

Bear in mind, that if you would like to write this function inline in the cloudformation template, the limit of chars is [4096](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html), and you can use such packages like 'aws-sdk/clients/ssm'(from JS SDK v2) and 'crypto' because they are available in [runtime](https://docs.aws.amazon.com/lambda/latest/dg/lambda-nodejs.html) without the need to create a package.json;
Make sure to initialize ssm and other aws clients outside of the handler function in case the already run lambda will be reused.

If your lambda authorizer logic doesn't depend on the environment, it also makes sense to put it to a separate stack, according to an AWS recommendation to organize stacks by their lifecycles.
You can then reference the lambda from the template with API Gateway either by cross-stack reference or just pass the lambda version in a parameter and update the parameter if you ever need to change this lambda. You can also create alias per environment so you don't need to update lambda version in stacks that use this lambda, but only update the this lambda stack to point aliases to specific versions.

In the minimal lambda stack you will need to create
>1 . AWS::Lambda::Function
>
>2 . AWS::IAM::Role which will be assumed to execute the lambda
>with  AssumeRolePolicyDocument:
>```yaml
>Version: "2012-10-17"
>Statement:
>-
>  Effect: "Allow"
>  Principal:
>    Service:
>      - "lambda.amazonaws.com"
>  Action:
>    - "sts:AssumeRole"
>```
>and ManagedPolicyArns:
>```yaml
>- 'arn:aws:iam::aws:policy/service->role/AWSLambdaBasicExecutionRole'
>```
>and Policies necessary for your lambda to have access to ssm or >any other services used inside lambda. Make sure to follow the >least privilege principle giving these permissions
>
>3 . AWS::Lambda::Version
>
>4 . AWS::Logs::LogGroup in case you have logging

In the API Gateway stack you will need to create
>1 . AWS::ApiGatewayV2::Authorizer
>make sure AuthorizerUri is in the correct format
>```yaml
>    !Join
>    - ''
>    - - 'arn:'
>        - !Ref 'AWS::Partition'
>        - ':apigateway:'
>        - !Ref 'AWS::Region'
>        - ':lambda:path/2015-03-31/functions/'
>        - !Ref YourLambdaVerionArn
>        - /invocations
>```
>and IdentitySource:
>```yaml
>- route.request.header.Cookie
>```
>which is used as a caching key. Auth result is cached for [300 seconds](https://docs.aws.amazon.com/apigateway/latest/developerguide/configure-api-gateway-lambda-authorization-with-console.html) by default
>
>2 . AWS::Lambda::Permission for the authorizer to call your lambda
>
>3 . Change in the AWS::ApiGatewayV2::Route to use your authorizer

>4 . Change your RequestTemplate on $connect route integration to pass all the necessary authorization info from an authorizer (i.e. `$context.authorizer.principalId`)

You will need to parse the value of the Cookie header to extract JWT and CSRF-TOKEN because this value will contain all the cookies at once

Make sure to add all the necessary resources to DependsOn attributes in the CloudFormation templates especially for the API Gateway deployment if you create your deployments through the CloudFormation to ensure resources are created in the correct order.

I hope this article will be helpful for someone facing the same problem.


[custom-domain-names]: https://docs.aws.amazon.com/apigateway/latest/developerguide/websocket-api-custom-domain-names.html
[lambda-authorizer]: https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-websocket-api-lambda-auth.html