TerraformでAPIGatewayにCognitoをオーソライザとして使用する環境を構築してみた Part2

Part2ではAPIGatewayの方を構築していきます。Part1で作成したCognitoをオーソライザとして使用する構成です。 またAPIGatewayのAPIをコールするとLambdaを実行するようにしたいので、一緒にLambdaも作成してます。 Lambdaはnodejs18.xを使用します。

gole

フォルダ構成

terraform/
 ├ modules/
 │ └ cognito/
 │   ├ iam.tf
 │   ├ locals.tf
 │   ├ main.tf
 │   ├ output.tf
 │   └ variable.tf
 │ └ api_gateway/
 │   ├ main.tf
 │   ├ output.tf
 │   └ variable.tf
 │ └ lambda/
 │   └ function/
 │     └ index.mjs
 │   ├ iam.tf
 │   ├ main.tf
 │   ├ output.tf
 │   └ variable.tf
 ├ main.tf
 ├ variables.tf
 ├ output.tf
 └ provider.tf 

モジュールにAPIGateway(api_gateway/)とLambda(lambda/)を追加しています。

コード説明

※Part1で記述済みのコード(Cognitoに関連するもの)は省略しています。

メイン

main.tf

APIGatewayにLambdaとユーザプールのarnを渡しています。

LambdaにはAPIGatewayの情報を渡しています。

module "api_gateway" {
  source = "./modules/api_gateway"
  lambda_invoke_arn = module.lambda.lambda_invoke_arn
  user_pool_arn = module.cognito.user_pool_arn
}

module "lambda" {
  source = "./modules/lambda"
  api_gateway_arn = module.api_gateway.api_gateway_arn
  api_gateway_method = module.api_gateway.api_gateway_method
  api_gateway_resouce_path_part = module.api_gateway.api_gateway_resouce_path_part
}

output.tf

APIGatewayのURLを取得したいので、出力させておきます。

output api_gateway_url {
  value = module.api_gateway.api_gateway_url
}

モジュール[API Gateway]

module/api_gateway/main.tf

今回はREST APIを構築しています。 まずはリクエスト側の設定(aws_api_gateway_method, aws_api_gateway_integration)でパスやメソッドを定義し、 レスポンス側の設定(aws_api_gateway_method_response, aws_api_gateway_integration_response)でレスポンスの内容を定義しています。 ※「aws_api_gateway_integration_response」は「aws_api_gateway_integration」の構築が完了するのを待って作成する必要があるようです。

残りのaws_api_gateway_deploymentとaws_api_gateway_stageを設定すればAPIGatewayとしての設定は完了ですが、 今回はPart1で作成したCognito ユーザプールをオーソライザとして使用するために「aws_api_gateway_authorizer」を記述しています。

resource "aws_api_gateway_rest_api" "api_gateway" {
  name = "sample-api-gateway"
}

resource "aws_api_gateway_resource" "resource" {
  parent_id   = aws_api_gateway_rest_api.api_gateway.root_resource_id
  path_part   = "sample-resource"
  rest_api_id = aws_api_gateway_rest_api.api_gateway.id
}

resource "aws_api_gateway_method" "method" {
  http_method   = "GET"
  resource_id   = aws_api_gateway_resource.resource.id
  rest_api_id   = aws_api_gateway_rest_api.api_gateway.id
  authorization = "COGNITO_USER_POOLS"
  authorizer_id = aws_api_gateway_authorizer.authorizer.id
}

resource "aws_api_gateway_method_response" "method_response" {
  rest_api_id = aws_api_gateway_rest_api.api_gateway.id
  resource_id = aws_api_gateway_resource.resource.id
  http_method = aws_api_gateway_method.method.http_method
  status_code = "200"
}

resource "aws_api_gateway_integration" "integration" {
  http_method = aws_api_gateway_method.method.http_method
  resource_id = aws_api_gateway_resource.resource.id
  rest_api_id = aws_api_gateway_rest_api.api_gateway.id
  integration_http_method = "POST"
  type                    = "AWS"
  uri         = var.lambda_invoke_arn
}

resource "aws_api_gateway_integration_response" "integration_response" {
  rest_api_id = aws_api_gateway_rest_api.api_gateway.id
  resource_id = aws_api_gateway_resource.resource.id
  http_method = aws_api_gateway_method.method.http_method
  status_code = aws_api_gateway_method_response.method_response.status_code
  
  # aws_api_gateway_integrationの完成後にリソース作成されるように依存関係を設定しておく
  depends_on = [
    aws_api_gateway_integration.integration,
  ]

  # Transforms the backend JSON response to XML
  response_templates = {
    "application/xml" = <<EOF
#set($inputRoot = $input.path('$'))
<?xml version="1.0" encoding="UTF-8"?>
<message>
    $inputRoot.body
</message>
EOF
  }
}

