1つのCognitoユーザープールに認証方式の違うユーザーを集約し、同じログイン画面から受け入れてみた

1つのCognitoユーザープールに認証方式の違うユーザーを集約し、同じログイン画面から受け入れてみた

Cognitoユーザープールに外部IdPとしてGoogleをフェデレーションし、同じログイン画面からCognitoネイティブユーザーとGoogleユーザーの両方を受け入れられるか、ログアウトはどこまで一元化できるかを検証しました!
2026.07.29

はじめに

クラウド事業統括本部の浅野です。

Amazon Cognitoのユーザープールには、GoogleやSAML/OIDC対応の外部IdPを「フェデレーション」として登録できます。

複数の認証方式を1つのアプリケーションで受け入れる方法は、これ以外にもあります。ALBのリスナールールごとにauthenticate-cognitoauthenticate-oidcを割り当て、経路そのものを分離する構成です。こちらの構成については別の記事で紹介しています。

https://dev.classmethod.jp/articles/2026-07-28-alb-listener-rule-auth-session-separation-tried/

本記事では、Cognito側にIdPを集約する構成を確認します。同一の窓口から2種類のユーザーがサインインできるのか、アプリ側でサインイン元を判別できるのか、ログアウトはどうなるのかを、Terraformの設定内容と実際の画面で紹介します。

外部IdPの代表としてGoogleを使いますが、考え方はSAML/OIDCに対応した他のIdPでも同じです。

実現したいこと

認証に関わるリソースをすべて1つずつに絞った状態で、次の3点を確認します。

  • Cognitoネイティブアカウントのユーザーと、Googleアカウントのユーザーが、同じHosted UIからサインインできるか
  • ALBが付与するx-amzn-oidc-dataのクレームから、どちらのIdP経由でサインインしたかをアプリ側で判別できるか
  • ログアウトをCognitoの/logoutエンドポイント1本に集約できるか

前提となるリソースの数は次のとおりです。

リソース 補足
Cognitoユーザープール 1 Cognitoネイティブユーザーと、Google経由のフェデレーションユーザーが同じディレクトリに入る
Cognito Hosted UIのドメイン 1 ログイン画面は1つだけ
App Client 1 supported_identity_providersCOGNITOGoogleを並べる
外部IdP(aws_cognito_identity_provider 1 Google
ALB 1 HTTPSリスナー1つ、リスナールールなし(デフォルトアクションで認証)
ALBのセッションCookie名 1 AuthFederatedRoute
アプリのドメイン 1
ECSサービス・タスク 1 nginx + Djangoの最小構成

App Clientを分けたり、ドメインを分けたりすれば、ログイン画面ごとに表示するIdPを変えることもできます。本記事ではその手前の「1つに集約したときに成立するか」を確認するため、あえてすべてを1つに寄せています。

構成

2026-07-29-cognito-unify-auth-methods-single-window-tried-01

VPCはPublic Subnetのみの2AZ構成で、ECS Fargateのサービス・タスクは1つだけです。ALBのHTTPSリスナーは、リスナールールを使わずデフォルトアクションでauthenticate-cognito(order 1)→forward(order 2)のチェーンを組んでいます。経路の分離が検証対象ではないため、ホストヘッダーやパスでの振り分けは不要です。

ALBから外部IdPへ直接つなぐ構成との一番の違いは、GoogleとのやりとりをすべてCognitoが仲介する点です。ALBから見た認証先はCognitoだけで、Googleの存在を知りません。そのためGoogle Cloud Console側に登録する承認済みリダイレクトURIも、ALBのドメインではなくCognito Hosted UIのドメイン(https://<プレフィックス>.auth.ap-northeast-1.amazoncognito.com/oauth2/idpresponse)宛になります。

Cognitoネイティブ経由の認証フロー

Google経由の認証フロー

ALBから見ると、2つのフローの違いはCognitoの内部で吸収されます。ALBが受け取るのはどちらの場合も同じApp Clientからの認可コードで、発行されるセッションCookieの名前も同一です。

Terraform

全リソースを1つのmain.tfに集約しています。全文は折りたたんでいるので、必要であれば展開してください。

main.tf(クリックで展開)
##############################################
# Cognito User PoolにGoogleを外部IdPとしてフェデレーションし、
# 同一App ClientでCognitoネイティブユーザーとGoogle経由ユーザーの両方を受け入れる構成。
# 全リソースを本ファイルに集約している。
##############################################

terraform {
  required_version = ">= 1.9"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
    null = {
      source  = "hashicorp/null"
      version = "~> 3.2"
    }
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project   = "cognito-idp-federation-poc"
      ManagedBy = "terraform"
    }
  }
}

