1つのCognitoユーザープールに認証方式の違うユーザーを集約し、同じログイン画面から受け入れてみた
はじめに
クラウド事業統括本部の浅野です。
Amazon Cognitoのユーザープールには、GoogleやSAML/OIDC対応の外部IdPを「フェデレーション」として登録できます。
複数の認証方式を1つのアプリケーションで受け入れる方法は、これ以外にもあります。ALBのリスナールールごとにauthenticate-cognitoとauthenticate-oidcを割り当て、経路そのものを分離する構成です。こちらの構成については別の記事で紹介しています。
本記事では、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_providersにCOGNITOとGoogleを並べる |
外部IdP(aws_cognito_identity_provider) |
1 | |
| ALB | 1 | HTTPSリスナー1つ、リスナールールなし(デフォルトアクションで認証) |
| ALBのセッションCookie名 | 1 | AuthFederatedRoute |
| アプリのドメイン | 1 | |
| ECSサービス・タスク | 1 | nginx + Djangoの最小構成 |
App Clientを分けたり、ドメインを分けたりすれば、ログイン画面ごとに表示するIdPを変えることもできます。本記事ではその手前の「1つに集約したときに成立するか」を確認するため、あえてすべてを1つに寄せています。
構成

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が入っていることを確認します。

amazoncognito.comを1つ登録しておけば、<プレフィックス>.auth.ap-northeast-1.amazoncognito.comも対象に含まれます。今回はGoogleと直接やりとりするのがCognitoだけなので、ALB側のドメインを登録する必要はありません。
続いてOAuthクライアントを作成します。アプリケーションの種類はウェブアプリケーションで、承認済みのJavaScript生成元と承認済みのリダイレクトURIの両方にCognito Hosted UIのドメインを指定します。

この2つを登録する手順はAWS公式ドキュメントに記載されています。
作成すると、クライアントIDとクライアントシークレットが発行されます。

この画面に表示されている通り、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"
]
}
SupportedIdentityProvidersにCOGNITOとGoogleが並んでいます。IdPの登録内容はマネジメントコンソールからも確認できます。

未認証でアプリの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のログイン画面が表示されました。

左に「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つの認証方式を受け付けている状態です。
まずメール/パスワードでサインインしました。

アプリはx-amzn-oidc-dataを署名検証なしでデコードし、クレームを表示するだけの実装です。usernameはUUID形式で、サインイン元を「Cognitoネイティブ」と判定しています。
開発者ツールでCookieを確認すると、AuthFederatedRoute-0・AuthFederatedRoute-1・AWSALBAuthNonceの3つが発行されていました。

-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.
次に、いったんログアウトしてから同じ画面の「Continue with Google」でサインインしました。

usernameがGoogle_<GoogleのuserId>という形式になり、サインイン元を「Google(フェデレーション経由)」と判定できています。クレームの中身は経路によって次のように違いました。
| Cognitoネイティブ | Google経由 | |
|---|---|---|
sub |
ユーザープールが発行したUUID | ユーザープールが発行したUUID(ネイティブとは別の値) |
username |
subと同じUUID |
Google_<GoogleのuserId> |
email_verified |
true |
false |
identities |
なし | providerNameがGoogleの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_mappingでemailしか写していないためです。Googleが検証済みのメールアドレスを返していても、ユーザープール側のemail_verified属性は更新されません。メールの検証済みフラグをアプリの判断に使う場合は、マッピングを追加する必要があります。
Cookieも確認します。

AuthFederatedRoute-0・AuthFederatedRoute-1・AWSALBAuthNonceで、ネイティブ経由のときと完全に同じ名前です。ALBから見れば同じApp Clientでの認証フローが完了しただけなので、経路によってCookie名が変わることはありません。
ALBのリスナールールごとに認証アクションを分ける構成では、経路ごとに別のCookie名を割り当てることになります。集約構成ではセッションが1つに統合されるため、アプリ側がCookieを意識する場面も1つで済みます。
ユーザーの管理場所も1つにまとまる
Google経由でサインインしたユーザーは、初回サインインの時点でユーザープールにプロファイルが作成されます。マネジメントコンソールのユーザー一覧を開くと、メール/パスワードで登録したユーザーと並んで表示されました。

ユーザー名がUUIDのほうがCognitoネイティブ、Google_始まりのほうがGoogle経由です。確認ステータスの列を見ると、前者は「確認済み」、後者は「外部プロバイダー」と表示されており、どちらの経路で作られたユーザーかがコンソール上でも判別できます。
認証方式が違っても、ユーザーの一覧・無効化・削除といった管理操作はこの1画面で完結します。外部IdPを使う構成でユーザー管理が分散しがちなところを、Cognitoに寄せることで1箇所にまとめられるのがフェデレーションの利点です。
ログアウトについて
ALBの認証アクションはログイン処理を肩代わりしますが、ログアウトのためのアクションは用意されていません。ユーザーをログアウトさせる処理はアプリ側で実装します。手順はALB公式ドキュメントに示されている通り、アプリがセッションCookieを失効させたうえで、IdPのログアウトエンドポイントへリダイレクトする、という流れです。
今回のアプリでは次のように実装しました。
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に着地しました。

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

ここまでは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_providersにCOGNITOとGoogleを並べるだけで、同一のHosted UI・同一のApp Client・同一のドメインから両方のユーザーがサインインできる- ALBが発行するセッションCookieは経路によらず同じ名前で、アプリ側から見たセッションは1つに統合される
x-amzn-oidc-dataのusernameの接頭辞や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箇所に寄せたいかで選択が変わります。今回は以上です。








