Importing/Exporting Serverless Custom Authorizers Across Services
James Stratford
Posted on April 7, 2021
Ever been faced with the issue of running out of custom authorizers for your Lambdas with Serverless in AWS? We ran out, and needed to consolidate ours for our API which is used across multiple microservices. AWS only lets you have 10 separate authorizers (though you can ask AWS for more, BUT they're quite against this), so this is an issue many will have when following the pattern of using one common API in this way. Indeed it's a problem that comes up on issues on Serverless' GitHub - e.g. here. The Serverless documentation alludes to how to share an authorizer, but doesn't show how to do it across services. In this article, I'll outline how we got round the problem with some examples in both yaml and TypeScript.
Solution
Our solution was to create a common-authorizer service, with the authorizers in this service exported to allow them to be imported in multiple other Serverless stacks.
Planning
The first step was relatively simple, going to the API Gateway Authorizers console on AWS and listing all the authorizers which were currently live, and then comparing this to the authorizers that were being used in various services to see which were performing the same functionality (e.g. both only allowing the same groups on a specific user pool). Collating this list showed we could go from 20+ authorizers down to 8 separate ones. Luckily, the number that were live in prod was 19, and we had 8 authorizers all fulfilling the same purpose, so could go from 19 down to 12 in one deployment (by adding 1 then removed8), then 12 down to 8 in a second deployment.
Common-authorizer Service
From here it was a matter of bringing all the authorizers into one service. At a basic level, this is defining all the custom auth Lambdas in a common-auth service and then exporting a reference for these Lambdas to be used elsewhere using Outputs and Imports. This is similar to how you'd simply reference the Lambda object with the name of the authorizer if it was in the same Serverless service, just with extra steps!
In order to ensure that the authorizer objects (AWS::ApiGateway::Authorizer) themselves are created in the authorizer stack, it's not good enough to just declare the custom auth Lambda functions in the common authorizer service; they need to become Authorizers here too. There's two main options here, either you can explicitly add it as an AWS::ApiGateway::Authorizer object by using CloudFormation inside your serverless.yml file, or you can attach it to a Lambda as the authorizer on an http endpoint. We chose to go for the latter, as this set-up led to the ability to create short integration tests that could test that our authorizers were serving the appropriate groups/pools correctly.
{
...
functions: {
basicUserAuthorizer: {
handler: "handler.basicUserAuthorizer"
},
testAuthorizerEndpoint: {
handler: "handler.testAuthorizerEndpoint",
events: [
{
http: {
method: 'get',
path: 'test/basicAuth',
authorizer: {
type: "token",
name: BasicUserAuthorizer,
arn: { "Fn::GetAtt": ["BasicUserAuthorizerLambdaFunction", "Arn"] },
resultTtlInSeconds: 0
}
}
}
]
}
}
}
We can then export the AWS::ApiGateway::Authorizer object Serverless has created for us to be used in other services.
{
...
resources: {
Outputs: {
"BasicUserAuthorizerId": {
"Export": {
"Name": "BasicUserAuthorizerId-dev"
},
"Value": {
"Ref": "BasicUserAuthorizerApiGatewayAuthorizer"
}
}
}
}
}
Where basicAuthorizer
is the name of the custom authorizer Lambda function declared in the same file. (Note, Serverless will capitalise the first letter of your Lambda resource's name, hence our Ref
is pointing at the BasicUserAuthorizerApiGatewayAuthorizer
object)
Other Services
Warning, once you start deploying services which reference your authorizer-service exports you won't be able to change the export name!
Now we have our BasicAuthorizer being exported, we can import it into our other stacks using the magic of the Intrinsic CloudFormation function ImportValue
! This can be done like so:
...
functions:
saveUserAddress:
handler: handler.saveUserAddress
events:
- http:
path: /example/save
authorizer:
type: 'CUSTOM'
authorizerId:
'Fn::ImportValue': BasicAuthorizerId-${self:provider.stage}
...
or in TypeScript.. (since due to the age of some of our services we have a mix of TS and yaml stacks)
...
functions: {
saveUserAddress: {
handler: "handler.saveAddress",
events: [
{
http: {
path: "/exampleTwo/save",
authorizer: {
type: "CUSTOM",
authorizerId: {
"Fn::ImportValue": "BasicAuthorizer-${self:provider.stage}"
},
},
}
}
]
}
}
As documented here, if you're specifying the AuthorizerId for an API Gateway method (what Serverless is doing under the hood) you must "specify CUSTOM
or COGNITO_USER_POOLS
for this property"(i.e. the type). This annoyingly is referring to a different authorier-type property than the one we referred to above as a token
!
Also worth noting here is that I've used a reference to the stage to allow this to be used across stages. Additionally, due to a yaml parsing issue with our CI and exclamation marks, I've used the more verbose Fn::ImportValue
here - if you don't have the parsing issue then feel free to use !ImportValue
inline!
And that's it! Your non-auth service should now be pulling the reference to the authorizer using your imported AuthorizerId, and therefore is able to share authorizers with other services! Make sure to test the authorized endpoints to ensure they're still performing as you expect, and once you're happy you can delete your old authorizer code.
Of Note
Integration/Deployment
A small change had to be made to our pipeline process in order to allow for the authorizer stack to always be deployed first. A simplified example for what occurs in dev is as follows:
diff=$(git whatchanged --name-only --pretty="" origin..HEAD)
matches=$(echo "$diff" | grep --color=never / || echo "")
changedDirectories=$(echo "$matches" | cut -d/ -f1 | sort -u)
authorizationChangedDirectories=$(echo "$changedDirectories" | grep auth- || true)
otherChangedDirectories=$(echo "$changedDirectories" | grep -v auth- || true)
echo "$authorizationChangedDirectories" | xargs --no-run-if-empty -n 1 -P 10 ./.circleci/deploy dev
echo "$otherChangedDirectories" | xargs --no-run-if-empty -n 1 -P 10 ./.circleci/deploy dev
What's going on here is we're using git whatchanged
to work out the difference between what's on master and what's currently in the branch, obtaining the directories of the files changed, and then splitting that list into auth and non-auth services. These lists are then split up using xargs
to call the deployment script.
resultTtlInSeconds
Due to the way API Gateway by default will cache (300s) the specific policy generated by an authorizer for a user, you get 403s on the second and beyond endpoint a user tries to access with an accessToken (if the authorizer in question is returning a specific resource in its policy like ours does).
E.g. if you have a user who tries to access foo/123
, then a policy is generated for them saying they have permission to access foo/123
, which is good if that user only ever accesses that endpoint. However, if they then want to go ahead and access foo/345
moments later, then the cached policy remains, saying they only have access to foo/123
returning a 403 Forbidden response.
Helper Functions
Part of the beauty of using TypeScript for our serverless file is that you can use helper functions to keep IaaC dry. We made use of two here in the common authorizer service, and then one to reference common authorizers in other services.
Common authorizer service
(1) Create Authorizer objects for the common-authorizer service:
export const commonAuthorizer = (lambdaName: string) => {
// N.B. lambdaName string must start with a capital letter
return {
type: 'token',
name: lambdaName,
arn: { "Fn::GetAtt": [`${lambdaName}LambdaFunction`, "Arn"] },
resultTtlInSeconds: 0
};
};
Which when lambdaName
is the name of the Lambda function you want to become an authorizer means you can simplify your events property of your testAuthorizerEndpoint
events: [
http: {
method: 'get',
path: 'test/basicAuth',
authorizer: commonAuthorizer("BasicUserAuthorizer")
},
http: {
method: 'get',
path: 'test/powerAuth',
authorizer: commonAuthorizer("PowerUserAuthorizer")
}
]
(2) Create Authorizer exports for the common-authorizer service to simplify your Outputs:
export const exportAuthorizerId = (authorizerLambdaName: string, stage: string = '${self:provider.stage}') => {
// N.B. authorizerLambdaName string must start with a capital letter
return {
Export: {
Name: `${authorizerLambdaName}Id-${stage}`
},
Value: {
"Ref": `${authorizerLambdaName}ApiGatewayAuthorizer`
}
};
};
Which when authorizerLambdaName
is the name of the custom Lambda function, can then simplify your exports section to look like this:
{
...
resources: {
Outputs: {
basicUserAuth: exportAuthorizerId('BasicUserAuthorizer'),
powerUserAuth: exportAuthorizerId('PowerUserAuthorizer')
}
}
}
Other services
(3) Create Authorizer imports for other services to be able to use the authorizers from the common authorizer service.
export const authObject = (authorizerName: string, stage: string = '${self:provider.stage}') => {
return {
type: 'CUSTOM',
authorizerId: {
'Fn::ImportValue': `${authorizerName}Id-${stage}`
}
}
}
Which when authorizerName
is the name of the required custom Lambda function from the common authorizer service can simplify your authorizer objects to look like this
functions: {
saveUserAddress: {
handler: "handler.saveAddress",
events: [
{
http: {
path: "/exampleTwo/save",
authorizer: authObject("BasicUserAuthorizer")
}
}
]
}
}
An extra bonus here is you can change the type of the authorizerName
parameter to a custom type that you define as the names of your authorizers to ensure you're using the correct names!
Thanks for reading
If you got this far, thanks for reading - I hope this helps you if you're stuck with the same situation we were!
Further Info
Serverless's documentation on variables - notably those on Cloudformation stack outputs
Yan's guide to selecting your API Gateway auth method - we're in the bucket of using group-based authentication therefore custom Lambdas.
Posted on April 7, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.