Deploying AWS API Gateway with IAM Auth using OpenAPI 3.0.1 and Terraform

Ben Booth
DAZN Engineering
Published in
5 min readFeb 26, 2019

Deploying AWS API Gateway with Terraform has never been an enjoyable experience for me. I tend to get lost in the Terraform configs, trying to connect all the associated resources together in my mind, trying to make sense of the apparent complexity. This is most likely due to my general lack of experience with AWS API Gateway, but I tend to find other AWS services much easier to understand when writing Terraform code.

A solution to this complexity is to describe the API using OpenAPI 3.0.1 spec, and deploy it the AWS API Gateway using Terraform.

Defining the API

Let’s say we want to create an API to perform CRUD on a DynamoDB table using a Lambda behind API Gateway.
We could use the following OpenAPI 3.0.1 spec to describe this:

{
“openapi”: “3.0.1”,
“info”: {
“title”: “my_api”,
“version”: “2019–02–26T13:01:33Z”
},
“servers”: [
{
“url”: “https://api.mydomain.com"
}
],
“paths”: {
“/{id}”: {
“get”: {
“parameters”: [
{
“name”: “id”,
“in”: “path”,
“required”: true,
“schema”: {
“type”: “string”
}
}
],
“security”: [
{
“sigv4”: []
}
],
“x-amazon-apigateway-integration”: {
“uri”: “arn:aws:apigateway:eu-central-1:lambda:path/2015–03–31/functions/arn:aws:lambda:eu-central-1:1234567890:function:my_lambda/invocations”,
“requestParameters”: {
“integration.request.path.id”: “method.request.path.id”
},
“passthroughBehavior”: “when_no_match”,
“timeoutInMillis”: 29000,
“httpMethod”: “POST”,
“type”: “aws_proxy”
}
},
“put”: {
“parameters”: [
{
“name”: “id”,
“in”: “path”,
“required”: true,
“schema”: {
“type”: “string”
}
}
],
“security”: [
{
“sigv4”: []
}
],
“x-amazon-apigateway-integration”: {
“uri”: “arn:aws:apigateway:eu-central-1:lambda:path/2015–03–31/functions/arn:aws:lambda:eu-central-1:1234567890:function:my_lambda/invocations”,
“requestParameters”: {
“integration.request.path.id”: “method.request.path.id”
},
“passthroughBehavior”: “when_no_match”,
“timeoutInMillis”: 29000,
“httpMethod”: “POST”,
“type”: “aws_proxy”
}
},
“delete”: {
“parameters”: [
{
“name”: “id”,
“in”: “path”,
“required”: true,
“schema”: {
“type”: “string”
}
}
],
“security”: [
{
“sigv4”: []
}
],
“x-amazon-apigateway-integration”: {
“uri”: “arn:aws:apigateway:eu-central-1:lambda:path/2015–03–31/functions/arn:aws:lambda:eu-central-1:1234567890:function:my_lambda/invocations”,
“requestParameters”: {
“integration.request.path.id”: “method.request.path.id”
},
“passthroughBehavior”: “when_no_match”,
“timeoutInMillis”: 29000,
“httpMethod”: “POST”,
“type”: “aws_proxy”
}
}
},
“/”: {
“get”: {
“security”: [
{
“sigv4”: []
}
],
“x-amazon-apigateway-integration”: {
“uri”: “arn:aws:apigateway:eu-central-1:lambda:path/2015–03–31/functions/arn:aws:lambda:eu-central-1:1234567890:function:my_lambda/invocations”,
“passthroughBehavior”: “when_no_match”,
“timeoutInMillis”: 29000,
“httpMethod”: “POST”,
“type”: “aws_proxy”
}
},
“post”: {
“security”: [
{
“sigv4”: []
}
],
“x-amazon-apigateway-integration”: {
“uri”: “arn:aws:apigateway:eu-central-1:lambda:path/2015–03–31/functions/arn:aws:lambda:eu-central-1:1234567890:function:my_lambda/invocations”,
“passthroughBehavior”: “when_no_match”,
“timeoutInMillis”: 29000,
“httpMethod”: “POST”,
“type”: “aws_proxy”
}
}
}
},
“components”: {
“securitySchemes”: {
“sigv4”: {
“type”: “apiKey”,
“name”: “Authorization”,
“in”: “header”,
“x-amazon-apigateway-authtype”: “awsSigv4”
}
}
},
“x-amazon-apigateway-policy”: {
“Version”: “2012–10–17”,
“Statement”: [
{
“Sid”: “”,
“Effect”: “Allow”,
“Principal”: {
“AWS”: [“arn:aws:iam::1234567890:role/POWERUSER”]
},
“Action”: “execute-api:Invoke”,
“Resource”: [
“arn:aws:execute-api:eu-central-1:1234567890:<restapi-id>/*/PUT/*”,
“arn:aws:execute-api:eu-central-1:1234567890:<restapi-id>/*/POST/”,
“arn:aws:execute-api:eu-central-1:1234567890:<restapi-id>/*/DELETE/*”
]
},
{
“Sid”: “”,
“Effect”: “Allow”,
“Principal”: {
“AWS”: “*”
},
“Action”: “execute-api:Invoke”,
“Resource”: [
“arn:aws:execute-api:eu-central-1:1234567890:<restapi-id>/*/GET/*”,
“arn:aws:execute-api:eu-central-1:1234567890:<restapi-id>/*/GET/”
]
}
]
}
}

