複数の S3 バケットを跨いでも Cognito の認証状態は維持されるのか教えてください

2022.12.16

この記事はアノテーション株式会社 AWS Technical Support Advent Calendar 2022 | Advent Calendar 2022 - Qiita 16日目の記事です。

困っていた内容

各画像(A, B)が異なる S3 バケットに保管されている状況下で下記手順のように、② の Web ページを仲介して S3 バケットを跨ぐ形で各画像にアクセスを行っても、Cognito ユーザープールで認証された状態は維持されるのか教えて下さい。

  • S3 バケット A
    • 画像 A が保管されている
  • S3 バケット B
    • 画像 B が保管されている
  • 手順
    1. Cognito ユーザープールに対して認証を行います
    2. 認証成功後、画像 A と画像 B のリンクが表示された Web ページに遷移する
    3. 2.の Web ページで画像 A のリンクをクリックして、画像 A を表示させる
    4. 3.と同じ様に 2.の Web ページで画像 B のリンクをクリックして、画像 B を表示させる

Cognito の認証された状態は維持されるの?

はい、S3 バケットを跨いでのアクセスには関係なく、JSON Web トークン (JWT)の有効期限内は Amazon Cognito によって認証された状態となり、トークン の有効期限内は維持(保持)される仕組みとなっています。

Cognito ユーザープールに認証された状態とは?

Cognito ユーザープールに対して、ユーザーが正常に認証されると AWS 認証情報と交換することができる JSON Web トークン (JWT) を Amazon Cognito が発行します。

Amazon Cognito user pools - Amazon Cognito

ユーザーが正常に認証されると、ユーザー独自の API へのアクセスをセキュア化して承認するために使用する、または AWS 認証情報と交換することができる JSON Web トークン (JWT) を Amazon Cognito が発行します。

ユーザーはこの発行されたトークンを使用することで、自身が Cognito ユーザープールで認証された状態であることを示すとともに、各 AWS サービスのリソースにアクセスするために必要な一時的な AWS 認証情報と交換します。

サインイン後に ID プールを使用して AWS サービスへアクセスする - Amazon Cognito

ユーザーがユーザープールを使用してサインインし、その後 ID プールを使用して AWS のサービスにアクセスできるようにすることが可能です。

認証が正常に行われると、ウェブまたはモバイルアプリが Amazon Cognito からユーザープールトークンを受け取ります。

これらのトークンを使用して、アプリケーションが AWS のその他サービスにアクセスできるようにする AWS 認証情報を取得できます。

また、各 AWS サービスのアクセス先のリソースでは、この発行されたトークンを検証することで、その有効性を確認します。

JSON web トークンの検証 - Amazon Cognito

JWT クレームを検証する

  1. トークンの有効期限が切れていないことを確認します。

  2. ID トークンの aud クレームとアクセストークンの client_id クレームは、Amazon Cognito ユーザープールで作成されたアプリクライアント ID と一致する必要があります。

  3. 発行者 (iss) のクレームは、ユーザープールと一致する必要があります。例えば、us-east-1 リージョンで作成されたユーザープールには、以下の iss 値があります。https://cognito-idp.us-east-1.amazonaws.com/.

  4. token_use クレームをチェックします。

    ・Web API オペレーションでアクセストークンのみを受け入れている場合は、その値を access にする必要があります。

    ・ID トークンのみを使用している場合、その値は id にする必要があります。

    ・ID とアクセストークンのいずれも使用している場合、token_use クレームは、id または access になります。

以上より、Cognito ユーザープールに認証された状態とは、Amazon Cognito によって発行されたトークンが有効である状態を示します。

検証して確認してみた

上記の状況を再現して、S3 バケットを跨いでのアクセスには関係なく、JSON Web トークン (JWT)の有効期限内は Amazon Cognito によって認証された状態となり、トークン の有効期限内は維持されるのか実際に検証して確認してみます。

検証に必要な各 AWS リソース等を下記の順番で準備・作成していきます。

  • Amazon S3
  • Amazon Cognito
  • AWS IAM
  • AWS Cloud9

Amazon S3

バケットの作成

検証用に子猫の画像を格納する「test-bucket-cat」と、子犬の画像を格納する「test-bucket-dog」をバケット名とする、2 つの S3 バケットを作成します。

