IAM Identity Center と Cognito とを組み合わせて SPA へのアクセス制御をおこなう

はじめに

弊社では Entra ID ユーザ / グループを使い AWS 利用時の認証や権限制御を IAM Identity Center を使い実現しています。Entra ID と IAM Identity Center を SCIM で連携させることで Entra ID 側の情報を用いて達成しており、このあたりは 拙稿 に詳細があります。

IAM Identity Center は自身が ID プロバイダ(以下 IdP と書きます)になることもでき、この場合 SAML / OAuth2 で外部アプリケーションとやりとりすることが可能です。このあたりも 拙稿 として存在します。

今回、社内のちょっとした要件で SPA(single page application の SPA です。本稿題名含め以下でも同様)を作成する事情があり、これに対しアクセスが可能なメンバを社内でも絞っておきたい需要があったので、このあたりの制御を IAM Identity Center で行わせるようにしてみました。ただし SPA に直接 IAM Identity Center とやりとりさせるのは骨が折れるので、何らかの仲立ちが欲しいところです。そこで Cognito を使い、以下を実現しました。

  • SPA へのアクセス時に Cognito user pool + IAM Identity Center による認証を行い、アクセスしたユーザが SPA を使う上で妥当な権限を持っているか認証
    • この時の「認証」の材料には Entra ID ユーザ / グループを使う
    • SPA 用にユーザが払い出されるわけではなく、既に Entra ID 上に有るユーザを使用した SSO という認証体験になる
  • Cognito identity pool + IAM ロールによって SPA が AWS 上で実行可能なアクションを制御

これにより、SPA 動作にあたり 認証の為のバックエンドを特に設ける事なく、また IAM Identity Center を IdP とすることで Entra ID 側には触れずに Entra ID ユーザ / グループを用いた認証が可能 という構成をとることができました。このあたりを tips として紹介できればと思います。

構成

はじめに今回整備した諸々の全容を示します。

SPA の中身には立ち入りません。AWS の適当なサービスを触る必要がある(= IAM クレデンシャルが必要)ものと理解ください。

SPA 自体はシンプルで、S3 バケットに配置したものを CloudFront で配信しているのみになります。CloudFront を経由しないアクセスを防ぐよう origin access control による制限*1を加えています。

認証に関しては Cognito の user pool*2 に依拠しています。以下要領です。

  • user pool の IdP として IAM Identity Center を指定
  • SPA からは Cognito を隠蔽せず、アクセスされた際に Cognito が用意するログインページへリダイレクトし、Cognito での認証が完了したのちに SPA へ戻ってくるように構成
  • Cognito による認証(= IdP での認証)が成功しアクセストークンが返ってきた場合は SPA は正規のコンテンツを、失敗した場合はアクセス拒否時のコンテンツを返却

認証が完了したのち、得られたトークンと identity pool*3 とを使い、クレデンシャルを得、SPA は得られたクレデンシャルをもとに AWS API を叩きます。このとき identity pool には SPA に執り行わせたい IAM アクションを許可した IAM ロールを authenticated role として設定しておき、払い出されたクレデンシャルの効力範囲を適当に制限しておく構想としました。

ポイント

上述した内容、特に Cognito の user pool / identity pool はドキュメントに従った素直な使い方につき、特段の補足は必要無いと思われます。一方で Cognito と IAM Identity Center とを連携させる方面については少々難儀しました。この周辺について説明します。

IAM Identity Center を Cognito の IdP としてどのように設定するか

Cognito の user pool では特定のベンダに依存しない IdP 連携方法として SAMLOpenID Connect が選択できます*4。IAM Identity Center ではこの種の連携を行う際にアプリケーションを用意し設定するのですが*5、ここでの選択肢には SAML か OAuth 2.0 かのみです*6

共通要素としては SAML になり、もちろんこれでうまくいくので、Cognito で使う IdP として IAM Identity Center を使う場合は SAML で連携させればよい と承知しておいて頂ければ、本稿の内容は8割カバーできます。

アプリケーションの作成に関しては AWS 公式ドキュメントを読むのが手っ取り早いです。以下が該当します。
docs.aws.amazon.com

アプリケーション作成後に設定する各種設定に関しては以下が詳しいです。

repost.aws

そもそも「認証」をどのようにするか

ちょっと大袈裟な節名で自分でも困惑していますが、要は「Cognito が IAM Identity Center の結果を認証結果とするなら IAM Identity Center は何をもって認証するの?」ということです。

これは単純に今回用意した IAM Identity Center アプリケーションに紐付けされているユーザ / グループであれば OK という整理にしてあります。つまりは Redash の SSO ログインに関する拙稿 の整理と同様に

これには IAM Identity Center 内でアプリケーションというものを用意し、アクセスさせたいユーザ / グループをアプリケーションに紐付けることで達成

