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

Cognitoの構築方法を忘れないように、Terraformを使ってコード化しておこうと思います。 また、せっかくなのでAPIGatewayを前段に配置してCognitoをオーソライザとして使ってみるところまでを試してみようと思います。 こちらの記事(Part1)ではCognitoの構築をメインに記述します。(※今回IDプールは使用しませんが後学のためにコード化だけしています。) Part2ではAPIGatewayの構築とCognitoのオーソライザ設定を主に記述します。

gole

フォルダ構成

terraform/
 ├ modules/
 │ └ cognito/
 │   ├ iam.tf
 │   ├ locals.tf
 │   ├ main.tf
 │   ├ output.tf
 │   └ variable.tf
 ├ main.tf
 ├ variables.tf
 ├ output.tf
 └ provider.tf 

APIGatewayやLambdaも次のPartで構築する予定のためモジュール化しています。

コード説明

メイン

provider.tf

terraformのバージョンやawsのリージョン、プロファイルを指定しています。

terraform {
    required_version = ">= v1.2.3"
    required_providers {
        aws = {
            source  = "hashicorp/aws"
            version = "~> 5.14"
        }
    }
}

# Configure the AWS Provider
provider "aws" {
    # リージョン
    region = var.region
    profile = var.profile
}
main.tf

cognitoモジュールに、variable.tfで設定された値をセットしています。

module "cognito" {
  source = "./modules/cognito"

  region = var.region
  
  # 不要であれば空配列([])を指定する
  string_schemas = var.string_schemas
  number_schemas = var.number_schemas
  another_schemas = var.another_schemas

  # 使用しない場合は空配列([])を指定する
  allowed_oauth_flows = var.allowed_oauth_flows

  read_attributes = var.read_attributes
  write_attributes = var.write_attributes
}
variable.tf

main.tfをすっきりさせたかったので、コード量が多くなりそうなフィールドをピックアップしてこちらに記述しています。

# -----------------------------------------------
# プロバイダー設定
# -----------------------------------------------
variable "region" {
  type        = string
  default     = "ap-northeast-1"
  description = "リージョン"
}

variable "profile" {
  type        = string
  default     = "default"
  description = "プロファイル"
}

# -----------------------------------------------
# cognito ユーザプール設定値
# -----------------------------------------------
variable "string_schemas" {
  type = list(object({
    name                     = string
    attribute_data_type      = string #Boolean, Number, String, DateTime.
    developer_only_attribute = bool    
    mutable                  = bool
    required                 = bool

    string_attribute_constraints = object ({
      max_length = number
      min_length = number
    })
  }))
  default     = [
    {
      attribute_data_type      = "String"
      name                     = "sample"
      developer_only_attribute = false
      mutable                  = true  # false for "sub"
      required                 = false # true for "sub"
      string_attribute_constraints = {   # if it is a string
        min_length = 0                 # 10 for "birthdate"
        max_length = 2048              # 10 for "birthdate"
      }
    }
  ]
  description = "ユーザに登録を求める属性情報(string型)"
}
variable "number_schemas" {
  type = list(object({
    name                     = string
    attribute_data_type      = string #Boolean, Number, String, DateTime.
    developer_only_attribute = bool    
    mutable                  = bool
    required                 = bool

    number_attribute_constraints = object({
      max_length = number
      min_length = number
    })
  }))
  default     = [

  ]
  description = "ユーザに登録を求める属性情報(number型)"
}
variable "another_schemas" {
  type = list(object({
    name                     = string
    attribute_data_type      = string #Boolean, Number, String, DateTime.
    developer_only_attribute = bool    
    mutable                  = bool
    required                 = bool
  }))
  default     = [

  ]
  description = "ユーザに登録を求める属性情報(bool, datetime型)"
}

# -----------------------------------------------
# アプリケーションクライアントの設定
# -----------------------------------------------

variable "allowed_oauth_flows" {
  type        = list
  default     = ["code"]
  description = "OAuthフローの許可(※空の場合はfalse)"
}

variable "read_attributes" {
  type        = map(bool)
  default     = {
    "address"                = true
    "birthdate"              = true
    "email"                  = true
    "email_verified"         = true
    "family_name"            = true
    "gender"                 = true
    "given_name"             = true
    "locale"                 = true
    "middle_name"            = true
    "name"                   = true
    "nickname"               = true
    "phone_number"           = true
    "phone_number_verified"  = true
    "picture"                = true
    "preferred_username"     = true
    "profile"                = true
    "updated_at"             = true
    "website"                = true
    "zoneinfo"               = true
  }
  description = "ユーザ属性の読み取り可否※カスタム属性で追加したものを読み取り可能にしたい場合はここにも追加すること"
}