以降では子猫の画像を格納する S3 バケット(test-bucket-cat)の作成および各種設定手順を例に説明します。

  • バケット名(命名要件に従い任意の名前を入力してください)
    • “test-bucket-cat” or “test-bucket-dog”
  • AWS リージョン
    • アジアパシフィック(東京)ap-northeast-1
  • オブジェクト所有者
    • ACL 無効(推奨)

  • このバケットのブロックパブリックアクセス設定
    • パブリックアクセスをすべて ブロック にチェック

  • バケットのバージョニング
    • 無効にする
  • タグ
    • 設定なし
  • デフォルトの暗号化
    • サーバー側の暗号化 → 無効にする

  • 詳細設定
    • オブジェクトロック → 無効にする

Cross-Origin Resource Sharing (CORS)の設定

2つの S3 バケットに保管された画像に確認用 Web ページ からアクセスする際に必要となる CORS の設定を行います。

  • Amazon S3 > バケット > [作成したバケット名] の順にアクセス
  • [アクセス許可]のタブを選択

  • 画面最下部の[Cross-Origin Resource Sharing (CORS)] > 編集 を押下

  • 下記の CORS の設定をコピーして、編集エリアに貼り付けて、[変更の保存]を押下

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET",
            "HEAD"
        ],
        "AllowedOrigins": [
            "*"
        ],
        "ExposeHeaders": []
    }
]

アクセスポイントの作成

確認用 Web ページのプログラムから、S3 バケットに対する「listObjects」メソッドと「getObject」メソッドの引数として指定するため、アクセスポイントを作成します。

  • アクセスポイント名(命名要件に従い任意の名前を入力してください)
    • test-bucket-cat バケット: cat
    • test-bucket-dog バケット: dog
  • ネットワークオリジン
    • インターネット
  • [アクセスポイントの作成]を押下

作成した S3 バケットに画像を格納

検証確認用の画像を作成した S3 バケットに保管します。

今回はフリー画像素材の pixabay から2つの画像(子猫と子犬)をダウンロードして使用したいと思います。

  • Amazon S3 > バケット > test-bucket-cat の順にアクセス

  • [オブジェクト]タブを選択して、ドラッグアンドドロップで画像を格納します(今回は検証確認で分かりやすくするため以下のように画像を各 S3 バケットに保管しています)
    • "test-bucket-cat" バケットには「子猫」の画像
    • "test-bucket-dog" バケットには「子犬」の画像
  • アップロード画面に自動で遷移されましたら、アップロードした画像を確認して問題なければ画面右下の[アップロード]を押下

Amazon Cognito

今回の検証用で使用する Cognito ユーザープールと ID プールを作成します。

ユーザープールの作成

  • サインインエクスペリエンスを設定
    • プロバイダーのタイプ
      • Cognito ユーザープール
    • Cognito ユーザープールのサインインオプション
      • E メール

  • セキュリティ要件を設定
    • パスワードポリシー
      • パスワードポリシーモード
        • Cognito のデフォルト
    • 多要素認証
      • MFA なし

  • ユーザーアカウントの復旧
    • デフォルト設定のままでOK

  • サインアップエクスペリエンスを設定
    • デフォルト設定のままでOK

  • メッセージ配信を設定
    • Cognito で E メールを送信

  • アプリケーションを統合
    • ユーザープール名(命名要件に従い任意の名前を入力してください)
      • TestUserPool
    • ホストされた認証ページ
      • Cognito のホストされた UI を使用(今回の検証では使用しません : チェック不要)

  • 最初のアプリケーションクライアント
    • アプリケーションタイプ
      • パブリッククライアント
    • アプリケーションクライアント名(命名要件に従い任意の名前を入力してください)
      • TestAppClient
    • クライアントのシークレット
      • クライアントのシークレットを生成しない

  • 高度なアプリケーションクライアントの設定
    • 認証フロー
      • ALLOW_REFRESH_TOKEN_AUTH(デフォルト設定)
      • ALLOW_USER_SRP_AUTH
    • 認証フローセッション持続期間
      • 3 分(デフォルト設定)
    • 更新トークンの有効期限
      • 30 日(デフォルト設定)
    • アクセストークンの有効期限
      • 5 分
    • ID トークンの有効期限
      • 5 分
    • 高度なセキュリティ設定 - オプション
      • デフォルト設定のままでOK

  • 属性の読み取りおよび書き込み許可
    • デフォルト設定のままでOK