という方針としました。Cognito から IAM Identity Center に処理が遷移した際、SAML 連携に使用している IAM Identity Center アプリケーションに紐付けされているユーザであればアプリケーションはそのまま処理を通してくれるので、Cognito 側では特に何も考えず、連携だけを気にしておけばよくなり、話が単純になります。

繰り返しになりますが、弊社は IAM Identity Center を Entra ID と連携させ、IAM Identity Center で使えるユーザ / グループは Entra ID のそれを引き継いでいます。
よって Entra ID 側の情報を前提に「認証」に必要な条件を構成することで、IAM Identity Center でもそれを引き継いで構成することが可能なようになっています。この際 Entra ID には一切触れないで済ませることが可能です。

コード例

おまたせしました。コード例を示します。

今回は Terraform コードに加え、あくまで参考として SPA コードのうち Cognito を取り扱う箇所を抜粋で掲載します。SPA は Vue.js を使い作っており*7。記法のお作法(主に環境変数周辺)は Vite のそれに従ったものになります。

Terraform

前述の

アクセスさせたいユーザ / グループをアプリケーションに紐付ける

の方針をグループ単位で実施させる前提のコードです

こちらをクリックして参照のこと
locals {
  entra_id_groups = {
    spa = [
      "test"  
    ]
  }
}





data "aws_ssoadmin_instances" "main" {}

resource "aws_ssoadmin_application" "spa" {
  name                     = "SPA"
  description              = "SAML application for SPA"
  application_provider_arn = "arn:aws:sso::aws:applicationProvider/custom-saml"
  instance_arn             = tolist(data.aws_ssoadmin_instances.main.arns)[0]

  portal_options {
    visibility = "ENABLED"
    sign_in_options {
      origin = "IDENTITY_CENTER"
    }
  }
}


data "aws_identitystore_group" "spa" {
  for_each = toset(local.entra_id_groups.spa)

  identity_store_id = tolist(data.aws_ssoadmin_instances.main.identity_store_ids)[0]
  alternate_identifier {
    unique_attribute {
      attribute_path  = "DisplayName"
      attribute_value = each.key
    }
  }
}

resource "aws_ssoadmin_application_assignment" "spa" {
  for_each = toset(local.entra_id_groups.spa)

  application_arn = aws_ssoadmin_application.spa.arn
  principal_id    = data.aws_identitystore_group.spa[each.key].group_id
  principal_type  = "GROUP"
}
こちらをクリックして参照のこと
data "aws_s3_object" "saml_metadata" {
  



  bucket = aws_s3_bucket.static_files.id
  key    = "saml-metadata/spa.xml"
}

resource "aws_iam_saml_provider" "spa" {
  name                   = "spa"
  saml_metadata_document = data.aws_s3_object.saml_metadata.body
}

resource "aws_cognito_identity_pool" "spa" {
  identity_pool_name               = "spa"
  allow_unauthenticated_identities = false 
  saml_provider_arns = [
    aws_iam_saml_provider.spa.arn
  ]

  cognito_identity_providers {
    client_id               = aws_cognito_user_pool_client.spa.id
    provider_name           = "cognito-idp.${data.aws_region.current.region}.amazonaws.com/${aws_cognito_user_pool.spa.id}"
    server_side_token_check = false
  }
}

data "aws_iam_policy_document" "assume_from_cognito" {
  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.spa.id,
      ]
    }
    condition {
      test     = "ForAnyValue:StringLike"
      variable = "cognito-identity.amazonaws.com:amr"
      values = [
        "authenticated"
      ]
    }
  }
}





resource "aws_iam_role" "allow_actions_for_spa" {
  name               = "allow-actions-for-spa"
  assume_role_policy = data.aws_iam_policy_document.assume_from_cognito.json
}

data "aws_iam_policy_document" "allow_actions_for_spa" {
  statement {
    
  }
}


resource "aws_iam_role_policy" "allow_actions_for_spa" {
  name   = "allow-actions-for-spa"
  role   = aws_iam_role.allow_actions_for_spa.id
  policy = data.aws_iam_policy_document.allow_actions_for_spa.json
}


resource "aws_cognito_identity_pool_roles_attachment" "spa" {
  identity_pool_id = aws_cognito_identity_pool.spa.id
  roles = {
    "authenticated" = aws_iam_role.allowed_actions_for_spa.arn
  }
}

resource "aws_cognito_user_pool" "spa" {
  name = "spa"
}

resource "aws_cognito_user_pool_domain" "spa" {
  user_pool_id = aws_cognito_user_pool.spa.id
  domain       = "test"  
}


