DEV Community

Cover image for API Authorization with AWS at Emergency Response Africa
Promise Ogbonna
Promise Ogbonna

Posted on

API Authorization with AWS at Emergency Response Africa

Introduction

Emergency Response Africa is a healthcare technology company that is changing how medical emergencies are managed in Africa.
As you can imagine, this means we manage a lot of web and mobile applications, used internally and externally.

Emergency Response Africa

The importance of securing access to resources from these client applications can't be overstated. The wrong user having access to the wrong resources can cause a lot of problem.

In this post, I'll discuss in detail how we handle Authorization to our internal APIs using Amazon Web Services (AWS) and how we determine the extent of the permissions to assign to the client making the request.

What is Authorization

Authorization is the process of verifying the resources a client has access to. While often used interchangeably with authentication, authorization represents a fundamentally different function. To learn more, read this post on Authentication and Authorization by Auth0.

Our Workflow

Our workflow is pretty simple, and our API is deployed using the Serverless Application Model

ERA Authentication Architecture

In this architecture, we make use of TOKEN Lambda authorizer. This means it expects the caller's identity in a bearer token, such as a JSON Web Token (JWT) or an OAuth token.

  1. The client app calls a method on an Amazon API Gateway API method, passing a bearer token in the header.

  2. API Gateway checks whether a Lambda authorizer is configured for the method. If it is, API Gateway calls the Lambda function.

  3. The Lambda function authenticates the client app by means generating an IAM policy based on the preconfigured settings in our API.

  4. If the call succeeds, the Lambda function grants access by returning an output object containing at least an IAM policy and a principal identifier.

  5. API Gateway evaluates the policy.
    If access is denied, API Gateway returns a suitable HTTP status code, such as 403 ACCESS_DENIED.
    If access is allowed, API Gateway executes the method.

Implementation

The most technical aspect of this post.
TLDR, You can jump straight to the code on GitHub.

  1. First thing, define the resources in our SAM template.

This includes:

  • The API
  • Authorizer
  • Environment variables template.yml.
Globals:
  Function:
    Runtime: nodejs12.x
    Timeout: 540
    MemorySize: 256
    Environment:
      Variables:
        # Environment variables for our application
        STAGE: test
        USER_POOL: eu-west-1_xxxxxxxxx
        REGION: eu-west-1

Resources:
  ApplicationAPI:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref Stage
      Auth:
        DefaultAuthorizer: APIAuthorizer
        Authorizers:
          APIAuthorizer:
            FunctionPayloadType: REQUEST
            # Get the Amazon Resource Name (Arn) of our Authorizer function
            FunctionArn: !GetAtt Authorizer.Arn
            Identity:
              Headers:
              # Define the headers the API would look for. We make use of Bearer tokens so it's stored in Authorization header.
                - Authorization
               # Caching policy; here we define in seconds how long API Gateway should cache the policy for.
              ReauthorizeEvery: 300

  Authorizer:
    Type: AWS::Serverless::Function
    Properties:
      # Reference the relative path to our authorizer handler
      Handler: src/functions/middlewares/authorizer.handler
      Description: Custom authorizer for controlling access to API

Enter fullscreen mode Exit fullscreen mode
  1. We implement our authorization function authorizer.js
const { getUserClaim, AuthError, getPublicKeys, webTokenVerify } = require("./utils");


/**
 * Authorizer handler
 */
exports.handler = async (event, context, callback) => {
  const principalId = "client";

  try {
    const headers = event.headers;

    const response = await getUserClaim(headers);
    return callback(null, generatePolicy(principalId, "Allow", "*", response));
  } catch (error) {
    console.log("error", error);
    const denyErrors = ["auth/invalid_token", "auth/expired_token"];

    if (denyErrors.includes(error.code)) {
      // 401 Unauthorized
      return callback("Unauthorized");
    }

    // 403 Forbidden
    return callback(null, generatePolicy(principalId, "Deny"));
  }
};

/**
 * Generate IAM policy to access API
 */
const generatePolicy = function (principalId, effect, resource = "*", context = {}) {
  const policy = {
    principalId,
    policyDocument: {
      Version: "2012-10-17",
      Statement: [
        {
          Action: "execute-api:Invoke",
          Effect: effect,
          Resource: resource,
        },
      ],
    },
    context, // Optional output with custom properties of the String, Number or Boolean type.
  };

  return policy;
};

/**
 * Grant API access to request
 * @param {object} h Request headers
 */
exports.getUserClaim = async (h) => {
  try {
    const authorization = h["Authorization"] || h["authorization"];

    const token = authorization.split(" ")[1];
    const tokenSections = (token || "").split(".");
    if (tokenSections.length < 2) {
      throw AuthError("invalid_token", "Requested token is incomplete");
    }

    const headerJSON = Buffer.from(tokenSections[0], "base64").toString("utf8");
    const header = JSON.parse(headerJSON);
    const keys = await getPublicKeys();
    const key = keys[header.kid];
    if (key === undefined) {
      throw AuthError("invalid_token", "Claim made for unknown kid");
    }

    // claims is verified.
    const claims = await webTokenVerify(token, key.pem);
    return { claims: JSON.stringify(claims) };
  } catch (error) {
    const message = `${error.name} - ${error.message}`;
    if (error.name === "TokenExpiredError")
      throw AuthError("expired_token", message);

    if (error.name === "JsonWebTokenError")
      throw AuthError("invalid_token", message);

    throw error;
  }
};

Enter fullscreen mode Exit fullscreen mode
  1. We implement our utils file utils.js
const { promisify } = require("util");
const fetch = require("node-fetch");
const jwkToPem = require("jwk-to-pem");
const jsonwebtoken = require("jsonwebtoken");

/**
 * Get public keys from Amazon Cognito
 */
exports.getPublicKeys = async () => {
  const issuer = `https://cognito-idp.${process.env.REGION}.amazonaws.com/${process.env.USER_POOL}`;
  const url = `${issuer}/.well-known/jwks.json`;
  const response = await fetch(url, { method: "get" });
  const publicKeys = await response.json();

  return publicKeys.keys.reduce((total, currentValue) => {
    const pem = jwkToPem(currentValue);
    total[currentValue.kid] = { instance: currentValue, pem };
    return total;
  }, {});
};

/**
 * Using JSON Web Token we verify our token
 */
exports.webTokenVerify = promisify(jsonwebtoken.verify.bind(jsonwebtoken));

/**
 * Generate Auth Error
 */
exports.AuthError = (code, message) => {
  const error = new Error(message);
  error.name = "AuthError";
  error.code = `auth/${code}`;
  return error;
};



Enter fullscreen mode Exit fullscreen mode
  1. We define helper functions to help us parse our event request.

Our claims is stored in event.requestContext.authorizer.
From our authorization function above we are only able to pass strings from our API Gateway authorizer, so it's stringified in the claims objects

helpers.js

 * Parse claims from event request context
 * @param {import("aws-lambda").APIGatewayProxyEvent} event
 */
exports.parseClaims = (event) => {
  return JSON.parse(event.requestContext.authorizer.claims);
};

Enter fullscreen mode Exit fullscreen mode

Conclusion

This rounds up our implementation.
This post serves as a reference to how we implemented authorization in our API, any further updates to our workflow would be made on this post.

For more clarification, you can reach out to me on Email or Twitter

Resources

Use API Gateway Lambda authorizers

Top comments (0)