Larissa
Larissa

Reputation: 138

Terraform API Gateway OPTIONS pre-flight not being communicated

I have an AWS API Gateway REST API with Lambda Proxy integration created with Terraform. The Lambdas perform CRUD operations on a DynamoDB table. Cognito User Pool Authorizer is set for anything that isn't a GET or OPTIONS request.

I've configured OPTIONS preflight as a MOCK integration in a Terraform module with Access-Control-Allow-Headers, Access-Control-Allow-Methods, Access-Control-Allow-Origin for all resources (modified from this article on Medium):

# api/cors/main.tf
resource "aws_api_gateway_method" "cors_method" {
  rest_api_id   = var.api_id
  resource_id   = var.resource_id
  http_method   = "OPTIONS"
  authorization = "NONE"
}

resource "aws_api_gateway_method_response" "cors_method_response" {
  rest_api_id = var.api_id
  resource_id = var.resource_id
  http_method = aws_api_gateway_method.cors_method.http_method
  status_code = "200"

  response_models = {
    "application/json" = "Empty"
  }

  response_parameters = {
    "method.response.header.Access-Control-Allow-Headers" = true
    "method.response.header.Access-Control-Allow-Methods" = true,
    "method.response.header.Access-Control-Allow-Origin"  = true,
  }

  depends_on = [aws_api_gateway_method.cors_method]
}

resource "aws_api_gateway_integration" "cors_integration" {
  rest_api_id      = var.api_id
  resource_id      = var.resource_id
  http_method      = aws_api_gateway_method.cors_method.http_method

  type = "MOCK"

  depends_on = [aws_api_gateway_method.cors_method]
}

resource "aws_api_gateway_integration_response" "cors_integration_response" {
  rest_api_id = var.api_id
  resource_id = var.resource_id
  http_method = aws_api_gateway_method.cors_method.http_method
  status_code = aws_api_gateway_method_response.cors_method_response.status_code

  response_parameters = {
    "method.response.header.Access-Control-Allow-Headers" = "'${join(",", var.headers)}'"
    "method.response.header.Access-Control-Allow-Methods" = "'${join(",", var.methods)}'",
    "method.response.header.Access-Control-Allow-Origin"  = "'${join(",", var.origins)}'",
  }

  depends_on = [aws_api_gateway_method_response.cors_method_response]
}
# api/cors/variables.tf
variable "api_id" {}

variable "resource_id" {}

variable "origins" {
  type    = list(string)
  default = ["http://localhost:3000"]
}

variable "methods" {
  type = list(string)
}

variable "headers" {
  type    = list(string)
  default = ["Content-Type", "X-Amz-Date", "Authorization", "X-Api-Key", "X-Amz-Security-Token"]
}
# api/main.tf

# API, other API resources, deployment, stage also defined here

# /users/{username}/follow
resource "aws_api_gateway_resource" "follow" {
  rest_api_id = aws_api_gateway_rest_api.api.id
  parent_id   = aws_api_gateway_resource.username.id
  path_part   = "follow"
}

module "FollowCORS" {
  source      = "./cors"
  api_id      = aws_api_gateway_rest_api.api.id
  resource_id = aws_api_gateway_resource.follow.id
  methods     = ["DELETE", "OPTIONS", "PUT"]
}

All Lambda functions return the same response headers as OPTIONS:

// Lambda for this endpoint/method

const AWS = require('aws-sdk');

// Set up DynamoDB DocumentClient

exports.handler = async (event) => {
  let body = {};
  let statusCode = 200;
  const headers = {
    'Access-Control-Allow-Headers':
      'Accept,Authorization,Content-Type,X-Amz-Date,X-Amz-Security-Token',
    'Access-Control-Allow-Methods': 'DELETE,OPTIONS,PUT',
    'Access-Control-Allow-Origin': '*', // temporary update as per suggestion
    'Content-Type': 'application/json',
    Vary: 'Origin',
  };

  // original `Access-Control-Allow-Origin` setting
  // const allowedOrigins = ['http://localhost:3000'];
  // const origin = event.headers.origin || '';
  // if (allowedOrigins.includes(origin)) {
  //   headers['Access-Control-Allow-Origin'] = origin;
  // }

  console.log('Event:\n', event);

  // Check/Get `cognito:username` from event

  try {
    // DELETE operation on DynamoDB table

    body.isFollowing = false;
  } catch (err) {
    console.error('Error:\n', err);

    statusCode = 500;
    body = { error: { message: err.message } };
  }

  return { statusCode, headers, body: JSON.stringify(body) };
};