As you can see from the spec, it’s actually very easy to understand how the API has been configured including the invocation of the Lambda.

IAM Authentication

Once thing I didn’t mention is IAM auth. Looking at the spec we can see `x-amazon-apigateway-policy`. This is configured to allow anyone with a valid IAM user or role to perform GET requests. Users who have the role POWERUSER can also perform PUT, POST, and DELETE.

Attaching this policy is not enough on it’s own, we also need to switch on the “AWS_IAM” authorisation:



“components”: {
“securitySchemes”: {
“sigv4”: {
“type”: “apiKey”,
“name”: “Authorization”,
“in”: “header”,
“x-amazon-apigateway-authtype”: “awsSigv4”
}
}
},

Notice here that AWS_IAM authorisation is refered to as `sigv4`.

Now we can add sigv4 authenentication to the methods:



“post”: {
“security”: [
{
“sigv4”: []
}
],

},

Ok, so now we have a readable, self-documenting API definition with IAM authorisation, that can be used to build out AWS API Gateway.

We can use the following terraform to achieve this using a template for the OpenAPI spec:


resource “aws_api_gateway_rest_api” “main” {
name = “my_api”
description = “My API”
body = “${data.template_file.api_gateway_openapi_spec.rendered}”
}
resource “aws_api_gateway_deployment” “stage” {
rest_api_id = “${aws_api_gateway_rest_api.main.id}”
stage_name = “v1”
}

All done!

Testing the endpoint

As we have configured IAM Authentication to every AWS API Gateway method, we cannot simply curl the endpoint. We need to sign our requests using an AWS V4 Signature.

In Go we can achieve a GET request like so:
package main

import (
“fmt”
“log”
“net/http”
“net/http/httputil”
“time”

"github.com/aws/aws-sdk-go/aws/credentials”
v4 “github.com/aws/aws-sdk-go/aws/signer/v4”
)
const apiURL = “https://api.mydomain.com"func main() {
// Get our AWS credentials from the Environment
creds := credentials.NewEnvCredentials()
// Create a new v4 Signer
signer := v4.NewSigner(creds)
// Create a new GET request
req, err := http.NewRequest(“GET”, apiURL, nil)
if err != nil {
log.Fatal(err)
}
// Presign the http.Request object.
//
// Note that the AWS service we are signing against is ‘execute-api’ and not ‘api-gateway’.
header, err := signer.Presign(req, nil, “execute-api”, “eu-central-1”, 10*time.Second, time.Now())
if err != nil {
log.Fatal(err)
}

// Set http.Request.Header to be the http.Header returned from the Presign function.
req.Header = header
client := &http.Client{} // Execute the request
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
// and dump the response
dump, err := httputil.DumpResponse(resp, true)
if err != nil {
log.Fatal(err)
}
fmt.Printf(“%q”, dump)
}

Things get a little more complicated when performing requests that contain a body, as the body need signing too:

package mainimport (
“fmt”
“log”
“net/http”
“net/http/httputil”
“time”
“github.com/aws/aws-sdk-go/aws/credentials”
v4 “github.com/aws/aws-sdk-go/aws/signer/v4”
)
const apiURL = “https://api.mydomain.com"func main() {
// Get our AWS credentials from the Environment
creds := credentials.NewEnvCredentials()
// Create a new v4 Signer
signer := v4.NewSigner(creds)
// Define a struct
type data struct {
ID string
Name string
Email string
}
// Create an instance of our struct. This will be the body of our POST request
d := &data{
ID: 1,
Name: “Alice”,
Email: “alice@mydomain.com”,
}
// Marshal the struct to a JSON byte slice.
b, err := json.Marshal(&d)
if err != nil {
log.Fatal(err)
}
// Create a new POST request
//
// We now pass the body as a *bytes.Reader
req, err := http.NewRequest(“POST”, apiURL, bytes.NewReader(b))
if err != nil {
log.Fatal(err)
}
// Presign the http.Request object.
//
// We also sign the body by passing it in as a *bytes.Reader
//
// Note that the AWS service we are signing against is ‘execute-api’ and not ‘api-gateway’.
header, err := signer.Presign(req, bytes.NewReader(b), “execute-api”, “eu-central-1”, 10*time.Second, time.Now())
if err != nil {
log.Fatal(err)
}
// Set http.Request.Header to be the http.Header returned from the Presign function.
req.Header = header
// NOTE: Here we set the req.Body to be the body again! If we don’t do this we lose http.Request.Body
req.Body = ioutil.NopCloser(bytes.NewReader(body))
client := &http.Client{} // Execute the request
resp, err := client.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
// …and dump the response
dump, err := httputil.DumpResponse(resp, true)
if err != nil {
log.Fatal(err)
}
fmt.Printf(“%q”, dump)
}

And there we have it. Not so complex after all.

--

--