resource "aws_cognito_identity_provider" "main" {
  user_pool_id  = aws_cognito_user_pool.spa.id
  provider_name = "iam-identity-center"
  provider_type = "SAML"

  provider_details = {
    "MetadataFile" = data.aws_s3_object.saml_metadata.body
  }

  attribute_mapping = {
    "email" = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
  }

  lifecycle {
    ignore_changes = [
      provider_details, 
    ]
  }
}


resource "aws_cognito_user_pool_client" "spa" {
  name         = "SPA"
  user_pool_id = aws_cognito_user_pool.spa.id
  explicit_auth_flows = [
    "ALLOW_USER_AUTH",
    "ALLOW_CUSTOM_AUTH",
    "ALLOW_USER_SRP_AUTH",
    "ALLOW_REFRESH_TOKEN_AUTH",
  ]
  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_scopes = [
    "openid",
    "email",
  ]
  allowed_oauth_flows = [
    "code"
  ]
  supported_identity_providers = [
    aws_cognito_identity_provider.main.provider_name,
  ]
  token_validity_units {
    access_token  = "minutes"
    id_token      = "minutes"
    refresh_token = "days"
  }

  callback_urls        = ["https://${var.spa_domain}/path/to/app"]    
  default_redirect_uri = "https://${var.spa_domain}/path/to/app"  
}

resource "local_file" "spa" {
  content         = 
VITE_AWS_REGION=${data.aws_region.current.region}
VITE_USER_POOL_ID=${aws_cognito_user_pool.spa.id}
VITE_USER_POOL_CLIENT_ID=${aws_cognito_user_pool_client.spa.id}
VITE_IDENTITY_POOL_ID=${aws_cognito_identity_pool.spa.id}
VITE_COGNITO_DOMAIN=${aws_cognito_user_pool_domain.spa.id}.auth.${data.aws_region.current.region}.amazoncognito.com
VITE_REDIRECT_URI=https://${var.spa_domain}/path/to/spa
EOT
  filename        = "./path/to/spa/.env"  
  file_permission = "0644"
}

おまけとして S3 および CloudFront に関するコードも掲載しておきます

こちらをクリックして参照のこと
resource "aws_s3_bucket" "spa" {
  bucket = "test"    
}


data "aws_iam_policy_document" "spa" {
  statement {
    effect = "Allow"
    principals {
      type = "Service"
      identifiers = [
        "cloudfront.amazonaws.com",
      ]
    }
    actions = [
      "s3:GetObject",
    ]
    resources = [
      "${aws_s3_bucket.spa.arn}/*",
    ]
    condition {
      test     = "StringEquals"
      variable = "AWS:SourceArn"
      values = [
        aws_cloudfront_distribution.spa.arn,
      ]
    }
  }
}





resource "aws_s3_bucket_policy" "spa" {
  bucket = aws_s3_bucket.spa.id
  policy = data.aws_iam_policy_document.spa.json
}

resource "aws_cloudfront_origin_access_control" "spa" {
  name                              = "oac-for-spa"
  description                       = "OAC for SPA S3 bucket"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
}

data "aws_cloudfront_cache_policy" "caching_optimized" {
  name = "Managed-CachingOptimized"
}

data "aws_cloudfront_cache_policy" "caching_disabled" {
  name = "Managed-CachingDisabled"
}

resource "aws_acm_certificate" "spa" {
  provider = aws.us-east-1

  domain_name       = "*.test.example"  
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_acm_certificate_validation" "spa" {
  provider = aws.us-east-1

  certificate_arn = aws_acm_certificate.spa.arn
  validation_record_fqdns = [
    for record in aws_route53_record.spa_cert_validation : record.fqdn
  ]
}