##############################################
# variables
##############################################

variable "aws_region" {
  description = "検証環境をデプロイするリージョン"
  type        = string
  default     = "ap-northeast-1"
}

variable "root_domain" {
  description = "Route53ホストゾーンのドメイン名(既存ゾーンを利用)"
  type        = string
  default     = "<ドメイン>"
}

variable "google_client_id" {
  description = "CognitoにフェデレーションするGoogle OAuthクライアントID(Google Cloud Consoleで事前作成)"
  type        = string
  sensitive   = true
}

variable "google_client_secret" {
  description = "GoogleのOAuthクライアントシークレット"
  type        = string
  sensitive   = true
}

##############################################
# data sources
##############################################

data "aws_caller_identity" "current" {}

data "aws_availability_zones" "available" {
  state = "available"
}

data "aws_route53_zone" "root" {
  name = var.root_domain
}

locals {
  name        = "cognito-idp-federation-poc"
  domain_name = "idp-federation-poc.${var.root_domain}"
  # Cognito Hosted UIのドメインプレフィックス
  cognito_domain_prefix = "idp-federation-poc-fed001"
  # ALBが発行する認証セッションCookieの名前
  session_cookie_name = "AuthFederatedRoute"
  # ログアウト後のランディングURL
  logout_uri = "https://idp-federation-poc.${var.root_domain}/logged-out"
  azs         = slice(data.aws_availability_zones.available.names, 0, 2)
  ecr_registry = "${data.aws_caller_identity.current.account_id}.dkr.ecr.${var.aws_region}.amazonaws.com"
  app_source_hash = sha1(join("", [
    for f in sort(fileset("${path.module}/../django", "**")) : filesha1("${path.module}/../django/${f}")
  ]))
  nginx_source_hash = sha1(join("", [
    for f in sort(fileset("${path.module}/../nginx", "**")) : filesha1("${path.module}/../nginx/${f}")
  ]))
}

##############################################
# VPC(Public subnet x2、ECSサービス/タスクは1つ)
##############################################

resource "aws_vpc" "this" {
  cidr_block           = "10.92.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags = { Name = "${local.name}-vpc" }
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${local.name}-igw" }
}

resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_vpc.this.id
  cidr_block              = "10.92.${count.index}.0/24"
  availability_zone       = local.azs[count.index]
  map_public_ip_on_launch = true
  tags                    = { Name = "${local.name}-public-${count.index}" }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this.id
  }
  tags = { Name = "${local.name}-public-rt" }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

##############################################
# Security Groups
##############################################

resource "aws_security_group" "alb" {
  name   = "${local.name}-alb-sg"
  vpc_id = aws_vpc.this.id
}

resource "aws_vpc_security_group_ingress_rule" "alb_https" {
  security_group_id = aws_security_group.alb.id
  cidr_ipv4          = "0.0.0.0/0"
  from_port           = 443
  to_port             = 443
  ip_protocol         = "tcp"
}

resource "aws_vpc_security_group_ingress_rule" "alb_http" {
  security_group_id = aws_security_group.alb.id
  cidr_ipv4          = "0.0.0.0/0"
  from_port           = 80
  to_port             = 80
  ip_protocol         = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "alb_all" {
  security_group_id = aws_security_group.alb.id
  cidr_ipv4          = "0.0.0.0/0"
  ip_protocol         = "-1"
}

resource "aws_security_group" "ecs" {
  name   = "${local.name}-ecs-sg"
  vpc_id = aws_vpc.this.id
}

resource "aws_vpc_security_group_ingress_rule" "ecs_from_alb" {
  security_group_id           = aws_security_group.ecs.id
  referenced_security_group_id = aws_security_group.alb.id
  from_port                    = 80
  to_port                      = 80
  ip_protocol                  = "tcp"
}

resource "aws_vpc_security_group_egress_rule" "ecs_all" {
  security_group_id = aws_security_group.ecs.id
  cidr_ipv4          = "0.0.0.0/0"
  ip_protocol         = "-1"
}

##############################################
# ACM + Route53
##############################################

resource "aws_acm_certificate" "this" {
  domain_name       = local.domain_name
  validation_method = "DNS"
  lifecycle { create_before_destroy = true }
}

resource "aws_route53_record" "cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.this.domain_validation_options : dvo.domain_name => {
      name  = dvo.resource_record_name
      type  = dvo.resource_record_type
      value = dvo.resource_record_value
    }
  }
  zone_id = data.aws_route53_zone.root.zone_id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.value]
  ttl     = 60
}