I'm able to successfully interact with all endpoints via Postman and can make GET requests from my Next.js app (useSWR, fetch, axios all OK).

The problem is I can't make any other requests (DELETE, PATCH, POST, PUT) with axios or fetch:

axios
  .delete(`${API_BASE_URL}/users/testuser/follow`, {
    headers: {
      Authorization: `Bearer ${id_token}`,
    },
  })
  .then((response) => {
    console.log(response);
  })
  .catch((error) => {
    console.log(error);
  });
fetch(`${API_BASE_URL}/users/testuser/follow`, {
  method: 'DELETE',
  headers: {
     Authorization: `Bearer ${id_token}`,
 },
})
  .then((res) => res.json())
  .then((data) => {
    console.log(data);
  });

where API_BASE_URL is https://${API_ID}.execute-api.{REGION}.amazonaws.com/{STAGE}. The item I'm attempting to DELETE does exist (created via Postman since PUT request also fails with same error).

I get the following error:

Access to fetch at 'https://{API_BASE_URL}/users/testuser/follow' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

I also get the

TypeError: Failed to fetch

when the fetch request fails.

The calls made via axios and fetch don't seem to even hit the API because no CloudWatch logs get created.

Also, the axios request headers shown in the Network tab only have Referer: http://localhost:3000/ but no Origin: http://localhost:3000. No response headers are shown:

Network tab screenshot showing no Origin and no response headers

As suggested in the comments, I tested the OPTIONS method for this endpoint with Postman, but get the following error:

... not a valid key=value pair (missing equal-sign) in Authorization header ...

I know this error appears when there are other issues (e.g. wrong method used), so I'm guessing something is wrong with my preflight config.

The same error occurs for all other endpoints and methods.

What's blocking these requests from being made?

Postman response headers for DELETE request

Screenshot of Postman response headers

Terraform v1.2.7 on windows_amd64

hashicorp/aws version ~> 4.26

Upvotes: 2

Views: 1825

Answers (2)

Larissa
Larissa

Reputation: 138

To anyone else experiencing this issue (and using Terraform), before you spend days headbanging your keyboard, did you try turning it off and on again? terraform destroy, then terraform apply.

I did also make modifications to my code, so I can't provide the satisfaction of pointing to a line number and saying that was the issue. It seems the changes weren't being deployed properly until I "turned it off, then on again".

Alas, the following is a condensed version of my updated code with the changes indicated:

Terraform

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 4.26"
    }
  }
}

provider "aws" {
  profile = "default"
  region  = "ca-central-1"
}

# Cognito
# ...

# Rest API

