Cognitoユーザープールを社内用・社外用に分割して、MFA・パスワードポリシー・権限を分離してみた
はじめに
皆さんこんにちは、クラウド事業統括本部の浅野です。
社内のユーザーと社外のユーザーが同じWebアプリを使う場合、認証の要件を社内外で分けたいケースがあります。社員は既存の社内IdPでシングルサインオンさせたい一方、取引先やパートナーにはアプリ専用のアカウントを発行し、MFAや強めのパスワードポリシーを課したい、といった具合です。
これをAmazon Cognitoで実現しようとすると、MFAやパスワードポリシーといった認証の強さに関わる設定を分割するためにユーザープールを分割する必要があります。
そこで今回は、ユーザープール自体を社内用・社外用の2つに分割し、ALBのauthenticate-cognitoアクションがリスナールール単位で設定できることを利用して、1つのALB・1つのアプリのままホスト名ごとに別々のプールで認証させる構成をやってみました。
ALBとCognitoを組み合わせた認証構成は、以下の記事でも検証しています。あわせてご覧ください。
実現したいこと
想定するシチュエーションは次のとおりです。
- 社内ユーザー(社員): 社内のIdPでアカウントを一元管理しており、アプリへもこの社内IdPの認証で接続させたい。MFAやパスワードポリシーはIdP側で担保済み
- 社外ユーザー(取引先・パートナー): 社内IdPには入れられないため、Cognitoで直接管理する。社内より厳しい認証ポリシーを課したい
- ロール: 社内外を問わず「一般ユーザー/管理者」の2ロールがあり、アプリは「社内外 × 一般/管理者」の4パターンでアクセス制御したい
これを要件に分解すると以下になります。
| # | 要件 |
|---|---|
| 1 | ログイン窓口の分離。社内用ページにパスワード欄を出さず、社外用ページに社内IdPの入口を出さない |
| 2 | MFAの分離。社外は必須(TOTP)、社内はCognitoでは求めない |
| 3 | パスワードポリシーの分離。社外だけ厳格化し、社内側の設定に影響させない |
| 4 | ユーザー管理・影響範囲の分離。ユーザー一覧が混ざらず、設定変更の影響が片側で閉じる |
| 5 | ロール分離。管理者だけを明示的に昇格させ、アプリが判定できる |
| 6 | セッションの分離。社外と社内ユーザーのセッションを完全に分離させる |
なお本検証では、社内IdPの代理として**Google(OIDC)**を使います。OIDCに対応したIdPであれば同じ構成が組めます。
構成
構成図は以下のとおりです。