resource "aws_acm_certificate_validation" "this" {
  certificate_arn         = aws_acm_certificate.this.arn
  validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}

resource "aws_route53_record" "alb_alias" {
  zone_id = data.aws_route53_zone.root.zone_id
  name    = local.domain_name
  type    = "A"
  alias {
    name                   = aws_lb.this.dns_name
    zone_id                = aws_lb.this.zone_id
    evaluate_target_health = true
  }
}

##############################################
# ECR + イメージビルド
##############################################

resource "aws_ecr_repository" "app" {
  name         = "${local.name}-app"
  force_delete = true
}

resource "aws_ecr_repository" "nginx" {
  name         = "${local.name}-nginx"
  force_delete = true
}

resource "null_resource" "build_setup" {
  triggers = { always_run = timestamp() }

  provisioner "local-exec" {
    interpreter = ["bash", "-c"]
    command     = <<-EOT
      set -euo pipefail
      docker info > /dev/null 2>&1 || { echo "Dockerデーモンに接続できません。Rancher Desktopが起動しているか確認してください。" >&2; exit 1; }
      docker buildx inspect idp-federation-poc-builder > /dev/null 2>&1 || docker buildx create --name idp-federation-poc-builder --use --bootstrap
      docker buildx use idp-federation-poc-builder
      aws ecr get-login-password --region ${var.aws_region} \
        | docker login --username AWS --password-stdin ${local.ecr_registry}
    EOT
  }
}

resource "null_resource" "build_push_app" {
  triggers = {
    source_hash       = local.app_source_hash
    build_script_hash = filesha1("${path.module}/main.tf")
  }
  provisioner "local-exec" {
    interpreter = ["bash", "-c"]
    command     = "docker buildx build --platform linux/arm64 -t ${aws_ecr_repository.app.repository_url}:${local.app_source_hash} --push ${path.module}/../django"
  }
  depends_on = [null_resource.build_setup, aws_ecr_repository.app]
}

resource "null_resource" "build_push_nginx" {
  triggers = {
    source_hash       = local.nginx_source_hash
    build_script_hash = filesha1("${path.module}/main.tf")
  }
  provisioner "local-exec" {
    interpreter = ["bash", "-c"]
    command     = "docker buildx build --platform linux/arm64 -t ${aws_ecr_repository.nginx.repository_url}:${local.nginx_source_hash} --push ${path.module}/../nginx"
  }
  depends_on = [null_resource.build_setup, aws_ecr_repository.nginx]
}

##############################################
# ECS
##############################################

resource "aws_ecs_cluster" "this" {
  name = local.name
  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

resource "aws_cloudwatch_log_group" "app" {
  name              = "/ecs/${local.name}/app"
  retention_in_days = 7
}

resource "aws_cloudwatch_log_group" "nginx" {
  name              = "/ecs/${local.name}/nginx"
  retention_in_days = 7
}

resource "aws_iam_role" "execution" {
  name = "${local.name}-execution-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ecs-tasks.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
}

resource "aws_iam_role_policy_attachment" "execution" {
  role       = aws_iam_role.execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

resource "aws_ecs_task_definition" "app" {
  family                   = "${local.name}-task"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "256"
  memory                   = "512"
  execution_role_arn       = aws_iam_role.execution.arn

  runtime_platform {
    cpu_architecture        = "ARM64"
    operating_system_family = "LINUX"
  }

  container_definitions = jsonencode([
    {
      name      = "nginx"
      image     = "${aws_ecr_repository.nginx.repository_url}:${local.nginx_source_hash}"
      essential = true
      portMappings = [{ containerPort = 80, protocol = "tcp" }]
      dependsOn = [{ containerName = "app", condition = "START" }]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.nginx.name
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "nginx"
        }
      }
    },
    {
      name      = "app"
      image     = "${aws_ecr_repository.app.repository_url}:${local.app_source_hash}"
      essential = true
      portMappings = [{ containerPort = 8000, protocol = "tcp" }]
      environment = [
        { name = "ALLOWED_HOSTS", value = "*" },
        # アプリがCognitoのLOGOUTエンドポイントURLを組み立てるために使う
        { name = "COGNITO_DOMAIN", value = "${aws_cognito_user_pool_domain.this.domain}.auth.${var.aws_region}.amazoncognito.com" },
        { name = "COGNITO_CLIENT_ID", value = aws_cognito_user_pool_client.alb.id },
        { name = "LOGOUT_URI", value = local.logout_uri },
        { name = "SESSION_COOKIE_NAME", value = local.session_cookie_name },
      ]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.app.name
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "app"
        }
      }
    }
  ])

  depends_on = [null_resource.build_push_app, null_resource.build_push_nginx]
}