variable "write_attributes" {
  type        = map(bool)
  default     = {
    "address"                = true
    "birthdate"              = true
    "email"                  = true
    # "email_verified"         = true ※書き込み不可項目
    "family_name"            = true
    "gender"                 = true
    "given_name"             = true
    "locale"                 = true
    "middle_name"            = true
    "name"                   = true
    "nickname"               = true
    "phone_number"           = true
    # "phone_number_verified"  = true ※書き込み不可項目
    "picture"                = true
    "preferred_username"     = true
    "profile"                = true
    "updated_at"             = true
    "website"                = true
    "zoneinfo"               = true
  }
  description = "ユーザ属性の書き込み可否(※カスタム属性で追加したものを書き込み可能にしたい場合はここにも追加すること)"
}

output.tf

環境構築後、取得したい情報をまとめています。

output user_pool_id {
  value       = module.cognito.user_pool_id
}

output identity_pool_id {
  value = module.cognito.identity_pool_id
}

output client_id {
  value = module.cognito.client_id
}

output domain {
  value = module.cognito.domain
}

出力内容は

  • ユーザプールID

  • IDプールID

  • クライアントID

  • ユーザプールドメイン(今回Hosted UIを使用するので出力している)

モジュール

modules/cognito/main.tf

ユーザプール、IDプール、クライアント、ドメイン、Hosted UIを定義します。(※Hosted UI作成にはドメインが必要) ドメインには適宜有効な値を設定する必要があります。

# ユーザプール定義
resource "aws_cognito_user_pool" "sample_user_pool" {
  name = "sample-pool"
  # ユーザ確認のための自動検証をメールで行う
  auto_verified_attributes = ["email"]

  # MFAの有効・無効 
  # (software_token_mfa_configurationまたはsms_configurationの少なくとも一つは設定しておく必要がある)
  mfa_configuration = "OFF"

  # 検証時のメッセージテンプレートを設定する
  verification_message_template {
    default_email_option = "CONFIRM_WITH_CODE" # CONFIRM_WITH_CODE or CONFIRM_WITH_LINK
    email_message = "{####}" # [Optional] プレースホルダー「{####}」を必ず含める
    email_message_by_link = "{##Click Here##}" # [Optional] プレースホルダー「{##Click Here##}」を必ず含める
    email_subject = "verify code" # [Optional] 検証コードを使用する場合のEメールの件名
    email_subject_by_link = "verify link" # [Optional] 検証リンクをを使用する場合の件名
    sms_message = "{####}" # [Optional] SMSを使用する場合のメッセージ プレースホルダー「{####}」を必ず含める
  }


  admin_create_user_config {
    # ユーザーに自己サインアップを許可する
    allow_admin_create_user_only = true
    # 招待メッセージのテンプレート
    invite_message_template {
      email_message = "Username is {username}. Temporary password is {####}."
      email_subject = "Temporary password"
      sms_message = "Username is {username}. Temporary password is {####}."
    }
  }

  # メール送信元の設定(AWS SESを使用する場合は「DEVELOPER」)
  email_configuration {
    email_sending_account = "COGNITO_DEFAULT"
  }

  # パスワードポリシー
  password_policy {
    minimum_length                   = 8    # 最低文字数
    require_lowercase                = true # 英小文字
    require_numbers                  = true # 数字
    require_symbols                  = true # 記号
    require_uppercase                = true # 英大文字
    temporary_password_validity_days = 7    # 初期登録時の一時的なパスワードの有効期限
  }

  
  # 「schema」は登録するユーザーに求める属性
  # ここでは例として標準属性のemailを必須に設定している
  schema {
    attribute_data_type = "String"
    name                = "email"
    required            = true
  }


  # string型のカスタム属性は「string_attribute_constraints」を使用して設定する
  # ※以下のようにdynamicブロックを使用することで、リストが空の場合は定義しないように設定できる
  dynamic "schema" {
    for_each = var.string_schemas
    content {
      attribute_data_type = schema.value.attribute_data_type
      name                = schema.value.name
      required            = schema.value.required
      developer_only_attribute = schema.value.developer_only_attribute
      mutable                  = schema.value.mutable
      string_attribute_constraints {
        max_length = schema.value.string_attribute_constraints.max_length
        min_length = schema.value.string_attribute_constraints.min_length
      }
    }
  }

  # number型のカスタム属性は「number_attribute_constraints」を使用して設定する
  dynamic "schema" {
    for_each = var.number_schemas
    content {
      attribute_data_type = schema.value.attribute_data_type
      name                = schema.value.name
      required            = schema.value.required
      developer_only_attribute = schema.value.developer_only_attribute
      mutable                  = schema.value.mutable
      number_attribute_constraints {
        max_length = schema.value.number_attribute_constraints.max_length
        min_length = schema.value.number_attribute_constraints.min_length
      }
    }
  }

  # string, number以外の型のカスタム属性(bool, datetime)はconstraintsを設定する必要はない
  dynamic "schema" {
    for_each = var.another_schemas
    content {
      attribute_data_type = schema.value.attribute_data_type
      name                = schema.value.name
      required            = schema.value.required
      developer_only_attribute = schema.value.developer_only_attribute
      mutable                  = schema.value.mutable
    }
  }
  
  username_configuration {
    # ユーザ名(Email)の大文字小文字の区別をするかどうか
    case_sensitive = false
  }

  tags = {
    Env = "env"
  }
}