なぜユーザープールを2つに分けるのか
Cognitoの設定は、どの階層に紐付くかが決まっています。
ユーザープール(= ユーザーディレクトリの実体)
├─ プール単位: ユーザー実体 / パスワードポリシー / MFA設定 / グループ /
│ Lambdaトリガー / 脅威保護・フィーチャープラン / 管理API・IAMの権限境界
├─ App Client単位: 許可するIdP / コールバックURL / トークン有効期間
└─ ドメイン単位: ログイン画面のバージョン(Hosted UI / マネージドログイン)
ポイントは、認証の強さに関わる設定(MFA・パスワードポリシー)がプール単位で、App Client単位では変えられないことです。
1つのプールをApp Clientだけ分けて使用することでも窓口の分離は可能ですが、ユーザープールごと分割する案と比較すると次のようになります。
| 要件 | 1プール+App Client×2 | 2プール |
|---|---|---|
| 窓口の分離 | ○ App Clientの許可IdP絞り込みで可能 | ○ プールごと別画面 |
| MFAの分離 | △「フェデレーションユーザーにMFAが適用されない」仕様に依存 | ◎ プールごとに明示設定 |
| パスワードポリシーの分離 | ✕ プール共通のため不可能 | ◎ プールごとに独立 |
| ユーザー管理・影響範囲 | ✕ 一覧混在、IAMでの権限分離も不可 | ◎ プール境界=管理・影響境界 |
| ロール用グループ | ◎ 1セットを共有 | ○ 同名グループを各プールに作成 |
上記のようにしっかりと要件を分離したい場合はユーザープールの分割が求められます。
認証フロー
社内ユーザーの経路です。internal用App Clientの許可IdPはGoogleだけなので、Cognitoのログイン画面は表示されずGoogleへ直接リダイレクトされます。
社外ユーザーの経路です。partnerプールはローカルユーザーのみで、Cognito認証を通ります。メール/パスワードに加えてTOTPが必須になるように設定しています。
Terraform
今回検証する構成の全リソースを1ファイルにまとめています。
参考にして下さい。
main.tf(クリックで展開)
##############################################
# 1アプリ(VPC / ALB / Fargate 各1)+ Cognitoユーザープール2つの構成。
# - internal: Google(OIDC)フェデレーション専用。MFAなし
# - partner : ローカルユーザーのみ。MFA必須(TOTP)・パスワード最小12文字
# ALBはホスト名別のリスナールールで、それぞれ別プールのauthenticate-cognitoを実行する。
# 全リソースを本ファイルに集約している。
##############################################
terraform {
required_version = ">= 1.9"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.57"
}
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = "cognito-idp-pool-isolation-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 = "internalプールにフェデレーションする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-pool-isolation-poc"
# internal / partner の2系統の定義。各リソースへfor_eachで展開する
realms = {
internal = {
host = "internal.pool-isolation-poc.${var.root_domain}"
cognito_domain_prefix = "pool-isolation-poc-int001" # リージョン内で一意である必要がある
session_cookie_name = "AuthInternal"
listener_rule_priority = 10
}
partner = {
host = "partner.pool-isolation-poc.${var.root_domain}"
cognito_domain_prefix = "pool-isolation-poc-ptr001"
session_cookie_name = "AuthPartner"
listener_rule_priority = 20
}
}
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
# 証明書は1枚にSANで2ホストをまとめる(internal./partner.)
##############################################
resource "aws_acm_certificate" "this" {
domain_name = local.realms.internal.host
subject_alternative_names = [local.realms.partner.host]
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]
}
# 2ホストとも同じALBへ向ける
resource "aws_route53_record" "alb_alias" {
for_each = local.realms
zone_id = data.aws_route53_zone.root.zone_id
name = each.value.host
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 = "aws ecr get-login-password --region ${var.aws_region} | docker login --username AWS --password-stdin ${local.ecr_registry}"
}
}
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 build --platform linux/arm64 -t ${aws_ecr_repository.app.repository_url}:${local.app_source_hash} ${path.module}/../django && docker push ${aws_ecr_repository.app.repository_url}:${local.app_source_hash}"
}
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 build --platform linux/arm64 -t ${aws_ecr_repository.nginx.repository_url}:${local.nginx_source_hash} ${path.module}/../nginx && docker push ${aws_ecr_repository.nginx.repository_url}:${local.nginx_source_hash}"
}
depends_on = [null_resource.build_setup, aws_ecr_repository.nginx]
}
##############################################
# Cognito User Pool ×2(internal / partner)
##############################################
# 社内用プール(Googleフェデレーション専用、MFAなし)
resource "aws_cognito_user_pool" "internal" {
name = "${local.name}-internal-pool"
user_pool_tier = "LITE"
auto_verified_attributes = ["email"]
username_attributes = ["email"]
}
# 社外用プール(ローカルユーザー、MFA必須(TOTP)、パスワード最小12文字)
resource "aws_cognito_user_pool" "partner" {
name = "${local.name}-partner-pool"
user_pool_tier = "LITE"
auto_verified_attributes = ["email"]
username_attributes = ["email"]
mfa_configuration = "ON"
software_token_mfa_configuration {
enabled = true
}
password_policy {
minimum_length = 12
require_lowercase = true
require_uppercase = true
require_numbers = true
require_symbols = true
}
}
locals {
pools = {
internal = aws_cognito_user_pool.internal
partner = aws_cognito_user_pool.partner
}
}
# Hosted UI(クラシック)のドメイン
resource "aws_cognito_user_pool_domain" "this" {
for_each = local.realms
domain = each.value.cognito_domain_prefix
user_pool_id = local.pools[each.key].id
managed_login_version = 1
}
# internalプールのGoogle 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.internal.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" "this" {
for_each = local.realms
name = "${local.name}-${each.key}-client"
user_pool_id = local.pools[each.key].id
generate_secret = true
allowed_oauth_flows_user_pool_client = true
allowed_oauth_flows = ["code"]
allowed_oauth_scopes = ["openid", "email"]
callback_urls = ["https://${each.value.host}/oauth2/idpresponse"]
logout_urls = ["https://${each.value.host}/logged-out"]
# internalはGoogleのみ、partnerはCognitoローカルのみを許可する
supported_identity_providers = each.key == "internal" ? ["Google"] : ["COGNITO"]
depends_on = [aws_cognito_identity_provider.google]
}
# 管理者グループ(両プールに同名で作成)
resource "aws_cognito_user_group" "admin" {
for_each = local.realms
name = "admin-users"
user_pool_id = local.pools[each.key].id
}
##############################################
# 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 = "*" },
# アプリがホスト名からApp Client・Cookie名・ログアウト先を引くためのマップ
{
name = "CLIENT_MAP"
value = jsonencode({
for k, r in local.realms : r.host => {
realm = k
cognito_domain = "${aws_cognito_user_pool_domain.this[k].domain}.auth.${var.aws_region}.amazoncognito.com"
client_id = aws_cognito_user_pool_client.this[k].id
cookie = r.session_cookie_name
logout_uri = "https://${r.host}/logged-out"
issuer = "https://${local.pools[k].endpoint}"
}
})
},
]
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]
}
##############################################
# ALB(1台・1リスナー、ホスト名別ルールで認証を振り分け)
##############################################
resource "aws_lb" "this" {
name = "pool-isolation-poc"
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 = "pool-isolation-poc"
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 = "fixed-response"
fixed_response {
content_type = "text/plain"
message_body = "Not Found"
status_code = "404"
}
}
}
# 非認証で転送するパス(トップページとログアウト後ページ)
resource "aws_lb_listener_rule" "public" {
listener_arn = aws_lb_listener.https.arn
priority = 5
condition {
host_header {
values = [for r in local.realms : r.host]
}
}
condition {
path_pattern {
values = ["/", "/logged-out"]
}
}
action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
# ホスト名別の認証ルール(それぞれ別プールのauthenticate-cognitoを実行)
resource "aws_lb_listener_rule" "realm_auth" {
for_each = local.realms
listener_arn = aws_lb_listener.https.arn
priority = each.value.listener_rule_priority
condition {
host_header {
values = [each.value.host]
}
}
action {
type = "authenticate-cognito"
order = 1
authenticate_cognito {
user_pool_arn = local.pools[each.key].arn
user_pool_client_id = aws_cognito_user_pool_client.this[each.key].id
user_pool_domain = aws_cognito_user_pool_domain.this[each.key].domain
session_cookie_name = each.value.session_cookie_name
on_unauthenticated_request = "authenticate"
}
}
action {
type = "forward"
order = 2
target_group_arn = aws_lb_target_group.app.arn
}
}
##############################################
# outputs
##############################################
output "app_urls" {
value = { for k, r in local.realms : k => "https://${r.host}/" }
}
output "cognito_hosted_ui_domains" {
value = {
for k in keys(local.realms) :
k => "${aws_cognito_user_pool_domain.this[k].domain}.auth.${var.aws_region}.amazoncognito.com"
}
}
output "google_oauth_redirect_uri_to_register" {
description = "Google Cloud ConsoleのOAuthクライアントに登録する承認済みリダイレクトURI(internalプールのHosted UI宛)"
value = "https://${aws_cognito_user_pool_domain.this["internal"].domain}.auth.${var.aws_region}.amazoncognito.com/oauth2/idpresponse"
}
設定の勘所を4つ抜粋します。
1. ユーザープールの分離
partnerプールにだけMFA必須とパスワードポリシーを設定します。プール単位の設定なので、internal側には一切影響しません。
resource "aws_cognito_user_pool" "partner" {
# ...(省略)...
mfa_configuration = "ON"
software_token_mfa_configuration {
enabled = true
}
password_policy {
minimum_length = 12
require_lowercase = true
require_uppercase = true
require_numbers = true
require_symbols = true
}
}
2. ALBの認証はリスナールール単位
ホストヘッダー条件のルールを2本作り、authenticate_cognitoにそれぞれ別プールのARN・App Client・ドメインを指定します。Cookie名(session_cookie_name)もルールごとに変えられます。デフォルトアクションは404にして、どちらのホスト名でもないアクセスを遮断します。
なお、トップページ(/)とログアウト後のランディングページ(/logged-out)は優先度5のルールで認証なしで通しています。トップページにログインボタンを置くため、そしてログアウト後の着地先は非認証でアクセスできる必要があるためです。
3. 許可IdPを片方だけにすると、その画面には入口自体が出ない
supported_identity_providers = each.key == "internal" ? ["Google"] : ["COGNITO"]
internal側はGoogleのみ、partner側はCognitoローカルのみを許可します。これで社内用の画面にパスワード欄が出ることも、社外用の画面にGoogleボタンが出ることもなくなります。
Djangoアプリでの処理内容
アプリ側は1つで2つのホスト名(社内/社外)を受けるため、ホスト名から各認証情報を引けるようにします。Terraformが環境変数CLIENT_MAPに次のJSONを渡します。
{
"internal.pool-isolation-poc.<ドメイン>": {
"realm": "internal",
"cognito_domain": "pool-isolation-poc-int001.auth.ap-northeast-1.amazoncognito.com",
"client_id": "<internal用App ClientのID>",
"cookie": "AuthInternal",
"logout_uri": "https://internal.pool-isolation-poc.<ドメイン>/logged-out",
"issuer": "https://cognito-idp.ap-northeast-1.amazonaws.com/<internalプールID>"
},
"partner.pool-isolation-poc.<ドメイン>": {
"realm": "partner",
"cognito_domain": "pool-isolation-poc-ptr001.auth.ap-northeast-1.amazoncognito.com",
"client_id": "<partner用App ClientのID>",
"cookie": "AuthPartner",
"logout_uri": "https://partner.pool-isolation-poc.<ドメイン>/logged-out",
"issuer": "https://cognito-idp.ap-northeast-1.amazonaws.com/<partnerプールID>"
}
}
アクセストークンの署名検証。ALBが転送するx-amzn-oidc-accesstokenを、ホストに対応するプールのJWKSで検証します。issとclient_idが期待値と一致することも確認する処理にしています。
def _verify_access_token(request, conf):
"""x-amzn-oidc-accesstokenを署名検証し、クレームを返す。
検証内容: RS256署名(発行元プールのJWKS)・有効期限・iss・client_id。
検証に失敗した場合はNoneを返す。
"""
raw = request.headers.get("x-amzn-oidc-accesstoken", "")
if not raw or not conf:
return None
issuer = conf["issuer"]
try:
client = _jwks_clients.setdefault(
issuer, PyJWKClient(f"{issuer}/.well-known/jwks.json")
)
signing_key = client.get_signing_key_from_jwt(raw)
claims = jwt.decode(raw, signing_key.key, algorithms=["RS256"], issuer=issuer)
if claims.get("client_id") != conf["client_id"]:
return None
return claims
except jwt.PyJWTError:
return None
また、ロール(一般/管理者)判定に、アクセストークンのcognito:groupsを使用しており、admin-usersがあれば管理者、なければ一般、というデフォルト拒否の1本で判定します。
groups = (claims or {}).get("cognito:groups", [])
is_admin = ADMIN_GROUP in groups # ADMIN_GROUP = "admin-users"
ここで注意が必要なのは、ALBがターゲットに渡す3つのヘッダーのうち、グループが入るのはアクセストークンだけという点です。
| ヘッダー | 中身 | cognito:groups |
|---|---|---|
x-amzn-oidc-data |
userInfoエンドポイント由来のユーザークレーム(JWT) | 含まれない |
x-amzn-oidc-accesstoken |
Cognitoが発行したアクセストークン | 含まれる |
x-amzn-oidc-identity |
sub(平文) |
含まれない |
また、ALBにはログアウトの機能がないため、アプリ側でホストに対応するCookieを失効させ、対応するプールのLOGOUTエンドポイントへリダイレクトします。
def logout(request):
"""ホストに対応するCookieを失効させ、対応するプールのLOGOUTエンドポイントへリダイレクトする"""
conf = _realm_config(request)
if not conf:
return HttpResponseRedirect("/")
params = urllib.parse.urlencode(
{"client_id": conf["client_id"], "logout_uri": conf["logout_uri"]}
)
response = HttpResponseRedirect(f"https://{conf['cognito_domain']}/logout?{params}")
# ALBは4KBを超える認証セッションCookieを `<name>-0`〜`-3` のシャードに分割するため、ログアウト時もそれに対応
cookie_names = (
[conf["cookie"]]
+ [f"{conf['cookie']}-{i}" for i in range(4)]
+ ["AWSALBAuthNonce"]
)
for name in cookie_names:
response.delete_cookie(name, path="/")
return response
やってみた
Google OAuthクライアントの作成
internalプールのGoogleフェデレーション用に、Google Cloud ConsoleでOAuthクライアントを作成しました。承認済みドメインにはamazoncognito.comを登録しています。GoogleとやりとりするのはCognitoなので、ALB側のドメインは登場しません。

承認済みのリダイレクトURIには、internalプールのHosted UIドメイン宛の/oauth2/idpresponseを指定しました。Hosted UIのドメインプレフィックスをTerraformで固定値にしており、URIが確定しているためこの段階で登録します。

環境構築と設定の確認
以下のコマンドで環境を構築しました。
terraform init
terraform apply -var="google_client_id=<クライアントID>" -var="google_client_secret=<シークレット>"
設定が意図どおりか、AWS CLIで確認しました。まずALBのリスナールールです。1つのリスナーに、異なるユーザープールを向いたauthenticate-cognitoルールが2本同居できています。
aws elbv2 describe-rules --listener-arn <HTTPSリスナーのARN>
[
{ "Priority": "5",
"Host": ["partner.pool-isolation-poc.<ドメイン>", "internal.pool-isolation-poc.<ドメイン>"],
"Path": ["/", "/logged-out"],
"Actions": [ { "Type": "forward" } ] },
{ "Priority": "10",
"Host": ["internal.pool-isolation-poc.<ドメイン>"],
"Actions": [
{ "Type": "authenticate-cognito",
"Pool": "arn:aws:cognito-idp:ap-northeast-1:<AWS_ACCOUNT_ID>:userpool/ap-northeast-1_jm3zEQBgX",
"Cookie": "AuthInternal" },
{ "Type": "forward" } ] },
{ "Priority": "20",
"Host": ["partner.pool-isolation-poc.<ドメイン>"],
"Actions": [
{ "Type": "authenticate-cognito",
"Pool": "arn:aws:cognito-idp:ap-northeast-1:<AWS_ACCOUNT_ID>:userpool/ap-northeast-1_thog2RT8A",
"Cookie": "AuthPartner" },
{ "Type": "forward" } ] },
{ "Priority": "default",
"Actions": [ { "Type": "fixed-response" } ] }
]
プールの設定差分です。partnerプールだけMFAがON、パスワード最小長が12文字になっています。
// aws cognito-idp describe-user-pool の抜粋
// internal
{ "Name": "cognito-idp-pool-isolation-poc-internal-pool",
"Tier": "LITE", "Mfa": "OFF", "PwMinLen": 8 }
// partner
{ "Name": "cognito-idp-pool-isolation-poc-partner-pool",
"Tier": "LITE", "Mfa": "ON", "PwMinLen": 12 }
App Clientの許可IdPです。片方ずつになっています。
// aws cognito-idp describe-user-pool-client の抜粋
{ "Name": "cognito-idp-pool-isolation-poc-internal-client",
"IdPs": ["Google"],
"Callback": ["https://internal.pool-isolation-poc.<ドメイン>/oauth2/idpresponse"] }
{ "Name": "cognito-idp-pool-isolation-poc-partner-client",
"IdPs": ["COGNITO"],
"Callback": ["https://partner.pool-isolation-poc.<ドメイン>/oauth2/idpresponse"] }
ログイン窓口の分離を確認する
それでは、実際に構築したWebアプリ環境に対してアクセスしてみます。
まずは、社内用のトップページです。「Googleでログイン」ボタンだけが確認できました。

以下の通りボタンを押すと、Cognitoのログイン画面を経由せずGoogleのアカウント選択画面に直行できました。

社外用のトップページと、その先のログイン画面です。こちらはメール/パスワード欄だけで、Googleボタンは存在しませんでした。


社外ユーザーだけMFAとパスワードポリシーが効く
社外用のログイン画面からサインアップしました。パスワードには最小12文字の制限が効いており、partnerプールのパスワードポリシーが適用されています。

サインアップを進めてメールの確認コードを入力すると、続けてTOTPの登録を求められました。MFAを必須にしたプールでは、Hosted UIがサインアップの流れの中で自動的にセットアップを促すことが確認できました。

2回目以降のサインインでは、パスワードに加えてTOTPコードの入力を求められました。

ログイン後の画面です。窓口の判定が「社外(partner)」、issがpartnerプール、cognito:groupsが無いため「一般」と判定されています。x-amzn-oidc-dataのクレーム名一覧にcognito:groupsが含まれていないことも確認できました。

社内ユーザーはMFAなしでログインできる
社内用の窓口からGoogleでサインインすると、MFAを一度も求められずにログインが完了しました。internalプールはMFA OFFの明示設定なので、期待通りの挙動です。

ここで、注意したいのはCognitoは追加したIdPごとに[プールID]_[IdP名]形式のグループを自動生成し、フェデレーションユーザーをそこへ自動的に追加します。
そのためクレーム自体は最初から存在しますが、こちらで管理しているadmin-usersではないため、ロール判定は「一般」のままです。
ロール判定の際に「グループなし=一般」ではなく「admin-usersがあれば管理者」という判定にしておくことで、この自動グループの影響を受けないようにしましょう。
グループで管理者だけを昇格させる
一般ユーザーが管理者専用ページにアクセスすると403になりました。

管理者への昇格は、意図的に自動化せず運用の明示操作としました。一般ユーザーはグループ無所属のままで表現できるため、サインアップしただけで一般ユーザーとして完成し、グループ運用の作業はゼロです。権限を持つのは明示的に昇格させたユーザーだけ、というデフォルト拒否の設計としています。
以下のコマンドで一般ユーザーを管理者グループに所属させ昇格させてみます。
aws cognito-idp admin-add-user-to-group \
--user-pool-id <プールID> \
--username <ユーザー名> \
--group-name admin-users
ここで重要なのが反映タイミングです。グループ変更が反映されるのは次回のトークン発行からなので、昇格直後のセッションではロールは変わりません。反映にはログアウト→再サインインを伴います。
なので、一度ログアウトして再サインインしました。

下記のように管理者専用ページにもアクセスできるようになりました(社外側の管理者でも同じ判定ロジックで通ります)。

なおユーザー管理の面では、コンソールのユーザー一覧もプールごとに完全に分かれています。internalプールにはGoogle経由で自動作成されたフェデレーションユーザーだけが並び、社外ユーザーと混ざっていません。

セッションとログアウトも独立している
ALBが発行するセッションCookieがホストごとに別名になっていることを確認できました。社内側はAuthInternal-*、社外側はAuthPartner-*が発行されています。片方でログイン済みでも、もう片方のホストでは改めて認証を求められました。


ログアウトすると、そのホストに対応するプールのLOGOUTエンドポイントを経由して/logged-outに着地し、Cookieが失効しました。


最後に
1つのALB・1つのアプリのまま、Cognitoユーザープールを社内用・社外用に分割する構成を検証しました。確認できたことをまとめます。
| 項目 | 結果 |
|---|---|
| ログイン窓口 | 社内外でホストを分けて分離可能を確認 |
| MFA | 社外だけTOTP必須(サインアップの流れで登録を強制)。社内はMFAなし可能 |
| パスワードポリシー | 社外だけ最小12文字。社内はデフォルトのままのようにポリシー分離可能 |
| ユーザー管理 | 一覧・グループ・IAM権限境界がプール単位で完全に分離 |
| ロール | admin-usersグループの有無だけで社内外共通の管理者判定が可能 |
| セッション | Cookie名がホスト別で、ログイン状態・ログアウトが互いに独立 |
今回検証した構成の運用面のポイントです。
- 管理者の昇格は明示操作にする: 一般ユーザーをグループ無所属で表現すれば、グループ運用の作業は管理者の昇格だけになる。デフォルト拒否の設計とも整合する
- グループ変更は次回トークン発行から反映される: ALBのセッションが生きている間は旧ロールのままなので、即時の権限剥奪が要件ならセッション時間の短縮やサーバー側チェックの併用が必要
また、社内外のユーザーが同一のコミュニティとして混ざるサービス(コラボレーション基盤など)でディレクトリの統合自体に価値がある場合は、1プール+App Client分割も選択肢になります。
今回は以上です。