resource "aws_ecs_service" "this" {
  name            = "${local.name}-svc"
  cluster         = aws_ecs_cluster.this.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 1
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = aws_subnet.public[*].id
    security_groups  = [aws_security_group.ecs.id]
    assign_public_ip = true
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.app.arn
    container_name   = "nginx"
    container_port   = 80
  }

  depends_on = [aws_lb_listener.https]
}

##############################################
# Cognito(User Pool 1つに Google をIdPとしてフェデレーション。App Clientは1つだけ)
##############################################

resource "aws_cognito_user_pool" "this" {
  name                      = "${local.name}-pool"
  auto_verified_attributes = ["email"]
  username_attributes       = ["email"]
}

# Hosted UIのドメイン。プレフィックスはリージョン内で一意である必要がある
resource "aws_cognito_user_pool_domain" "this" {
  domain       = local.cognito_domain_prefix
  user_pool_id = aws_cognito_user_pool.this.id
}

# GoogleをUser PoolのIdPとして登録する。
# Google Cloud Console側には output google_oauth_redirect_uri_to_register の値を
# 承認済みのリダイレクトURIとして登録しておく
resource "aws_cognito_identity_provider" "google" {
  user_pool_id  = aws_cognito_user_pool.this.id
  provider_name = "Google"
  provider_type = "Google"

  provider_details = {
    client_id        = var.google_client_id
    client_secret     = var.google_client_secret
    authorize_scopes  = "openid email profile"
  }

  attribute_mapping = {
    email    = "email"
    username = "sub"
  }
}

resource "aws_cognito_user_pool_client" "alb" {
  name         = "${local.name}-alb-client"
  user_pool_id = aws_cognito_user_pool.this.id

  generate_secret                     = true
  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_flows                  = ["code"]
  allowed_oauth_scopes                 = ["openid", "email"]
  callback_urls                        = ["https://${local.domain_name}/oauth2/idpresponse"]
  # CognitoのLOGOUTエンドポイントにlogout_uriとして渡せるURL
  logout_urls = [local.logout_uri]
  # Cognitoネイティブアカウントと、Googleフェデレーションの両方を同一App Clientで許可する
  supported_identity_providers = ["COGNITO", "Google"]

  depends_on = [aws_cognito_identity_provider.google]
}

##############################################
# ALB(1台・1ドメイン・1リスナー。リスナールールはログアウト後のランディング用の1本だけ)
##############################################

resource "aws_lb" "this" {
  name               = local.name
  load_balancer_type = "application"
  internal           = false
  security_groups    = [aws_security_group.alb.id]
  subnets            = aws_subnet.public[*].id
}

resource "aws_lb_target_group" "app" {
  name        = local.name
  port        = 80
  protocol    = "HTTP"
  vpc_id      = aws_vpc.this.id
  target_type = "ip"
  stickiness {
    type    = "lb_cookie"
    enabled = false
  }
  health_check {
    path = "/"
  }
}

