Skip to main content

Create an API Gateway Authorizer

This guide reviews how to create an API Gateway authorizer for verifying incoming JWT based access tokens. The benefits of following this guide ensure that you will have a safe implementation while also ensuring high performance.

Below you can find the following:

  1. A lambda authorizer for ensuring incoming JWTs are correctly authorized.
  2. A CloudFormation and SAM template which enables deploying an API Gateway with the Authorizer.

Custom Lambda Authorizer using the Authress SDKโ€‹

This lambda authorizer is a full featured authorizer that optimizes for verifying identities. While API Gateway provides some default authorizers, such as JWT & Cognito, which can often work, they are not optimal. The lack attention to performance as well as extensibility. Further depending on the type of API Gateway you create, they might not always be available.

This Lambda authorizer is written in javascript and depends on a couple of libraries that aren't included in Lambda by default. First install these dependencies:

npm install openapi-factory authress-sdk cookie

This includes the authress-sdk which isn't strictly necessary, but has some great built-ins for better token verifications. Here we'll review a code snippet using the authress-sdk.

Javascript authorizer using the Authress SDK
import { AuthressClient } from 'authress-sdk';
const cookieManager = require('cookie');
const Api = require('openapi-factory');

/*
CONFIGURATION
Update these values with your environment configuration
The url should be your custom domain which can be configured at https://authress.io/app/#/setup?focus=domain
*/
const EXPECTED_ISSUER = 'https://authress.company.com';
/************************************************************/


const api = new Api({});
module.exports = api;

api.setAuthorizer(async request => {
const authorization = Object.keys(request.headers).find(key => key.match(/^Authorization$/i));

const token = request.headers[authorization] ? request.headers[authorization].split(' ')[1] : null;
if (!token) { throw Error('Unauthorized'); }

try {
const cookies = cookieManager.parse(request.headers.cookie || '');
const userToken = cookies.authorization || request.headers.Authorization.split(' ')[1];
const authressClient = new AuthressClient({ authressApiUrl: EXPECTED_ISSUER });
const userIdentity = await authressClient.verifyToken(userToken);

const policy = {
principalId: userIdentity.sub,
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Action: ['execute-api:Invoke'],
Resource: ['arn:aws:execute-api:*:*:*']
}
]
},
context: {
jwt: userToken,
principalId: userIdentity.sub
}
};
return policy;
} catch (error) {
throw Error('Unauthorized');
}
});

Custom Lambda Authorizer for any issuerโ€‹

As above, we'll install the necessary dependencies.

npm install openapi-factory authress-sdk jose axios
Generic javascript authorizer
const axios = require('axios');
const { jwtVerify, importJWK } = require('jose');

/*
CONFIGURATION
Update these values with your environment configuration
*/
const EXPECTED_ISSUER = 'https://authress.company.com';
/************************************************************/

class Authorizer {
constructor() {
this.publicKeysPromises = {};
}

async getPublicKey({ jwkKeyListUrl, addAuthorizationHeader }, kid, token) {
if (!this.publicKeysPromises[jwkKeyListUrl]) {
const headers = Object.assign(addAuthorizationHeader ? { Authorization: `Bearer ${token}` } : {}, { 'User-Agent': 'My Service' });
this.publicKeysPromises[jwkKeyListUrl] = axios.get(jwkKeyListUrl, { headers });
}

try {
const result = await this.publicKeysPromises[jwkKeyListUrl];
const jwk = result.data.keys.find(key => key.kid === kid);
if (jwk) {
return jwk;
}

this.publicKeysPromises[jwkKeyListUrl] = null;
console.log(JSON.stringify({ title: 'PublicKey-Resolution-Failure', level: 'ERROR', kid: kid || 'NO_KID_SPECIFIED', keys: result.data.keys }));
throw Error('Unauthorized');
} catch (error) {
console.log(JSON.stringify({ title: 'Unauthorized', level: 'ERROR', details: 'Failed to get public key', kid: kid || 'NO_KID_SPECIFIED', error: error }));
this.publicKeysPromises[jwkKeyListUrl] = null;
throw Error('Unauthorized');
}
}

async decodeJwt(token) {
try {
return token && {
header: JSON.parse(base64url.decode(token.split('.')[0])),
payload: JSON.parse(base64url.decode(token.split('.')[1]))
};
} catch (error) {
return null;
}
}

async getPolicy(request) {
const authorization = Object.keys(request.headers).find(key => {
return key.match(/^Authorization$/i);
});

const token = request.headers[authorization] ? request.headers[authorization].split(' ')[1] : null;
if (!token) {
console.log(JSON.stringify({ title: 'Unauthorized', level: 'WARN', details: 'no token specified' }));
throw Error('Unauthorized');
}

const unverifiedToken = this.decodeJwt(token);
const kid = unverifiedToken && unverifiedToken.header && unverifiedToken.header.kid;
if (!kid) {
console.log(JSON.stringify({ title: 'Unauthorized', level: 'INFO', details: 'Kid not in token' }));
throw Error('Unauthorized');
}

const issuer = unverifiedToken && unverifiedToken.payload && unverifiedToken.payload.iss;
if (!issuer) {
console.log(JSON.stringify({ title: 'Unauthorized', level: 'INFO', details: 'Issuer not in token' }));
throw Error('Unauthorized');
}

if (issuer !== EXPECTED_ISSUER) {
console.log(JSON.stringify({ title: 'Unauthorized', level: 'INFO', details: 'Issuer not valid' }));
throw Error('Unauthorized');
}

const key = await this.getPublicKey(issuerData, kid, token);

let identity;
try {
const verifiedToken = await jwtVerify(token, await importJWK(key), { algorithms: ['RS256', 'RS512', 'EdDSA'], issuer, audience: issuerData.audience });
identity = verifiedToken.payload;
} catch (exception) {
console.log(JSON.stringify({ title: 'Unauthorized', level: 'INFO', details: 'Invalid Token', error: exception, token: token || '<NO TOKEN>' }));
throw Error('Unauthorized');
}

return {
principalId: identity.sub,
policyDocument: {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Action: [
'execute-api:Invoke'
],
Resource: [
'arn:aws:execute-api:*:*:*'
]
}
]
},
context: {
jwt: token,
principalId: identity.sub
}
};
}
}