# ドメイン定義
resource "aws_cognito_user_pool_domain" "sample_user_pool_domain" {
  # 適宜有効な値を設定する
  domain       = "xxxxxxxxxxxx"
  user_pool_id = aws_cognito_user_pool.sample_user_pool.id
}

# クライアント定義
resource "aws_cognito_user_pool_client" "sample_client" {
  name = "sample-client"

  user_pool_id = aws_cognito_user_pool.sample_user_pool.id

  # デフォルトは時間単位(※token_validity_units.access_tokenで単位の変更が可能)
  access_token_validity = 1
  # デフォルトは時間単位(※token_validity_units.id_tokenで単位の変更が可能)
  id_token_validity = 1
  # デフォルトは日単位(※token_validity_units.refresh_tokenで単位の変更が可能)
  refresh_token_validity = 30
  # seconds, minutes, hours, daysから設定
  token_validity_units {
    access_token  = "hours"
    id_token      = "hours"
    refresh_token = "days"
  }

  # フローが設定されていたらtrue、設定されていなければfalseとなるようにしておく
  allowed_oauth_flows_user_pool_client = length(var.allowed_oauth_flows) == 0 ? false : true

  allowed_oauth_flows = var.allowed_oauth_flows

  allowed_oauth_scopes = ["phone", "email", "openid", "profile"]
  # フローのタイムアウトを3分に設定
  auth_session_validity = 3

  callback_urls = ["http://localhost:3000"]

  # callback_urlsに含まれているものを指定すること
  default_redirect_uri = "http://localhost:3000"

  logout_urls = ["http://localhost:3000"]

  enable_token_revocation = true

  explicit_auth_flows = ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_PASSWORD_AUTH"]

  generate_secret = false

  # ユーザが存在しない場合の挙動(ENABLED or LEGACY)
  # ENABLED=ユーザ存在エラーを防止(ユーザ名、パスワードの間違いとして認証できない場合と同じ挙動となる)
  # LEGACY=UserNotFoundExceptionが返る
  prevent_user_existence_errors = "ENABLED"

  supported_identity_providers = ["COGNITO"]

  read_attributes = local.read_attribute_list

  write_attributes = local.write_attribute_list
}

# Hosted UI
resource "aws_cognito_user_pool_ui_customization" "sample_hosted_ui" {
  client_id = aws_cognito_user_pool_client.sample_client.id

  css        = ".label-customizable {font-weight: 400;}"
  # image_file = filebase64("logo.png")

  # Refer to the aws_cognito_user_pool_domain resource's
  # user_pool_id attribute to ensure it is in an 'Active' state
  user_pool_id = aws_cognito_user_pool_domain.sample_user_pool_domain.user_pool_id
}

# IDプール定義
resource "aws_cognito_identity_pool" "sample_identity_pool" {
  identity_pool_name = "sample-identity-pool"

  # 認証していないユーザーに使用を許可するか
  allow_unauthenticated_identities = false

  openid_connect_provider_arns     = []
  saml_provider_arns               = []
  supported_login_providers        = {}
  tags                             = {
    Env = "env"
  }

  cognito_identity_providers {
    client_id               = aws_cognito_user_pool_client.sample_client.id
    provider_name           = "cognito-idp.${var.region}.amazonaws.com/${aws_cognito_user_pool.sample_user_pool.id}"
    server_side_token_check = false
  }
}
modules/cognito/variable.tf

メインコードから渡される値を格納する変数を定義します。

