API Authorization with AWS at Emergency Response Africa
Promise Ogbonna
Posted on October 4, 2021
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.
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
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.
The client app calls a method on an Amazon API Gateway API method, passing a bearer token in the header.
API Gateway checks whether a Lambda authorizer is configured for the method. If it is, API Gateway calls the Lambda function.
The Lambda function authenticates the client app by means generating an IAM policy based on the preconfigured settings in our API.
If the call succeeds, the Lambda function grants access by returning an output object containing at least an IAM policy and a principal identifier.
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.
- 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
- 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;
}
};
- 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;
};
- 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);
};
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
Posted on October 4, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.