Hints for Lambda Authorizers Returning Policy Documents
…it is useful to use appropriate HTTP status codes [and] [i]t is this desire for traceability that requires additional work, and that typical Internet searches have difficulty resolving quickly.
Hints for Lambda Authorizers Returning Policy Documents
You may have created a Lambda-based AWS API Gateway Authorizer at some point, and had significant issues surrounding rule-processing and reporting errors to clients.
The conceptual flow is described in the AWS documentation.
This conversation revolves around JWTs as a bearer token, but JWTs aren’t the only artifact that can be presented to an API Authorizer.
In order to best-suit our consumers, it is useful to use appropriate HTTP status codes–401s should be thrown for token/auth issues, and 403s should be thrown for issues outside of strict token scope: failing flow or control rules, or failing procedural or business rules.
An argument could be made (security-by-opacity versus open inspection) for rejecting with a 401 tokens that don’t present the appropriate permissions (e.g. User:Read role presented to an endpoint of /users/updateProfile should result in a 403), but I would reject these with a 403–I know who the bearer represents, they just don’t have the required permissions. For me, this open inspection (for the vast majority of items in API design) is much more valuable for logging/debugging/metrics/analysis than trying to obfuscate errors surrounding role assertions and authentication.
It is this desire for traceability that requires additional work, and that typical Internet searches have difficulty resolving quickly.
From the Beginning: What’s a JWT?
A JWT is a type of OAuth2.0 token, and provides authorization through its presence–hence it being known as a bearer token–if you have the token, you are its principal. JWTs are covered under RFC 7519.
JWTs have the following general structure, separated by periods:
- Header
- Payload
- Signature
The signature is optional (though if you’re using JWTs for RBAC, you will definitely want to sign and validate JWTs using PKI, and likely will want to encrypt the payload as well), while the other two sections are required.
This is a valid JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkNocmlzIFBpbHNvbiIsInRpdGxlIjoiQ2F0IFdyYW5nbGVyIiwicm9sZXMiOlt7IlVzZXIiOiJSZWFkIn0seyJVc2VyIjoiV3JpdGUifV0sImlhdCI6MTUxNjIzOTAyMn0.ZirHk88tqgS0FYGWJ4JYz6OXzuS319LHaemv04kLd4o
JWTs are passed to APIs in the Authorization header: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkNocmlzIFBpbHNvbiIsInRpdGxlIjoiQ2F0IFdyYW5nbGVyIiwicm9sZXMiOlt7IlVzZXIiOiJSZWFkIn0seyJVc2VyIjoiV3JpdGUifV0sImlhdCI6MTUxNjIzOTAyMn0.ZirHk88tqgS0FYGWJ4JYz6OXzuS319LHaemv04kLd4o
The JWT above has this payload, into which I’ve placed some profile information and RBAC-capable roles:
{
"sub": "1234567890",
"name": "Chris Pilson",
"title": "Cat Wrangler",
"roles": [
{
"User": "Read"
},
{
"User": "Write"
}
],
"iat": 1516239022
}
As-before, a call presenting a JWT to an API indicates, I am this user or service by its presentation. Again, it is important to note that a JWT is an authorization device rather than an authentication device; if a JWT is captured and presented by a bad actor, and it hasn’t expired, then that bad actor is the user or service described in the JWT.
An in-depth discussion on JWT security is beyond the scope of this post, but it is vital to understand the role that JWTs play in an API request, and the importance surrounding using security controls to ensure they aren’t tampered with or captured or otherwise be permitted to escape a very narrow use.
The Problem
If, for instance, you have some code that looks similar to this JWT-based flow (presented as mostly-Python pseudo-code, which isn’t a language I am strong in generating, but a language which is easy to read):
import (
jwt
logger
json
)
logger.setLevel(INFO)
def validateToken(token):
# Validate the JWT using the jwt library.
...
def processSomeBusinessRules(event):
# Some business rules get processed here; if these rules fail, we should throw a FORBIDDEN 403.
...
def handler(event, context):
try:
# Format: Bearer eyxxxxxx.yyyyyyyy.zzzzzzz
token = event["headers"]["Authorization].split[' '][1]
validateToken(token)
processSomeBusinessRules(event)
except Exception as ex:
logger.error(ex)
raise Exception("Unauthorized")
This will result in a 401 being raised if the token has issues (e.g. expired, nbf claim not met, signature invalid, etc.), and also if some business rules were violated. This is a bad experience for a consumer, and we can do better.
A Solution
We will need to touch the API Gateway Responses section of AWS API Gateway, Access Denied (403) response, within which the Lambda Authorizer is attached.
We will need to edit the response from the default to accept some data from the authorizer.
Using OASv3, you can add a vendor tag to your specification file. In our case, we’ll want to attach this to the ACCESS_DENIED type, and to accommodate our code below, we’ll want to change the application/json responseTemplates to include "{\"message\": \"$context.authorizer.messageString"}\".
From the AWS documentation, buried pretty deep in a table inside a link off the primary documentation trail:
| Parameter | Description |
|---|---|
| $context.authorizer.property | The stringified value of the specified key-value pair of the context map returned from an API Gateway Lambda authorizer function. |
Hence, we’re setting up our API Gateway to look at the returned object from our Authorizer Lambda, and parse $.context.messageString for passing into a response to our end consumer, in the form of a message-{error message} KV pair.
How do we do this? We can add a little bit of code to our above example:
import (
jwt
logger
json
)
logger.setLevel(INFO)
class BusinessRulesFailed(Exception):
"Raised when business rules fail"
pass
def buildPolicyDocumentWithContext(event, statusCode, message, errorType="")
errorMap = {
401: "Unauthorized",
403: "Forbidden"
}
principalId = ... # Get the principalId from the token. It'll be the 'sub' claim usually.
policy = {
"principalId": principalId,
"policyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Action": "*",
"Effect": "Deny",
"Resource": event["methodArn"]
}
]
}
}
context = {
"statusCode": statusCode,
"errorType": errorMap[statusCode] if statusCode in errorMap else errorType,
"messageString": message
}
# Attach the context to our policy document response going out.
policy["context"] = context
return policy
def validateToken(token):
# Validate the JWT using the jwt library.
...
def processSomeBusinessRules(event):
# Some business rules get processed here; if these rules fail, we should throw a FORBIDDEN 403.
# Note that we EARLY-EXIT here. We could also process all the rules and just push to an 'errors' array if we wanted.
if (someBusinessRuleFails):
errorMessage = "Some business rule failed."
logger.error(errorMessage)
raise BusinessRulesFailed(errorMessage)
if (anotherBusinessRuleFails):
errorMessage = "Some other business rule failed."
logger.error(errorMessage)
raise BusinessRulesFailed(errorMessage)
def handler(event, context):
try:
# Format: Bearer eyxxxxxx.yyyyyyyy.zzzzzzz
token = event["headers"]["Authorization].split[' '][1]
validateToken(token)
processSomeBusinessRules(event)
except BusinessRulesFailed as ex:
logger.error(ex)
if APIGW_OUTPUT_SUPPORTS_THIS:
return buildPolicyDocumentWithContext(event, 403, str(ex))
else:
# Generic Unauthorized Exception raised
raise Exception("Unauthorized")
except Exception as ex:
logger.error(ex)
raise Exception("Unauthorized")
Now your consumers will get back 403s that contain insight into what went wrong, like this:
{
"message": "Some business rule failed."
}
This provides actionable, supportable insight into just what’s going wrong, as opposed to our default 401/403 response catch-all.