resource "aws_lb_listener" "http_redirect" {
  load_balancer_arn = aws_lb.this.arn
  port              = 80
  protocol          = "HTTP"
  default_action {
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.this.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate_validation.this.certificate_arn

  default_action {
    type  = "authenticate-cognito"
    order = 1
    authenticate_cognito {
      user_pool_arn              = aws_cognito_user_pool.this.arn
      user_pool_client_id        = aws_cognito_user_pool_client.alb.id
      user_pool_domain           = aws_cognito_user_pool_domain.this.domain
      session_cookie_name        = local.session_cookie_name
      on_unauthenticated_request = "authenticate"
    }
  }

  default_action {
    type             = "forward"
    order            = 2
    target_group_arn = aws_lb_target_group.app.arn
  }
}

# ログアウト後のランディングページは認証をかけずにターゲットへ転送する
resource "aws_lb_listener_rule" "logged_out" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 10

  condition {
    path_pattern {
      values = ["/logged-out"]
    }
  }

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

##############################################
# outputs
##############################################

output "app_url" {
  value = "https://${local.domain_name}/"
}

output "cognito_hosted_ui_domain" {
  value = "${aws_cognito_user_pool_domain.this.domain}.auth.${var.aws_region}.amazoncognito.com"
}

output "google_oauth_redirect_uri_to_register" {
  description = "Google Cloud ConsoleのOAuthクライアントに追加登録する承認済みリダイレクトURI(Cognito Hosted UI宛)"
  value       = "https://${aws_cognito_user_pool_domain.this.domain}.auth.${var.aws_region}.amazoncognito.com/oauth2/idpresponse"
}

キモになるのはCognitoとALBの設定なので、そこだけ抜粋して説明します。

Cognito: 同一App Clientに2つのIdPを並べる

resource "aws_cognito_identity_provider" "google" {
  user_pool_id  = aws_cognito_user_pool.this.id
  provider_name = "Google"
  provider_type = "Google"

  provider_details = {
    client_id        = var.google_client_id
    client_secret    = var.google_client_secret
    authorize_scopes = "openid email profile"
  }

  attribute_mapping = {
    email    = "email"
    username = "sub"
  }
}

resource "aws_cognito_user_pool_client" "alb" {
  name         = "${local.name}-alb-client"
  user_pool_id = aws_cognito_user_pool.this.id

  generate_secret                      = true
  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_flows                  = ["code"]
  allowed_oauth_scopes                 = ["openid", "email"]
  callback_urls                        = ["https://${local.domain_name}/oauth2/idpresponse"]
  logout_urls                          = [local.logout_uri]
  supported_identity_providers         = ["COGNITO", "Google"]

  depends_on = [aws_cognito_identity_provider.google]
}
  • aws_cognito_identity_provider: Googleをユーザープールの外部IdPとして登録します。attribute_mappingはGoogleが返す属性をユーザープールの属性へ写す設定で、ここではusernameにGoogleのsub(Googleアカウントの一意なID)を割り当てています
  • supported_identity_providers: COGNITO(ユーザープール内のネイティブアカウント)とGoogleの両方を指定します。ここが今回の構成の中心で、この1行でHosted UIに「Continue with Google」のボタンが追加されます
  • depends_on: App ClientはIdPが登録済みであることを前提にするため、aws_cognito_identity_providerへの依存を明示しています

ALB: デフォルトアクションで認証する

経路の分離をしないため、リスナールールは使わずデフォルトアクションに認証と転送を並べます。

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.this.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate_validation.this.certificate_arn

  default_action {
    type  = "authenticate-cognito"
    order = 1
    authenticate_cognito {
      user_pool_arn              = aws_cognito_user_pool.this.arn
      user_pool_client_id        = aws_cognito_user_pool_client.alb.id
      user_pool_domain           = aws_cognito_user_pool_domain.this.domain
      session_cookie_name        = local.session_cookie_name
      on_unauthenticated_request = "authenticate"
    }
  }

  default_action {
    type             = "forward"
    order            = 2
    target_group_arn = aws_lb_target_group.app.arn
  }
}

resource "aws_lb_listener_rule" "logged_out" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 10

  condition {
    path_pattern {
      values = ["/logged-out"]
    }
  }

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

aws_lb_listener_ruleが1本だけあるのは、ログアウト後のランディングページ(/logged-out)を認証をかけずに通すためです。理由は後述のログアウトの章で説明します。

やってみた

1. Google Cloud ConsoleでのOAuthクライアント作成

Google Auth Platformで、まず「承認済みドメイン」にamazoncognito.comが入っていることを確認します。

2026-07-29-cognito-unify-auth-methods-single-window-tried-02

amazoncognito.comを1つ登録しておけば、<プレフィックス>.auth.ap-northeast-1.amazoncognito.comも対象に含まれます。今回はGoogleと直接やりとりするのがCognitoだけなので、ALB側のドメインを登録する必要はありません。

続いてOAuthクライアントを作成します。アプリケーションの種類はウェブアプリケーションで、承認済みのJavaScript生成元と承認済みのリダイレクトURIの両方にCognito Hosted UIのドメインを指定します。

2026-07-29-cognito-unify-auth-methods-single-window-tried-03