ID プールの作成

  • 新しい ID プールの作成
    • ID プール名(命名要件に従い任意の名前を入力してください)
      • TestIdPool
  • 認証されていない ID
    • 今回の検証では使用しません(チェック不要)
  • 認証フローの設定(基本 (クラシック) フローを許可する)
    • 今回の検証では使用しません(チェック不要)
  • 認証プロバイダー
    • [Cognito] のタブを選択
      • ユーザープール ID
        • ap-northeast-1_*********
      • アプリクライアント ID
        • Amazon Cognito > ユーザープール > [作成したユーザープール] > [アプリケーションの統合]タブを選択
          • 画面最下部の [アプリクライアントと分析] の クライアントID を指定

  • Identify the IAM roles to use with your new identity pool
    • Cognito ユーザープールに対して認証されたユーザーに対する IAM ロールは後ほど設定しますので、右下の[許可]を選択でOKです

  • IdentityPoolId は後ほど使用するのでメモする等控えておいてください

AWS IAM

Cognito_[作成した ID プール名]Auth_Role の編集

  • IAM > ロール > Cognito_[作成したID プール名]Auth_Role に移動する

  • 該当のポリシー名付近の+ボタンをクリックして展開後、[編集]を押下

  • [JSON]タブを選択して、下記の IAM ポリシーをコピーして、編集エリアに貼り付けて、[ポリシーの確認]を押下

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:*"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}
  • [変更の保存]を押下

AWS Cloud9

Cloud9 環境を作成

  • Details
    • Name(命名要件に従い任意の名前を入力してください)
    • Environment type
      • New EC2 instance(デフォルト設定)

  • New EC2 instance
    • Instance type
      • t3.small (2 GiB GiB RAM + 2 vCPU)
    • Platform
      • Amazon Linux 2
    • Timeout
      • 30 minutes

  • Network settings
    • デフォルト設定のままでOK
  • Tags
    • 設定不要

検証用ソースコードの準備

  • 検証ソースコードのダウンロード
    • 今回の検証用のソースコードはこちらの記事を参考にさせていただきました

git clone https://github.com/junjis0203/restricted-s3-access-example.git
  • 検証用にソースコードを編集
    • 今回の検証用のため、ソースコードを一部下記のように書き換えます

config.js

  • 下記の例を参考に各変数にリソースID(ARN)等の値(文字列)を設定する
// リージョン
const REGION = 'ap-northeast-1'

// S3 - バケットアクセスポイントのARN
const S3_BUCKET_CAT = 'arn:aws:s3:ap-northeast-1:012345678912:accesspoint/cat'
const S3_BUCKET_DOG = 'arn:aws:s3:ap-northeast-1:012345678912:accesspoint/dog'

// Cognito - ユーザープール ID、クライアント ID、ID プールの ID
const USER_POOL_ID = 'ap-northeast-1_*********'
const APP_CLIENT_ID = '1a2bc3d45efg6hijkl7m8no9pq'
const IDENTITY_POOL_ID = 'ap-northeast-1:1a234bc5-67d8-9ef0-g1hi-2jk3lmn4567o'

index.html

  • 今回の検証用に一部変更や追記した下記ソースコードをコピーしてご活用ください
