DEV Community

loading...
Cover image for Importing/Exporting Serverless Custom Authorizers Across Services

Importing/Exporting Serverless Custom Authorizers Across Services

jcts3 profile image James Stratford ・6 min read

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
                  }
               }
            }
         ]
      }
   }
}

Enter fullscreen mode Exit fullscreen mode

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"
            }
         }
      }
   }
}
Enter fullscreen mode Exit fullscreen mode

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}
...
Enter fullscreen mode Exit fullscreen mode

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}"
                  },
               },
            }
         }
      ]
   }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  };
};
Enter fullscreen mode Exit fullscreen mode

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")
   }
]
Enter fullscreen mode Exit fullscreen mode

(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`
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

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')
      }
   }
}
Enter fullscreen mode Exit fullscreen mode

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}`
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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")
            }
         }
      ]
   }
}
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (0)

pic
Editor guide