この2つを登録する手順はAWS公式ドキュメントに記載されています。

https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-social-idp.html

作成すると、クライアントIDとクライアントシークレットが発行されます。

2026-07-29-cognito-unify-auth-methods-single-window-tried-04

この画面に表示されている通り、OAuth同意画面が公開・確認されるまで、Googleサインインを使えるのは組織内のユーザーに限られます。検証時にどのGoogleアカウントでサインインするかは、この制限を踏まえて選びます。

2. Terraformでの環境構築

発行されたクライアントID・シークレットを変数に渡してapplyします。

cd terraform
terraform init
terraform apply \
  -var="google_client_id=<取得した値>" \
  -var="google_client_secret=<取得した値>"

apply後、意図した通りにリスナーが構成されているかをCLIで確認します。

aws elbv2 describe-listeners --load-balancer-arn <ALB_ARN> \
  --query 'Listeners[?Port==`443`].DefaultActions'
[
    [
        {
            "Type": "authenticate-cognito",
            "Order": 1,
            "AuthenticateCognitoConfig": {
                "UserPoolArn": "arn:aws:cognito-idp:ap-northeast-1:<AWS_ACCOUNT_ID>:userpool/<USER_POOL_ID>",
                "UserPoolClientId": "<APP_CLIENT_ID>",
                "UserPoolDomain": "idp-federation-poc-fed001",
                "SessionCookieName": "AuthFederatedRoute",
                "Scope": "openid",
                "SessionTimeout": 604800,
                "OnUnauthenticatedRequest": "authenticate"
            }
        },
        {
            "Type": "forward",
            "Order": 2,
            "TargetGroupArn": "arn:aws:elasticloadbalancing:ap-northeast-1:<AWS_ACCOUNT_ID>:targetgroup/<TARGET_GROUP>"
        }
    ]
]

リスナールールを使わず、デフォルトアクションだけで認証と転送が並んでいることを確認できました。SessionTimeoutはTerraformで明示していないため、デフォルトの604800秒(7日間)が入っています。

App ClientがCognitoネイティブとGoogleの両方を受け付ける設定になっているかも確認します。

aws cognito-idp describe-user-pool-client \
  --user-pool-id <USER_POOL_ID> --client-id <APP_CLIENT_ID> \
  --query 'UserPoolClient.{IdPs:SupportedIdentityProviders,Callbacks:CallbackURLs,Logout:LogoutURLs,Flows:AllowedOAuthFlows,Scopes:AllowedOAuthScopes}'
{
    "IdPs": [
        "COGNITO",
        "Google"
    ],
    "Callbacks": [
        "https://idp-federation-poc.<ドメイン>/oauth2/idpresponse"
    ],
    "Logout": [
        "https://idp-federation-poc.<ドメイン>/logged-out"
    ],
    "Flows": [
        "code"
    ],
    "Scopes": [
        "email",
        "openid"
    ]
}

SupportedIdentityProvidersCOGNITOGoogleが並んでいます。IdPの登録内容はマネジメントコンソールからも確認できます。

2026-07-29-cognito-unify-auth-methods-single-window-tried-05

未認証でアプリのURLにアクセスしたときの挙動も、この時点で確認できます。

curl -sS -o /dev/null -D - https://idp-federation-poc.<ドメイ>/
HTTP/2 302
server: awselb/2.0
location: https://idp-federation-poc-fed001.auth.ap-northeast-1.amazoncognito.com/oauth2/authorize?client_id=<APP_CLIENT_ID>&redirect_uri=https%3A%2F%2Fidp-federation-poc.<ドメイ>%2Foauth2%2Fidpresponse&response_type=code&scope=openid&state=...
set-cookie: AWSALBAuthNonce=...; Path=/; Secure; HttpOnly

ALBがCognitoの/oauth2/authorizeへリダイレクトしています。リダイレクト先にGoogleは登場しません。

3. 2つのサインイン経路を確認する

app_urlにアクセスすると、Cognitoのログイン画面が表示されました。

2026-07-29-cognito-unify-auth-methods-single-window-tried-06

左に「Sign in with your social account」として「Continue with Google」、右に「Sign in with your email and password」としてメール/パスワードの入力欄が並んでいます。supported_identity_providersに2つ指定したことが、そのままこの画面に反映されています。1つのURL・1つのApp Clientで2つの認証方式を受け付けている状態です。

まずメール/パスワードでサインインしました。

