DEV Community

Arpad Toth for AWS Community Builders

Posted on • Originally published at arpadt.com

4 1

Implementing advanced authorization with AWS Lambda for endpoint-specific access

When handling more complex authorization patterns, we can implement the necessary logic using Cognito Lambda triggers and authorizer functions.

1. The scenario

I’m working on a maths contest application where participants move from one checkpoint to another, solving engaging maths puzzles. Each checkpoint has a task assigned to teams based on their year group.

Checkpoints are staffed by invigilators or supervisors who give the tasks to participants. Students step aside to solve the task, then share their - hopefully - correct answer with the supervisor, who logs it into the application.

Naturally, the application runs on AWS serverless infrastructure. 😄

One key requirement is that supervisors at any checkpoint can only access tasks assigned to their specific checkpoint. They should not see puzzles from other checkpoints.

The API Gateway includes an endpoint structured like this:

API_URL/checkpoints/:id
Enter fullscreen mode Exit fullscreen mode

This endpoint returns data for the application to display about the corresponding checkpoint. The application has many endpoints, but, in this article, I will focus on the /checkpoints/:id endpoint to explain the solution.

So, supervisors at Checkpoint 1 can only call /checkpoints/1, those at Checkpoint 2 can call /checkpoints/2, and so forth.

The application uses a Cognito user pool to manage users, including the supervisors.

2. Challenges

As I planned the solution for this scenario, I encountered several challenges. I will address the following ones below.

2.1. Endpoint protection

Supervisors, regardless of the checkpoint, call the same endpoint, with the :id path parameter being the only difference. How can I implement endpoint authorization to reject calls when the path parameter does not match the supervisor’s assigned checkpoint?

2.2. Client-side

The client dynamically adds the path parameter to the URL based on the supervisor’s checkpoint. How does the client determine which path parameter to use when making the call?

2.3. Consistency

The client needs to call all endpoints, including /checkpoints/:id, using an access token as outlined by OAuth 2.0.

3. Pre-requisites

This post won't go into detail about how to create and configure the following:

  • A Cognito user pool, groups and a pre-token generation trigger.
  • An app client with resource servers and scopes.
  • A REST-type API Gateway and its integrations.
  • Lambda functions.

Links to the relevant documentation pages will be provided at the end of the post for those who need them.

4. Solution

Here’s one solution that works for my scenario.

4.1. Overview

The solution relies on two Lambda functions.

Authorization flow

The first function triggers after user authentication, but before the user pool issues tokens. The second serves as the endpoint authorizer.

Before implementing these, though, we need to assign supervisors to the appropriate Cognito groups.

4.2. Groups

I opted for straightforward group names for supervisor assignments: checkpoint-1, checkpoint-2, and so on. Cognito allows up to 10,000 groups per user pool, which should be plenty unless our maths contest grows to an enormous scale.

Supervisors at Checkpoint 1 are added to the checkpoint-1 group, and the same pattern applies until all supervisors are assigned to their respective groups.

4.3. Resource servers

I configured a dedicated resource server and custom scopes in the user pool for supervisor authorization management.

I won’t explain the setup process here - I covered resource servers and custom scopes in a separate article.

The custom scopes follow this format: checkpoint/1, checkpoint/2, and so on. Supervisors at Checkpoint 1 receive the checkpoint/1 scope, those at Checkpoint 2 get checkpoint/2, and the pattern continues for all checkpoints.

4.4. Pre-token generation trigger

We should tackle two tasks: let the client know the correct path parameter and attach the matching scope to the access token.

A pre-token generation Lambda trigger can handle both. Once connected to the user pool, Cognito calls the Lambda function with a payload like this:

{
  // Some properties are omitted for brevity
  "region": "eu-central-1",
  "userPoolId": "USER_POOL_ID",
  "userName": "USER_NAME",
  "request": {
    "userAttributes": {
      "sub": "USER_ID_IN_COGNITO",
    },
    "groupConfiguration": {
      "groupsToOverride": [
        // WE NEED THIS:
        "checkpoint-1",
      ],
    },
    "scopes": [
      "openid"
    ]
  },
  "response": {
    "claimsAndScopeOverrideDetails": null
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the pre-token generation Lambda trigger receives the supervisor’s assigned checkpoint! The function can then add this information to the ID and access tokens.

ID tokens carry details about the authenticated user. We can include a custom checkpoint_id variable to store the checkpoint ID. The front-end client can pull this ID from the token and dynamically insert it as a path parameter in the URL.

Access tokens contain authorization details in the scope property. The pre-token generation trigger can add the relevant scope to the access token.

Here’s what the function’s (pseudo) code might look like:

export async function handler(event) {
  // 1. Generate scopes
  const groups = event.request.groupConfiguration.groupsToOverride;
  const userScopes = generateUserScopesFromCognitoGroups(groups);

  // 2. Get the checkpoint ID for supervisors
  const checkpointId = extractCheckpointIdFromScope(userScopes);

  // 3. Add the info to the tokens
  return {
    ...event,
    response: {
      claimsAndScopeOverrideDetails: {
        // 3.a. Checkpoint ID to the ID token
        idTokenGeneration: {
          claimsToAddOrOverride: {
            checkpoint_id: checkpointId.toString(),
          },
        },
        // 3.b. Scopes to the access token
        accessTokenGeneration: {
          scopesToAdd: ['openid', ...userScopes],
        },
      },
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

The generateUserScopesFromCognitoGroups utility converts a Cognito group name like checkpoint-1 into a scope format, checkpoint/1.

The extractCheckpointIdFromScope utility pulls the checkpoint ID 1 from checkpoint-1.

Once the function runs, the ID token includes this property:

"checkpoint_id": "1"
// other ID token properties
Enter fullscreen mode Exit fullscreen mode

The access token reflects the corresponding checkpoint scope:

"scope": "openid checkpoint/1"
// other access token properties
Enter fullscreen mode Exit fullscreen mode

With these enriched tokens, the client can extract the checkpoint_id from the ID token, append it to the URL path, and call the endpoint with the access token.

4.5. Lambda authorizer

Now, the client calls the correct URL with the checkpoint ID in the path and the access token in the Authorization header. The final step is to confirm that the checkpoint ID in the endpoint matches the scope in the token.

Cognito authorizers are too rigid for this case. Using one would require attaching all checkpoint scopes to the endpoint, allowing any supervisor to call it with any checkpoint ID.

Lambda authorizers work well for custom authorization logic. They are standard Lambda functions that receive specific authorizer payloads from API Gateway.

Of the two payload types, TOKEN and REQUEST, I chose REQUEST for this scenario. I have covered TOKEN-type payloads elsewhere, so I will focus on REQUEST payloads here.

A Lambda authorizer with a REQUEST payload gets an event object like this:

{
  // properties are omitted for brevity
  "type": "REQUEST",
  "methodArn": "arn:aws:execute-api:eu-central-1:ACCOUNT_ID:API_ID/STAGE_NAME/GET/checkpoints/1",
  "resource": "/checkpoints/{id}",
  "path": "/checkpoints/1",
  "httpMethod": "GET",
  "headers": {
    // WE NEED THIS
    "Authorization": "Bearer ACCESS_TOKEN",
  },
  "pathParameters": {
    // AND THIS
    "id": "1"
  },
  "stageVariables": {},
  "requestContext": {
    // lots of properties
  }
}
Enter fullscreen mode Exit fullscreen mode

The event includes everything the authorizer needs to compare the :id path parameter with the access token’s scope.

Here’s a snippet of the authorizer’s (pseudo) code:

export async function handler(event) {
  const { headers, pathParameters, methodArn: resource } = event;

  // 1. Get the token from the Authorization header
  const token = extractAuthorizationToken(headers);
  // 2. Verify and decode the token
  const tokenPayload = verifyAccessToken(token);

  // 3. Get the "sub" and "scope" properties from the token
  const { sub: userId, scope } = payload;
  // 4. Get the checkpoint ID from the invoked URL path
  const checkPointIdInPath =
    extractCheckpointIdFromPathParameters(pathParameters);

  // 5. Compare the checkpoint IDs from the path and the token
  const matchingCheckpointIds =
    matchPathParameterIdToScope(checkPointIdInPath, scope);
  // 6. Return IAM policies as per the result
  return matchingCheckpointIds
    ? generatePolicy({ resource, effect: Effect.ALLOW, principalId: userId })
    : generatePolicy({ resource, effect: Effect.DENY, principalId: userId });
}

// Util function to generate the policy
function generatePolicy({ resource, effect, principalId }) {
  return {
    policyDocument: {
      Version: '2012-10-17',
      Statement: [
        {
          Action: 'execute-api:Invoke',
          Resource: resource,
          Effect: effect,
        },
      ],
    },
    principalId,
  };
}
Enter fullscreen mode Exit fullscreen mode

These steps outline the logic described earlier.

The authorizer function must return an IAM policy. If the checkpoint IDs match, the policy will Allow the request to proceed. If not, it will Deny it.

The principalId that identifies the user making the call is a mandatory attribute. The sub property from the token, the user’s unique ID in the user pool, serves this purpose and identifies the supervisor.

5. Considerations

As noted, Cognito groups are a practical way to organize supervisors by their checkpoint assignments.

If we wanted to adapt this approach for an application with a larger user base, creating and managing groups might become impractical.

In that case, we could store user details and their properties in a separate database like DynamoDB. The pre-token trigger function could then fetch the user’s profile from the database.

This setup might be easier to manage in a rapidly changing environment, though it adds an extra API call to the database, which could slow down the response time.

6. Summary

Combining a pre-token generation trigger with a custom API Gateway authorizer function allows for more sophisticated authorization flows.

The pre-token generation function adds custom details to the ID and access tokens. Then, a Lambda authorizer can enforce the custom authorization logic.

7. Further reading

Getting started with user pools - How to create a user pool with an app client

Adding groups to a user pool - The title says it all

Pre token generation Lambda trigger - Detailed reference for adding a trigger

Creating an app client - How to create an app client for a user pool

Get started with API Gateway - How to create an API Gateway resource

Create your first function - AWS Lambda "Getting started"

Hostinger image

Get n8n VPS hosting 3x cheaper than a cloud solution

Get fast, easy, secure n8n VPS hosting from $4.99/mo at Hostinger. Automate any workflow using a pre-installed n8n application and no-code customization.

Start now

Top comments (0)

Best Practices for Running  Container WordPress on AWS (ECS, EFS, RDS, ELB) using CDK cover image

Best Practices for Running Container WordPress on AWS (ECS, EFS, RDS, ELB) using CDK

This post discusses the process of migrating a growing WordPress eShop business to AWS using AWS CDK for an easily scalable, high availability architecture. The detailed structure encompasses several pillars: Compute, Storage, Database, Cache, CDN, DNS, Security, and Backup.

Read full post

👋 Kindness is contagious

If this post resonated with you, feel free to hit ❤️ or leave a quick comment to share your thoughts!

Okay