<html>
    <head>
    </head>
    <body>
        <div id="app">
        </div>

        <script src="https://sdk.amazonaws.com/js/aws-sdk-2.734.0.min.js"></script>
        <script src="https://cdn.jsdelivr.net/npm/amazon-cognito-identity-js@4.3.3/dist/amazon-cognito-identity.min.js"></script>
        <script src="config.js"></script>
        <script src="common.js"></script>
        <script>
            const cognitoUser = userPool.getCurrentUser();
            if (!cognitoUser) {
                document.location.href = 'signin.html'
            }

            cognitoUser.getSession(function(err, result) {
                if (err) {
                    console.error(err);
                    return
                }

                AWS.config.credentials = new AWS.CognitoIdentityCredentials({
                    IdentityPoolId: IDENTITY_POOL_ID,
                    Logins: {
                        [LOGINS_KEY]: result.getIdToken().getJwtToken()
                    },
                })
                AWS.config.credentials.refresh(function(err) {
                    if (err) {
                        console.error(err)
                        return
                    }

                    console.log('Refreshing AWS credentials is succeed')
                    getImagesCat()
                    getImagesDog()
                })
            })

            const s3 = new AWS.S3()

            function getImagesCat() {
                s3.listObjects({Bucket: S3_BUCKET_CAT}, function(err, data) {
                    if (err) {
                        console.error(err)
                        return
                    }

                    const ul = document.createElement('ul')
                    data.Contents.forEach(function(content) {
                        if (content.Key.endsWith('.jpg')) {
                            const li = document.createElement('li')
                            const a = document.createElement('a')
                            const br = document.createElement('br')
                            const img = document.createElement('img')
                            a.href = '#'
                            a.innerHTML = content.Key
                            a.addEventListener('click', function(e) {
                                e.preventDefault()

                                s3.getObject({Bucket: S3_BUCKET_CAT, Key: content.Key}, function(err, data) {
                                    if (err) {
                                        console.error(err)
                                        return
                                    }

                                    console.log(data)
                                    const blob = new Blob([data.Body], {type: data.ContentType})
                                    img.src = URL.createObjectURL(blob)
                                })
                            })
                            li.appendChild(a)
                            li.appendChild(br)
                            li.appendChild(img)
                            ul.appendChild(li)
                        }
                    })
                    const app = document.getElementById('app')
                    app.appendChild(ul)
                })
            }

            function getImagesDog() {
                s3.listObjects({Bucket: S3_BUCKET_DOG}, function(err, data) {
                    if (err) {
                        console.error(err)
                        return
                    }

                    const ul = document.createElement('ul')
                    data.Contents.forEach(function(content) {
                        if (content.Key.endsWith('.jpg')) {
                            const li = document.createElement('li')
                            const a = document.createElement('a')
                            const br = document.createElement('br')
                            const img = document.createElement('img')
                            a.href = '#'
                            a.innerHTML = content.Key
                            a.addEventListener('click', function(e) {
                                e.preventDefault()

                                s3.getObject({Bucket: S3_BUCKET_DOG, Key: content.Key}, function(err, data) {
                                    if (err) {
                                        console.error(err)
                                        return
                                    }

                                    console.log(data)
                                    const blob = new Blob([data.Body], {type: data.ContentType})
                                    img.src = URL.createObjectURL(blob)
                                })
                            })
                            li.appendChild(a)
                            li.appendChild(br)
                            li.appendChild(img)
                            ul.appendChild(li)
                        }
                    })
                    const app = document.getElementById('app')
                    app.appendChild(ul)
                })
            }
        </script>
    </body>
</html>

Cognito ユーザープールに対して認証を行う検証用ユーザーの作成

  • 検証用ユーザーを作成するシェルスクリプトファイルの作成および編集
$ touch create-testuser.sh
$ vi create-testuser.sh #(もしくは、Cloud9のエディタでファイルを開いて編集)
  • create-testuser.sh に以下を記述して保存
USER_POOL_ID=ap-northeast-1_*********
USER_NAME=<メールアドレス>
MAIL=<USER_NAMEと同じメールアドレス>

$ aws cognito-idp admin-create-user \
  --user-pool-id ${USER_POOL_ID} \
  --username ${USER_NAME} \
  --temporary-password <任意の一時的なパスワードを入力> \
  --user-attributes \
    Name=email,Value=${MAIL} \
    Name=email_verified,Value=True
  • create-testuser.sh を編集後、以下を実施
$ chmod +x create-testuser.sh
$ ./create-testuser.sh
{
    "User": {
        "Username": "a123bc45-abc1-1234-5678-12ab345cd6ef", 
        "Enabled": true, 
        "UserStatus": "FORCE_CHANGE_PASSWORD", 
        "UserCreateDate": **********.***, 
        "UserLastModifiedDate": **********.***, 
        "Attributes": [
            {
                "Name": "sub", 
                "Value": "a123bc45-abc1-1234-5678-12ab345cd6ef"
            }, 
            {
                "Name": "email_verified", 
                "Value": "True"
            }, 
            {
                "Name": "email", 
                "Value": "test@gmail.com"
            }
        ]
    }
}