2026-07-29-cognito-unify-auth-methods-single-window-tried-07

アプリはx-amzn-oidc-dataを署名検証なしでデコードし、クレームを表示するだけの実装です。usernameはUUID形式で、サインイン元を「Cognitoネイティブ」と判定しています。

開発者ツールでCookieを確認すると、AuthFederatedRoute-0AuthFederatedRoute-1AWSALBAuthNonceの3つが発行されていました。

2026-07-29-cognito-unify-auth-methods-single-window-tried-08

-0-1と連番が付いているのは、Terraformで指定したsession_cookie_nameが接頭辞になり、4KBを超えた分がシャーディングされているためです。

Because most browsers limit the cookie size to 4K, the load balancer shards a cookie that is greater than 4K in size into multiple cookies.

https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html

次に、いったんログアウトしてから同じ画面の「Continue with Google」でサインインしました。

2026-07-29-cognito-unify-auth-methods-single-window-tried-09

usernameGoogle_<GoogleのuserId>という形式になり、サインイン元を「Google(フェデレーション経由)」と判定できています。クレームの中身は経路によって次のように違いました。

Cognitoネイティブ Google経由
sub ユーザープールが発行したUUID ユーザープールが発行したUUID(ネイティブとは別の値)
username subと同じUUID Google_<GoogleのuserId>
email_verified true false
identities なし providerNameGoogleのJSON

usernameの接頭辞とidentitiesクレームの有無が、サインイン元IdPを判別する手掛かりになります。今回のアプリは接頭辞で判定しています。

claims = jwt.decode(
    request.headers.get("x-amzn-oidc-data", ""),
    options={"verify_signature": False},
)
username = claims.get("username", "-")
is_federated = username.startswith("Google_")

email_verifiedがGoogle経由でfalseになっているのは、attribute_mappingemailしか写していないためです。Googleが検証済みのメールアドレスを返していても、ユーザープール側のemail_verified属性は更新されません。メールの検証済みフラグをアプリの判断に使う場合は、マッピングを追加する必要があります。

Cookieも確認します。

2026-07-29-cognito-unify-auth-methods-single-window-tried-10

AuthFederatedRoute-0AuthFederatedRoute-1AWSALBAuthNonceで、ネイティブ経由のときと完全に同じ名前です。ALBから見れば同じApp Clientでの認証フローが完了しただけなので、経路によってCookie名が変わることはありません。

ALBのリスナールールごとに認証アクションを分ける構成では、経路ごとに別のCookie名を割り当てることになります。集約構成ではセッションが1つに統合されるため、アプリ側がCookieを意識する場面も1つで済みます。

ユーザーの管理場所も1つにまとまる

Google経由でサインインしたユーザーは、初回サインインの時点でユーザープールにプロファイルが作成されます。マネジメントコンソールのユーザー一覧を開くと、メール/パスワードで登録したユーザーと並んで表示されました。

2026-07-29-cognito-unify-auth-methods-single-window-tried-11

ユーザー名がUUIDのほうがCognitoネイティブ、Google_始まりのほうがGoogle経由です。確認ステータスの列を見ると、前者は「確認済み」、後者は「外部プロバイダー」と表示されており、どちらの経路で作られたユーザーかがコンソール上でも判別できます。

認証方式が違っても、ユーザーの一覧・無効化・削除といった管理操作はこの1画面で完結します。外部IdPを使う構成でユーザー管理が分散しがちなところを、Cognitoに寄せることで1箇所にまとめられるのがフェデレーションの利点です。

ログアウトについて

ALBの認証アクションはログイン処理を肩代わりしますが、ログアウトのためのアクションは用意されていません。ユーザーをログアウトさせる処理はアプリ側で実装します。手順はALB公式ドキュメントに示されている通り、アプリがセッションCookieを失効させたうえで、IdPのログアウトエンドポイントへリダイレクトする、という流れです。

https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html

今回のアプリでは次のように実装しました。

def logout(request):
    params = urllib.parse.urlencode(
        {"client_id": COGNITO_CLIENT_ID, "logout_uri": LOGOUT_URI}
    )
    response = HttpResponseRedirect(f"https://{COGNITO_DOMAIN}/logout?{params}")
    for name in AUTH_COOKIE_NAMES:
        response.delete_cookie(name, path="/")
    return response