resource "aws_cloudfront_distribution" "spa" {
  enabled = true
  tags = {
    Name = "spa"
  }
  aliases = [
    var.spa_domain,
  ]

  origin {
    domain_name              = aws_s3_bucket.spa.bucket_regional_domain_name
    origin_id                = aws_s3_bucket.spa.id
    origin_access_control_id = aws_cloudfront_origin_access_control.spa.id
  }

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD", "OPTIONS"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = aws_s3_bucket.spa.id

    viewer_protocol_policy = "redirect-to-https"

    
    cache_policy_id = data.aws_cloudfront_cache_policy.caching_optimized.id

    function_association {
      event_type   = "viewer-request"
      function_arn = aws_cloudfront_function.spa.arn
    }
  }

  
  ordered_cache_behavior {
    path_pattern           = "path/to/app/assets/*.js"
    allowed_methods        = ["GET", "HEAD", "OPTIONS"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = aws_s3_bucket.spa.id
    viewer_protocol_policy = "redirect-to-https"
    cache_policy_id        = data.aws_cloudfront_cache_policy.caching_optimized.id
  }

  
  ordered_cache_behavior {
    path_pattern           = "path/to/app/*"
    allowed_methods        = ["GET", "HEAD", "OPTIONS"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = aws_s3_bucket.spa.id
    viewer_protocol_policy = "redirect-to-https"
    cache_policy_id        = data.aws_cloudfront_cache_policy.caching_disabled.id
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn = aws_acm_certificate.spa.arn
    ssl_support_method  = "sni-only"
  }
}


resource "aws_cloudfront_function" "spa" {
  name    = "spa-router"
  runtime = "cloudfront-js-1.0"
  comment = "Rewrites SPA paths to index.html"

  publish = true

  code = 
function handler(event) {
  var request = event.request;
  var uri = request.uri;

  
  if (uri.split('.').pop() !== uri) {
    return request;
  }

  
  if (uri.startsWith('/path/to/app')) {
    request.uri = '/path/to/app/index.html';
  }

  return request;
}
EOT
}
認証部分
const CLIENT_ID = import.meta.env.VITE_USER_POOL_CLIENT_ID;
const COGNITO_DOMAIN = import.meta.env.VITE_COGNITO_DOMAIN;  # Cognito の user pool に対し設定できるドメイン(hosted UI アクセス時のドメインとして見える)の ID。環境変数経由で渡す
const REDIRECT_URI = import.meta.env.VITE_REDIRECT_URI;  # SPA の URL。Cognito で認証が成功した場合に戻ってくるため必要。環境変数経由で渡す

export function redirectToHostedUI() {
  const url = new URL(`https://${COGNITO_DOMAIN}/login`);
  url.searchParams.set("client_id", CLIENT_ID);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("scope", "openid email");
  url.searchParams.set("redirect_uri", REDIRECT_URI);
  window.location.href = url.toString();
}

export async function handleCognitoRedirect() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get("code");
  if (!code) return sessionStorage.getItem("id_token"); 

  const body = new URLSearchParams({
    grant_type: "authorization_code",
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    code,
  });

  const tokenResp = await fetch(`https://${COGNITO_DOMAIN}/oauth2/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: body.toString(),
  });

  if (!tokenResp.ok) throw new Error("Failed to fetch tokens");

  const tokens = await tokenResp.json();
  sessionStorage.setItem("id_token", tokens.id_token);
  return tokens.id_token;
}
クレデンシャル取得部分
import { S3Client } from "@aws-sdk/client-s3";   # S3 を触る場合の例として記載
import { CognitoIdentityClient, GetIdCommand, GetCredentialsForIdentityCommand } from "@aws-sdk/client-cognito-identity";

const REGION = import.meta.env.VITE_AWS_REGION;           # AWS リージョン。Cognito リソースが存在する場所。環境変数経由で渡す
const USER_POOL_ID = import.meta.env.VITE_USER_POOL_ID;
const IDENTITY_POOL_ID = import.meta.env.VITE_IDENTITY_POOL_ID;

export async function getS3Client() {
  const idToken = localStorage.getItem("id_token");
  if (!idToken) throw new Error("User not authenticated");

  const client = new CognitoIdentityClient({ region: REGION });

  const identityResp = await client.send(
    new GetIdCommand({
      IdentityPoolId: IDENTITY_POOL_ID,
      Logins: {
        [`cognito-idp.${REGION}.amazonaws.com/${USER_POOL_ID}`]: idToken,
      },
    })
  );

  const credResp = await client.send(
    new GetCredentialsForIdentityCommand({
      IdentityId: identityResp.IdentityId,
      Logins: {
        [`cognito-idp.${REGION}.amazonaws.com/${USER_POOL_ID}`]: idToken,
      },
    })
  );

  return new S3Client({
    region: REGION,
    credentials: {
      accessKeyId: credResp.Credentials.AccessKeyId,
      secretAccessKey: credResp.Credentials.SecretKey,
      sessionToken: credResp.Credentials.SessionToken,
      expiration: credResp.Credentials.Expiration,
    },
  });
}

おわりに

Cognito から IAM Identity Center を IdP として参照し、これを使用して SPA へのアクセス制御を実施する例について解説しました。

S3 + CloudFront で配信する諸々のアクセス制御として真っ先に浮かんでしまうのが私的にはアクセス元 IP アドレスによる境界制限(AWS WAF v2 などを使う想定)なのですが、IAM Identity Center に依拠したユーザ認証という選択肢が今回割合気軽に使え、なかなか現代的なアクセス制御ができるようになったと思っています。

セキュリティ的な方面から考えても

  • 境界防御ではなく認証による制限ができる
  • 既存の IdP の構成を使用して認証の設計ができ、ID 運用の分散が防げる
  • 既に IdP 上に存在するユーザを使っての SSO という挙動になり利用者の体験をあまり損わない

といったメリットがあり、見通しのよい構成となりました。

IAM Identity Center / Cognito l活用の一助として本稿が参考になれば幸いです。

MNTSQ 株式会社 SRE 秋本

参考




元の記事を確認する

関連記事