variable region {}
# ユーザプール
variable string_schemas {}
variable number_schemas {}
variable another_schemas {}

# アプリケーションクライアント
variable allowed_oauth_flows {}

variable read_attributes {}
variable write_attributes {}
modules/cognito/locals.tf

読み取り・書き込みの設定はリスト形式なのでmap→listに変換しています。

# 書き込み・読み取りぞれぞれでvalueがtrueのものをfilterして、属性(key)のリストを作成している
locals {
  read_attribute_list = [for key, value in var.read_attributes : key if value == true]
  write_attribute_list = [for key, value in var.write_attributes : key if value == true]
}
modules/cognito/output.tf

メインコードから参照できるように出力を定義しておきます。

# ※user_pool_arnはAPIGateway作成時に必要
output user_pool_arn {
  value = aws_cognito_user_pool.sample_user_pool.arn
}

output user_pool_id {
  value = aws_cognito_user_pool.sample_user_pool.id
}

output client_id {
  value = aws_cognito_user_pool_client.sample_client.id
}

output identity_pool_id {
  value = aws_cognito_identity_pool.sample_identity_pool.id
}

output domain {
  value = aws_cognito_user_pool_domain.sample_user_pool_domain.domain
}
modules/cognito/iam.tf

Cognito IDプールが付与することができる権限を定義したロールを作成しています。(main.tfのコード量が多くて見にくくなるので分けました)

# 信頼関係
data "aws_iam_policy_document" "authenticated" {
  statement {
    effect = "Allow"

    principals {
      type        = "Federated"
      identifiers = ["cognito-identity.amazonaws.com"]
    }

    actions = ["sts:AssumeRoleWithWebIdentity"]

    condition {
      test     = "StringEquals"
      variable = "cognito-identity.amazonaws.com:aud"
      values   = [aws_cognito_identity_pool.sample_identity_pool.id]
    }

    condition {
      test     = "ForAnyValue:StringLike"
      variable = "cognito-identity.amazonaws.com:amr"
      values   = ["authenticated"]
    }
  }
}

resource "aws_iam_role" "authenticated" {
  name               = "cognito_authenticated"
  assume_role_policy = data.aws_iam_policy_document.authenticated.json
}

# 付与する権限
data "aws_iam_policy_document" "authenticated_role_policy" {
  statement {
    effect = "Allow"

    actions = [
      "cognito-identity:GetOpenIdToken",
      "cognito-identity:GetCredentialsForIdentity"
    ]

    resources = ["*"]
  }
}
# 信頼関係と付与する権限を紐づけ
resource "aws_iam_role_policy" "authenticated" {
  name   = "authenticated_policy"
  role   = aws_iam_role.authenticated.id
  policy = data.aws_iam_policy_document.authenticated_role_policy.json
}
# idプールにアタッチする
resource "aws_cognito_identity_pool_roles_attachment" "id_pool_role_attachment" {
  identity_pool_id = aws_cognito_identity_pool.sample_identity_pool.id

  role_mapping {
    # aws_cognito_identity_pool.identity_pool.cognito_identity_providers.provider_nameに依存する
    identity_provider         = "cognito-idp.${var.region}.amazonaws.com/${aws_cognito_user_pool.sample_user_pool.id}:${aws_cognito_user_pool_client.sample_client.id}"
    ambiguous_role_resolution = "AuthenticatedRole"
    type                      = "Rules"

    mapping_rule {
      claim      = "isAdmin"
      match_type = "Equals"
      role_arn   = aws_iam_role.authenticated.arn
      value      = "paid"
    }
  }

  roles = {
    "authenticated" = aws_iam_role.authenticated.arn
  }
}

terraform コマンド実行

メインコード(今回の場合は,/terraform)のある階層で以下のコマンド実行します。(※planを実行して問題なければapplyを実行する)

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

Outputs:

client_id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
domain = "xxxxxxxxxxxx"
identity_pool_id = "ap-northeast-1:xxxxxxxxxx-xxxx-xxxx-xxxxx-xxxxxxxxxxxxx"
user_pool_id = "ap-northeast-1_xxxxxxxxxx"

構築した環境を削除する場合はdestroy

terraform destroy

まとめ

Cognitoの設定は項目が多くて大変ですが、その分コード化しておくことで次回作業時の手間を大幅削減できるなと思いました。 またIDプールのiamの設定に関しては手動で環境構築していた際もハマりポイントだったので、ここで自動化のノウハウを得れてよかったです。 コード化すると理解がより進むような気がします。

参考

Terraform Registry

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

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