AUTH_COOKIE_NAMESには、接頭辞そのものと、シャーディングされたAuthFederatedRoute-0-3、そしてAWSALBAuthNonceを入れています。ALBは最大4つまでシャードを作るため、まとめて失効させます。

AUTH_COOKIE_NAMES = (
    [SESSION_COOKIE_NAME]
    + [f"{SESSION_COOKIE_NAME}-{i}" for i in range(4)]
    + ["AWSALBAuthNonce"]
)

Cookieを消すだけではCognito側のセッションが残り、再アクセスした時点で認証が通ってしまうため、Cognitoの/logoutエンドポイントも必ず経由させます。このときlogout_uriに指定するURLは、App Clientのlogout_urlsに登録されている必要があります。

ここで1つ制約があります。ログアウト後の着地ページは、認証を要求するルールの配下に置けません。

Client logout landing pages are unauthenticated. This means that they cannot be behind an Application Load Balancer rule that requires authentication.

デフォルトアクションで全リクエストに認証をかけている今回の構成では着地ページも認証対象になり、ログアウト直後にまた認証へ飛ばされます。そのため/logged-outだけを認証なしで転送するリスナールールを1本だけ追加しました。経路分離が目的ではなく、ログアウトを成立させるために必要なルールです。

実際にログアウトすると、/logged-outに着地しました。

2026-07-29-cognito-unify-auth-methods-single-window-tried-12

このときのCookieを確認すると、1件も残っていません。

2026-07-29-cognito-unify-auth-methods-single-window-tried-13

ここまではCognitoネイティブ経由でもGoogle経由でも同じ結果になりました。差が出るのは、ログアウト後にもう一度サインインしたときです。

Cognitoネイティブ経由 Google経由
ALB発行Cookieの失効 できる できる
Cognito側のセッション /logoutで終了する /logoutで終了する
IdP側のセッション Cognito自身がIdPのため終了する Google側は残る
再サインイン時 メール/パスワードの再入力が必要 Googleのアカウント選択画面は表示されるが、パスワードの再入力は不要

Google経由で再サインインすると、アカウント選択画面が表示され、アカウントを選ぶだけでアプリに戻りました。パスワードを求められないのは、Google側のログインセッションが残っているためです。GoogleのOIDC discoveryドキュメントにはend_session_endpointが存在せず、Cognitoから見てもGoogleのセッションを終了させる手段はありません。

つまり、認証窓口をCognitoに集約しても、外部IdP側のセッションまでは切れないという非対称性は解消しません。ログアウトの実装先がCognitoの/logout1本にまとまる点はフェデレーションの利点ですが、IdP側のセッション制御まで一元化できるわけではありません。

最後に

Cognitoユーザープールに外部IdP(Google)をフェデレーションし、1つの窓口でCognitoネイティブユーザーとGoogleユーザーの両方を受け入れられるかを検証しました。確認できたのは次の3点です。

  • supported_identity_providersCOGNITOGoogleを並べるだけで、同一のHosted UI・同一のApp Client・同一のドメインから両方のユーザーがサインインできる
  • ALBが発行するセッションCookieは経路によらず同じ名前で、アプリ側から見たセッションは1つに統合される
  • x-amzn-oidc-datausernameの接頭辞やidentitiesクレームから、どちらのIdP経由でサインインしたかをアプリ側で判別できる

ALBのリスナールールごとにIdPを直結する構成と比べると、次のように整理できます。

Cognitoに集約(本記事) ALBのリスナールールで直結
認証窓口 1つ(Cognito Hosted UI) 経路ごと
ユーザーの一覧・棚卸し Cognitoに一元化 IdPごとに分散
セッションCookie 1種類 経路ごとに別名
ログアウトの実装 Cognitoの/logout1本 経路(IdP)ごとに実装が変わる
外部IdP側のセッション 切れない 切れない
外部ユーザーのCognito課金 フェデレーションMAUとして発生する 発生しない
設定変更の影響範囲 全経路に波及する 経路ごとに閉じる

長期運用でユーザー管理やログアウト実装を一元化したいのであれば集約構成が向きます。一方で、Amazon CognitoはOIDC/SAML federationでサインインしたMAUを独立した課金項目として計上するため、外部ユーザーの規模が大きい場合はコストが効いてきます。影響範囲を経路ごとに閉じたい場合も直結構成に分があります。

どちらが正解ということはなく、外部ユーザーの規模と、認証基盤をどこまで1箇所に寄せたいかで選択が変わります。今回は以上です。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事