resource "aws_api_gateway_rest_api" "test_api" {
  name        = "test_api"
  description = "Test REST API"

  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

resource "aws_api_gateway_authorizer" "authorizer" {
  name            = "CognitoUserPoolAuthorizer"
  rest_api_id     = aws_api_gateway_rest_api.test_api.id
  identity_source = "method.request.header.Authorization"
  type            = "COGNITO_USER_POOLS"
  provider_arns   = ["${aws_cognito_user_pool.user_pool.arn}"]
}

resource "aws_api_gateway_resource" "test_resource" {
  rest_api_id = aws_api_gateway_rest_api.test_api.id
  parent_id   = aws_api_gateway_rest_api.test_api.root_resource_id
  path_part   = "test"
}

# Lambda Proxy

resource "aws_api_gateway_method" "test_method" {
  rest_api_id   = aws_api_gateway_rest_api.test_api.id
  resource_id   = aws_api_gateway_resource.test_resource.id
  http_method   = "POST"
  authorization = "COGNITO_USER_POOLS"
  authorizer_id = aws_api_gateway_authorizer.authorizer.id
}

resource "aws_api_gateway_method_response" "test_method_response" {
  rest_api_id = aws_api_gateway_rest_api.test_api.id
  resource_id = aws_api_gateway_resource.test_resource.id
  http_method = aws_api_gateway_method.test_method.http_method
  # -------------------------------------------------------------------------
  # 1. Changed value from `string` to `int` ("200" -> `200`)
  # -------------------------------------------------------------------------
  status_code = 200 

  response_parameters = {
    "method.response.header.Access-Control-Allow-Origin" = true,
  }

  depends_on = [aws_api_gateway_method.test_method]
}

resource "aws_lambda_function" "test_handler" {
  function_name = "test_handler"

  handler = "index.handler"
  runtime = "nodejs16.x"

  filename         = "../lambda/index.zip"
  source_code_hash = filebase64sha256("../lambda/index.zip")

  role = aws_iam_role.test_lambda_role.arn

  memory_size = "128"
  timeout     = "5"
}

resource "aws_iam_role" "test_lambda_role" {
  name = "test_lambda_role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Sid    = ""
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

resource "aws_iam_policy" "test_lambda_policy" {
  name = "test_lambda_policy"
  path = "/"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["logs:CreateLogStream", "logs:CreateLogGroup", "logs:PutLogEvents"]
        Resource = "arn:aws:logs:ca-central-1:*:*"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_role_policy" {
  role       = aws_iam_role.test_lambda_role.name
  policy_arn = aws_iam_policy.test_lambda_policy.arn
}

resource "aws_api_gateway_integration" "test_integration" {
  rest_api_id = aws_api_gateway_rest_api.test_api.id
  resource_id = aws_api_gateway_method.test_method.resource_id
  http_method = aws_api_gateway_method.test_method.http_method

  integration_http_method = "POST"
  type                    = "AWS_PROXY"

  uri = aws_lambda_function.test_handler.invoke_arn

  depends_on = [aws_api_gateway_method.cors_method, aws_lambda_function.test_handler]
}

resource "aws_lambda_permission" "test_permission" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.test_handler.function_name
  principal     = "apigateway.amazonaws.com"

  # -------------------------------------------------------------------------
  # 2. Updated `source_arn` to be more specific
  #    previously `${aws_api_gateway_rest_api.test_api.execution_arn}/*/*/*}`
  #    the remaining `*` enables permissions for all API stages
  # ------------------------------------------------------------------------- 
  source_arn = "${aws_api_gateway_rest_api.test_api.execution_arn}/*/${aws_api_gateway_method.test_method.http_method}${aws_api_gateway_resource.test_resource.path}"
}

# CORS

resource "aws_api_gateway_method" "cors_method" {
  rest_api_id   = aws_api_gateway_rest_api.test_api.id
  resource_id   = aws_api_gateway_resource.test_resource.id
  http_method   = "OPTIONS"
  authorization = "NONE"
}

resource "aws_api_gateway_method_response" "cors_method_response" {
  rest_api_id = aws_api_gateway_rest_api.test_api.id
  resource_id = aws_api_gateway_resource.test_resource.id
  http_method = aws_api_gateway_method.cors_method.http_method
  # -------------------------------------------------------------------------
  # 3. Changed `status_code` value from `string` to `int` ("200" -> `200`)
  # ------------------------------------------------------------------------- 
  status_code = 200 

  response_models = {
    "application/json" = "Empty"
  }

  response_parameters = {
  # -------------------------------------------------------------------------
  # 4. Added Access-Control-Allow-Credentials
  #    calls with `Authorization` header fail without this
  # ------------------------------------------------------------------------- 
    "method.response.header.Access-Control-Allow-Credentials" = true,
    "method.response.header.Access-Control-Allow-Headers" = true
    "method.response.header.Access-Control-Allow-Methods" = true,
    "method.response.header.Access-Control-Allow-Origin"  = true,
  }

  depends_on = [aws_api_gateway_method.cors_method]
}

resource "aws_api_gateway_integration" "cors_integration" {
  rest_api_id = aws_api_gateway_rest_api.test_api.id
  resource_id = aws_api_gateway_resource.test_resource.id
  http_method = aws_api_gateway_method.cors_method.http_method

  type = "MOCK"

  # -------------------------------------------------------------------------
  # 5. Added `request_templates`
  # ------------------------------------------------------------------------- 
  request_templates = {
    "application/json" = "{ \"statusCode\": 200 }"
  }

  depends_on = [aws_api_gateway_method.cors_method]
}

resource "aws_api_gateway_integration_response" "cors_integration_response" {
  rest_api_id = aws_api_gateway_rest_api.test_api.id
  resource_id = aws_api_gateway_resource.test_resource.id
  http_method = aws_api_gateway_method.cors_method.http_method
  status_code = aws_api_gateway_method_response.cors_method_response.status_code

  # careful with double/single quotes here
  response_parameters = {
  # -------------------------------------------------------------------------
  # 6. Added Access-Control-Allow-Credentials
  #    calls with `Authorization` header fail without this
  # ------------------------------------------------------------------------- 
    "method.response.header.Access-Control-Allow-Credentials" = "'true'"
  # -------------------------------------------------------------------------
  # 7. Simplified `Access-Control-Allow-Headers` to `Authorization,Content-Type` for methods with 
  #    `COGNITO_USER_POOLS` authorization and `Content-Type` only for methods without authorization
  # ------------------------------------------------------------------------- 
    "method.response.header.Access-Control-Allow-Headers" = "'Authorization,Content-Type'" 
    "method.response.header.Access-Control-Allow-Methods" = "'OPTIONS,POST'",
    "method.response.header.Access-Control-Allow-Origin"  = "'${join(",", ["http://localhost:3000"])}'",
  }

  depends_on = [aws_api_gateway_method_response.cors_method_response]
}

resource "aws_api_gateway_deployment" "test_deployment" {
  rest_api_id = aws_api_gateway_rest_api.test_api.id

  depends_on = [aws_api_gateway_integration.test_integration]
}

resource "aws_api_gateway_stage" "api_stage" {
  deployment_id = aws_api_gateway_deployment.test_deployment.id
  rest_api_id   = aws_api_gateway_rest_api.test_api.id
  stage_name    = "dev"
}

# CloudWatch
# ...

Lambda

exports.handler = async (event) => {
  let body = {};
  let statusCode = 200;
  // 8. Return same `Access-Control-Allow-Headers` as `Integration Response`
  const headers = {
    'Access-Control-Allow-Credentials': true,
    'Access-Control-Allow-Headers': 'Authorization,Content-Type',
    'Access-Control-Allow-Methods': 'OPTIONS,POST',
    'Content-Type': 'application/json',
    Vary: 'Origin',
  };

  const allowedOrigins = ['http://localhost:3000'];
  const origin = event.headers?.origin || '';
  if (allowedOrigins.includes(origin)) {
    headers['Access-Control-Allow-Origin'] = origin;
  }

  console.log('Event:\n', event);

  const currentUsername = event.requestContext.authorizer.claims['cognito:username'] || '';

  if (!currentUsername) {
    statusCode = 401;
    body = JSON.stringify({ error: { message: 'Unauthorized' } });

    return { statusCode, headers, body };
  }

  const payload = JSON.parse(event.body);

  if (!payload.test) {
    statusCode = 400;
    body = JSON.stringify({ error: { message: 'Invalid input' } });

    return { statusCode, headers, body };
  }

  body.greeting = `Hello from the other side, ${currentUsername}`;
  body.test = payload.test;

  return { statusCode, headers, body: JSON.stringify(body) };
};

Prior to the issue being fixed, I noticed the API endpoint for the Lambda's API Gateway trigger (Using the AWS Console: Lambda > Functions > function_name > Configuration > Triggers) had the following error:

The API with ID API_ID doesn’t include a resource with path /* having an integration arn:aws:lambda:REGION:ACCOUNT_ID:function:FUNCTION_NAME on the ANY method.

whereas now there is a clickable link with the complete resource path (as per updated aws_lambda_permission Terraform config). I'm not sure how much this was contributing to the problem.

May the CORS be with you.

Upvotes: 1

Mark
Mark

Reputation: 409

You need to ensure the "Access-Control-Allow-Origin" field in the lambda header response matches the OPTIONS response. It could be worth setting them both to * temporarily to check there are no other issues...

Lambda response header:

'headers': {
    "Content-Type": "application/json",
    "Access-Control-Allow-Origin": "*"
},

OPTIONS response:

{
  "Access-Control-Allow-Headers": ["Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token"],
  "Access-Control-Allow-Methods": ["DELETE,OPTIONS,PUT"],
  "Access-Control-Allow-Origin": ["*"],
  "Content-Type":["application/json"]
}

Also it would be interesting to see the response you get hitting the endpoint from POSTMAN. It's possible that the gateway Integration Response isn't passing through the lambda header values, and needs to be configured.

Upvotes: 0

Related Questions