Here’s how we used AWS Cognito’s authorizer to enable users to have developer API access restricted to their user data using Client Id/Secret.
For a SaaS product, we needed to allow users to have API access to our product’s services. This enabled users to build custom capabilities by accessing and leveraging our APIs via an authenticated, authorized, and controlled access mechanism.
This requirement is not uncommon. Many services, such as Spotify, or Trello, or Digital Ocean (among many others) allow API based access for developers to build custom services.
The requirement for API access
Our product needed to enable the following capability:
- Users (belonging to a particular role) should be offered the ability to obtain API access in the form of a Client Id and a Client Secret.
- This API access would be bound to that user’s data. In other words, using these API client id/secret automatically binds each API call as if the user has logged in. The scope of data available will be bound to data that belongs to that user and/or data that is available to any logged-in user.
- Users can enable this API access and obtain the Client Id/Client Secret
- Users can reset the API key associated with their account implying that the older client id/secret pairs are permanently disabled.
- A front-end login for users.
- A client id/secret based access for other systems.
- Implement user-based access control.
API access requirements in technical terms
To understand these requirements in technical terms, we first need to understand our tech stack. We are using the following services
- AWS Cognito for managing user authentication to access the site’s UI. Users are managed in a Cognito User Pool.
- AWS API Gateway for managing access control, throttling and monitoring for our APIs
- AWS Lambdas for executing our code
- Our code was using Python and Flask
- To support API access, we are using AWS Cognito User Pool Apps that provide an app client id and client secret.
Cognito provides a User Pool to manage users. Users logging in via a UI would be authenticated by Cognito and all requests to the API would now have a bearer token. The bearer token contains the Cognito username or the user’s email. Our code thereby authorizes the call to operate only within that user scope.
The AWS API gateway allows access to authenticated APIs via the use of app client id/secret. However, these id/secret pairs only determine whether a call to the API passes through to the code or results in an Unauthorized or Forbidden HTTP error. Cognito does not enable any way to link an app to a Cognito user. Hence, no user-level information is associated with successful calls using the client id/secret. Thus, any additional authorization such as access to a specific user scope by our code is directly not possible when using these API client id/secret.
So, what we needed to do is to programmatically allow the creation, update, delete, and query of a two-way mapping of users and client id. Given a client id, we need to determine which user this client id authorizes. Similarly, given a Cognito Username, we need to create, obtain, reset the client id/secret associated with that user.
Our solution for Developer API Access
Usually, such a use-case would require writing a custom Lambda authorizer. But can also be done using AWS Cognito Authorizer as outlined below.
A mapping database
We start by creating a database mapping a three-way database mapping in our codebase. Conceptually it is a simple table (let’s call it ID_Mapping_Table) with the following schema and indexes on each of the fields
|Internal User_id||Cognito Username||Cognito Client_Id|
In this table,
- Internal User_id is a unique identification of the user internal to our system,
- Cognito Username is the username (or email address) provided by the Cognito bearer token when a user authenticated by the Cognito user pool accesses our API, and
- Cognito Client_Id is the app client id provided by the Cognito access token when an API call using app client id/secret is successfully made to our API
When users are added/provisioned in our system, we ensure that their Internal User_id and their Cognito Username are stored in this table.
To support app client id/secret, we provide the following API endpoints:
- Create_app_client: this endpoint strictly checks the presence of a bearer token that has the Cognito Username (implying that the call is being made from an authenticated user of our UI). The bearer token contains the Cognito Username which can be used to look up the ID_Mapping_Table. This endpoint sets up a new Cognito app client supporting the OAuth 2.0 client credentials grant flow. Additionally, it updates the ID_Mapping_Table with the app client id of this created app client. We now have the three-way mapping of Internal User_id, Cognito Username, and Cognito Client_Id.
- Reset_app_client: similar to the Create_app_client except that it removes any existing app client prior to setting up a new one. The older client id is now permanently disabled.
We have a Python decorator that is associated with all authenticated calls. The decorator performs the following tasks:
- Checks whether an access token with the Cognito Username is provided in the request header. If so, it looks up the ID_Mapping_Table for the Cognito Username and passes the Internal User_id to our code.
- Checks whether the access token contains the client id (but no Cognito Username). In this case, the call is coming from an API request using a valid app client id/secret pair. The decorator looks up the ID_Mapping_Table for the Cognito Client_Id and passes the Internal User_id to our code.
- In other words, our code protected by this decorator is guaranteed to obtain an Internal user_id and can scope all the processing to that user’s scope.
With this approach, we have enabled user-based access control via direct authenticated use of app client ids and secrets. At Ignite, we continually look for such patterns to convert them to frameworks that all our projects can use.