resource "aws_api_gateway_deployment" "deployment" {
  rest_api_id = aws_api_gateway_rest_api.api_gateway.id
  triggers = {
    redeployment = sha1(jsonencode([
      aws_api_gateway_resource.resource.id,
      aws_api_gateway_method.method.id,
      aws_api_gateway_integration.integration.id,
    ]))
  }
  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_api_gateway_stage" "stage" {
  deployment_id = aws_api_gateway_deployment.deployment.id
  rest_api_id   = aws_api_gateway_rest_api.api_gateway.id
  stage_name    = "sample-api-gateway-stage"
}

resource "aws_api_gateway_authorizer" "authorizer" {
  name                   = "sample-authorizer"
  rest_api_id            = aws_api_gateway_rest_api.api_gateway.id
  type                   = "COGNITO_USER_POOLS"
  provider_arns          = [var.user_pool_arn]
  # ID ソースの指定
  identity_source        = "method.request.header.Authorization"
  # キャッシュされたオーソライザーの結果の存続時間 (TTL) (秒単位)
  authorizer_result_ttl_in_seconds = 300
}
modules/api_gateway/variable.tf

ユーザプールとLambdaのarnをAPIGatewayモジュールに渡すための変数を作成しておきます。

variable lambda_invoke_arn {}
variable user_pool_arn {}
modules/api_gateway/output.tf

Lambdaモジュールへ情報を渡すための各種出力と、実際にコールするAPIのURLを構築してメインコードから参照するための出力(api_gateway_url )を定義しておきます。

output api_gateway_arn {
  value = aws_api_gateway_rest_api.api_gateway.execution_arn
}
output api_gateway_method {
  value = aws_api_gateway_method.method.http_method
}
output api_gateway_resouce_path_part {
  value = aws_api_gateway_resource.resource.path_part
}

output api_gateway_url {
  # https://{restapi_id}.execute-api.{region}.amazonaws.com/{stage_name}/
  value = "${aws_api_gateway_stage.stage.invoke_url}${aws_api_gateway_resource.resource.path}"
}

モジュール[Lambda]

modules/lambda/function/index.mjs

今回はLambdaの実行に特に目的がないので、レスポンスコード200で「Hello World」を返すだけのコードを作成しておきます。

export const handler = async (event) => {
  const response = {
    statusCode: 200,
    body: JSON.stringify('Hello World!'),
  };
  return response;
};
modules/lambda/main.tf

使用するコードを指定してLambdaを構築し、「aws_lambda_permission」でAPIGatewayから呼び出されることを許可するように設定します。

data "archive_file" "lambda" {
  type        = "zip"
  source_dir = "modules/lambda/function"
  output_path = "lambda_function_payload.zip"
}

resource "aws_lambda_function" "lambda" {
  function_name = var.function_name
  filename      = "lambda_function_payload.zip"
  handler       = "index.handler"
  role          = aws_iam_role.iam_for_lambda.arn
  source_code_hash = data.archive_file.lambda.output_base64sha256

  runtime = "nodejs18.x"

  environment {
    variables = {
      env= "sample"
    }
  }
}