module.exports = new Authorizer();

CloudFormation & SAM Templateโ€‹

Here is a CloudFormation template that works with or without SAM. It contains the minimum resources you need to deploy the authorizer and point it at your lambda function. With anything there are additional complexities with the deployment of a lambda function, which are not captured here. In the next section we'll go over an optimized strategy for quickly deploying lambda functions to handle all the edge cases.

AWSTemplateFormatVersion: '2010-09-09'
Description: Lambda Authorizer
Resources:
AuthorizerLambdaFunction:
Type: AWS::Lambda::Function
Properties:
Handler: index.handler
Role: !Ref LambdaRole
Code:
S3Bucket: !Sub
- 'deployment-artifacts-${AWS::AccountId}-${AWS::Region}'
S3Key: function.zip
Runtime: nodejs18.x
Timeout: 30

APIGatewayAuthorizer
Type: AWS::ApiGatewayV2::Authorizer
Properties:
Name: 'DefaultAuthorizer'
ApiId: !Ref HTTPApiGateway
AuthorizerType: REQUEST
AuthorizerPayloadFormatVersion: 2.0
AuthorizerCredentialsArn: !Ref APIGatewayIAMRole
AuthorizerResultTtlInSeconds: 3600
AuthorizerUri: !Ref AuthorizerLambdaFunction
IdentitySource: [$request.header.Authorization]

Additionally required would be the API Gateway V2 as well as the Lambda Function code already waiting in S3.

Deployment Scriptโ€‹

With the function code and the template, we can now deploy the authorizer. However, getting the code to S3 and the template run is also not the easiest. So this following code snippet combines it all together.

Prerequisites:

npm install aws-architect aws-sdk commander
Build lambda.zip and deploy it
const path = require('path');
const aws = require('aws-sdk');
const AwsArchitect = require('aws-architect');
const commander = require('commander');

/*
CONFIGURATION
Update these values with your environment configuration
*/
const REGION = 'eu-west-1';
const AWS_ACCOUNT_ID = '0000000000';
/************************************************************/

aws.config.region = REGION;

const version = `0.0.${process.env.CI_PIPELINE_ID || '0'}`;
commander.version(version);

let packageMetadataFile = path.join(__dirname, 'package.json');
let packageMetadata = require(packageMetadataFile);
packageMetadata.version = version;

let apiOptions = {
deploymentBucket: `deployment-artifacts-${AWS_ACCOUNT_ID}-${REGION}`,
sourceDirectory: path.join(__dirname, 'src'),
description: packageMetadata.description,
regions: [REGION]
};

// This configuration sets up GitLab OIDC, similar can be done for any provider, such as GitHub, that offers OIDC.
async function setupAWS() {
if (!process.env.CI_JOB_JWT_V2) { return; }
try {
aws.config.credentials = new aws.WebIdentityCredentials({
WebIdentityToken: process.env.CI_JOB_JWT_V2,
RoleArn: `arn:aws:iam::${AWS_ACCOUNT_ID}:role/GitlabRunnerAssumedRole`,
RoleSessionName: `GitLabRunner-${process.env.CI_PROJECT_PATH_SLUG}-${process.env.CI_PIPELINE_ID}`,
DurationSeconds: 3600
});

const stsResult = await new aws.STS().getCallerIdentity().promise();
console.log('Configured AWS Credentials', stsResult);
} catch (error) {
console.log('Failed to get AWS Credentials', error);
process.exit(1);
}
}

commander
.command('deploy')
.description('Deploy to AWS.')
.action(async () => {
if (!process.env.CI_COMMIT_REF_SLUG) {
console.log('Deployment should not be done locally.');
return null;
}
await setupAWS();

packageMetadata.version = version;

const awsArchitect = new AwsArchitect(packageMetadata, apiOptions);

try {
const stackTemplate = require('./cloudFormationTemplate.json');
await awsArchitect.validateTemplate(stackTemplate);
await awsArchitect.publishLambdaArtifactPromise();

const stackConfiguration = {
changeSetName: `${process.env.CI_COMMIT_REF_SLUG}-${process.env.CI_PIPELINE_ID || '1'}`,
stackName: packageMetadata.name
};
await awsArchitect.deployTemplate(stackTemplate, stackConfiguration, {});

let publicResult = await awsArchitect.publishAndDeployStagePromise({
stage: 'production',
functionName: packageMetadata.name,
deploymentBucketName: apiOptions.deploymentBucket,
deploymentKeyName: `${packageMetadata.name}/${version}/lambda.zip`
});
console.log(publicResult);
} catch (failure) {
console.log(failure);
process.exit(1);
}
return null;
});

commander.parse(process.argv[2] ? process.argv : process.argv.concat(['deploy']));

Then run:

Execute deploy script.
node make.js deploy # or npm run deploy / yarn deploy