検証

[Preview] をより確認

  • Cloud9 より index.html を右クリックして、[Preview] を選択

ログイン

  • Email と Password を入力して、[sign in] をクリック
  • New Password が表示されるため、新しいパスワードを入力して、[change password] をクリック

認証成功後

  • 認証が成功すると、各バケットに保管されている画像へのリンクが表示されます

  • Google Chrome デベロッパー ツールにおいて、ローカルストレージ内に、各種 JSON Web トークン (JWT)が取得できていることも確認できます

リンクをクリックして、異なる S3 バケットに保管されている画像を確認用 Web ページから表示してみる

  • 子猫の画像

  • 子犬の画像

JSON Web トークン (JWT)の有効期限が切れた場合の挙動確認

今回の検証では、JSON Web トークン (JWT)における、ID トークン(および、アクセストークン)の有効期限を 5 分に設定しているため、5分を経過した場合にどのような挙動になるか確認してみました。

1. トークンの有効期限が切れたとき - 画面の表示

  • 以下のとおり、画面には何も表示されなくなります

2. トークンの有効期限が切れたとき - コンソールログ

  • 以下のとおり、”CredentialsError” が記録されていることを確認しました

CredentialsError: Missing credentials in config, if using AWS_CONFIG_FILE, set AWS_SDK_LOAD_CONFIG=1

【補足】 ”CredentialsError” 後に、S3 バケットに格納している画像のリンクを再度表示させるには・・・

  • Cloud9の [Preview] を ”Refresh” すると、ローカルストレージに保管されている 更新トークン(リフレッシュトークン)を使用して再度新しい ID トークンとアクセストークンを取得することができます
    • その後、各 S3 バケットに保管されている画像へのリンクが再び表示されます

おまけ

JSON Web トークン (JWT) を検証するソースコードは GitHub - awslabs/aws-jwt-verify: JS library for verifying JWTs signed by Amazon Cognito, and any OIDC-compatible IDP that signs JWTs with RS256, RS384, and RS512 より、Amazon Cognito によって署名された JWT を検証するための JavaScript ライブラリが公開されています。

今回の検証で Amazon Cognito ユーザープールから取得した JSON Web トークン (JWT) を使用して、Cloud9 上から実行して確認してみました。

ソースコード - aws-jwt-verify.mjs

import { CognitoJwtVerifier } from "aws-jwt-verify";

// Verifier that expects valid access tokens:
const verifier = CognitoJwtVerifier.create({
  userPoolId: "ap-northeast-1_*********",
  tokenUse: "id",
  clientId: "1a2bc3d45efg6hijkl7m8no9pq",
});

try {
  const payload = await verifier.verify(
    "[ID トークンを入力してください]"
  );
  console.log("Token is valid. Payload:", payload);
} catch {
  console.log("Token not valid!");
}

実行例 - 有効期限内の ID トークンの場合

$ node aws-jwt-verify.mjs 
Token is valid. Payload: {
  sub: '1234abcd-123a-1234-abcd-abcd1234abcd',
  email_verified: true,
  iss: 'https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_*********',
  'cognito:username': '1234abcd-123a-1234-abcd-abcd1234abcd',
  origin_jti: '5678abcd-567a-5678-abcd-abcd5678abcd',
  aud: '1a2bc3d45efg6hijkl7m8no9pq',
  event_id: '1256abcd-125a-1256-abcd-abcd1256abcd',
  token_use: 'id',
  auth_time: **********,
  exp: **********,
  iat: **********,
  jti: 'ab1234dd-rta1-45f1-kd9d-89dsd3bjs9np',
  email: 'test@gmail.com'
}

実行例 - 有効期限切れの ID トークンの場合

$ node aws-jwt-verify.mjs 
Token not valid!

まとめ

  • S3 バケットを跨いでのアクセスには関係なく、JSON Web トークン (JWT)の有効期限内は Amazon Cognito(ユーザープール) によって認証された状態となり、トークンの有効期限内は維持(保持)されること
  • Amazon Cognito(ユーザープール)に認証された状態とは、Amazon Cognito(ユーザープール)によって発行されたトークンが有効である状態を示すこと

この記事が少しでも誰かのお役にたてば幸いです。

参考資料