resource "aws_lambda_permission" "permission" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.lambda.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${var.api_gateway_arn}/*/${var.api_gateway_method}/${var.api_gateway_resouce_path_part}"
} 
modules/lambda/iam.tf

Lambdaがログの出力ができるように権限を付けておきます。

data "aws_iam_policy_document" "assume_role" {
  statement {
    effect = "Allow"

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }

    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role" "iam_for_lambda" {
  name               = "iam_for_lambda"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

resource "aws_iam_policy" "lambda_policy" {
  name        = "example-lambda-policy"
  description = "IAM policy for the example Lambda function"

  policy = jsonencode({
    Version   = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = [
          aws_cloudwatch_log_group.lambda_log_group.arn,
          "${aws_cloudwatch_log_group.lambda_log_group.arn}:*"
        ]
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_policy_attachment" {
  role       = aws_iam_role.iam_for_lambda.name
  policy_arn = aws_iam_policy.lambda_policy.arn
}

resource "aws_cloudwatch_log_group" "lambda_log_group" {
  name = "/aws/lambda/${aws_lambda_function.lambda.function_name}"
  # ログを残しておく期間
  retention_in_days = 30
}
modules/lambda/output.tf

LambdaのarnをAPIGatewayモジュールに渡せるように出力しておきます。

output lambda_invoke_arn {
  value = aws_lambda_function.lambda.invoke_arn
}
modules/lambda/variable.tf

APIGatewayの情報を受け取れるように、変数を定義しておきます。

variable api_gateway_arn {}
variable api_gateway_method {}
variable api_gateway_resouce_path_part {}

terraform コマンド実行

メインコード(今回の場合は,/terraform)のある階層で以下のコマンド実行する。(※モジュールを追加しているのでinitも再度実行する)

terraform init
terraform plan
terraform apply
.
.
.
Apply complete! Resources: 23 added, 0 changed, 0 destroyed.

Outputs:

api_gateway_url = "https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/sample-api-gateway-stage/sample-resource"
client_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
domain = "xxxxxxxxxxxx"
identity_pool_id = "ap-northeast-1:xxxxxxxxxx-xxxx-xxxx-xxxxx-xxxxxxxxxxxxx"
user_pool_id = "ap-northeast-1_xxxxxxxxxx"

APIをコールしてみる

curlコマンドやブラウザから「api_gateway_url 」で表示されたアドレスを実行してみます。

> curl https://xxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/sample-api-gateway-stage/sample-resource
{"message":"Unauthorized"}

オーソライザを設定しているので未認証のユーザは実行できないことになっているため、想定通りの動作を確認できました。

今度はAuthorizationヘッダーにidトークンをセットしてリクエストしてみます。

まずはユーザプールにユーザを作成します。(※作成方法を以下を参考に) Cognitoで認証されたユーザーだけがAPI Gatewayを呼び出せるオーソライザーを使ってみた | DevelopersIO

次にaws cliを使ってidトークンを取得します 「client-id」にはTerraformで出力されたものを使用します。 「USERNAME、PASSWORD」はユーザ作成時に設定したものを使用します。 ユーザが「NEW_PASSWORD_REQUIRED」になっている場合は「respond-to-auth-challenge 」を使用して、パスワードの設定をすると次回以降の「initiate-auth」でトークンが取得できるようになります。

> aws cognito-idp initiate-auth --client-id xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx--auth-flow USER_PASSWORD_AUTH --auth-parameters USERNAME=xxx,PASSWORD=xxxxxxx
{                                                                                                                                                         
    "ChallengeName": "NEW_PASSWORD_REQUIRED",                                                                                                             
    "Session": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "ChallengeParameters": {
        "USER_ID_FOR_SRP": "xxx",
        "requiredAttributes": "[]",
        "userAttributes": "{\"email\":\"xxxxxx@linkode.co.jp\"}"
}

 aws cognito-idp respond-to-auth-challenge 
 --client-id 6da7slbs4757bk89g5omvtqant 
 --challenge-name NEW_PASSWORD_REQUIRED 
 --challenge-responses NEW_PASSWORD="test@Password1",USERNAME=xxxx
 --session xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

{
    "ChallengeParameters": {},
    "AuthenticationResult": {
        "AccessToken": "xxxxxxxxxxxxx",
        "ExpiresIn": 3600,
        "TokenType": "Bearer",
        "RefreshToken": "xxxxxxxxxxxxx",
        "IdToken": "xxxxxxxxxxxxxxxx"
    }
}

「IdToken」として表示されてたトークンを以下のようにAuthorizationヘッダーに指定して再度リクエストしてみます。

curl -H "Authorization: Bearer xxxxxxxxxxxxxxxxxxxxxxxxxx "https://g4m3m9x8h6.execute-api.ap-northeast-1.amazonaws.com/sample-api-gateway-stage/sample-resource
<?xml version="1.0" encoding="UTF-8"?>
<message>
    "Hello World!"
</message>

Lambdaが実行されて「Hello World!」がレスポンスされていることがわかります。 (※xml形式で出力されているのはAPIGatewayの「aws_api_gateway_integration_response」で「response_templates 」を使ってLambdaからのレスポンスを加工しているから)

まとめ

LambdaのPermissionが抜けていてAPIGatewayからLambdaが実行できなかったり、APIGatewayのレスポンス設定をしておらずレスポンスが返ってこなかったりして色々と躓きましたが、 無事にAPIGatewayでCognitoオーソライザを使用する環境ををTerraformで構築することができました。 今後これをベースにさらに拡張していき、インフラ環境構築のノウハウを蓄積していければと思いました。

参考

initiate-auth — AWS CLI 1.35.19 Command Reference

respond-to-auth-challenge — AWS CLI 1.35.19 Command Reference

【AWS】TerraformでCognitoをつくって楽したいんだ! | Katsuya Place

Cognito ユーザープールをオーソライザーとして使って API Gateway のアクセスを制御する - AWSの部屋

https://dev.classmethod.jp/articles/api-gateway-cognito-authorizer/#toc-5

Terraform Registry

Terraform Registry