以前の記事にて、CloudFrontで静的サイトを配信するCDKを実装しました。
サイトにはAPI GatewayとLambdaを統合した機能も実装しましたが、APIのURLを知っていれば、誰でもAPIを直接叩ける状況でした。
今回はこの構成に認証を追加します。
いきなりCDKで実装できるほど、認証周りに詳しくないので、まずはコンソール上で認証基盤を構築し、認証部分が裏側でどう動いているのかを確認することにします。今回は構築部分の記事です。
全体の流れ
構築は以下の流れで実施してます。
- 配信コンテンツ用のバケット作成, ファイル格納
- Amazon Cognitoのユーザープール作成
- Lambda関数作成
- REST API作成 (API Gateway)
- 公開鍵とKey group作成 (CloudFront Signed Cookies用)
- CloudFront Distribution作成
- CognitoのCallback URLをCloudFrontに更新
- S3のコンテンツ修正
- 動作確認
1. 配信コンテンツ用のバケット作成, ファイル格納
今回は、配信するコンテンツ以外に、ログイン用のページなども作ります。
全てCloudFrontで配信するため、オリジンとするバケットを用意し、ファイルを格納します。
以下のディレクトリ構成でバケットを作成し、3つのファイルを格納しました。(CloudFrontで配信するため、Block Public AccessはONのまま、静的ウェブホスティングも不要です)
Bucket
├── index.html (CloudFrontの署名付きCookiesで保護したページ)
└── public/
└── login.html (ログイン用ページ)
└── callback.html (リダイレクト用ページ)
格納する3つのファイルはAIに書いてもらってます。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Private Site</title>
</head>
<body>
<h1>Private Site</h1>
<p>このページが表示できている=CloudFrontのSigned Cookiesで保護が通っています。</p>
<button id="pingBtn">/api/ping を呼ぶ(Authorization付き)</button>
<pre id="out"></pre>
<script>
const out = document.getElementById("out");
const btn = document.getElementById("pingBtn");
btn.addEventListener("click", async () => {
out.textContent = "calling...";
const idToken = sessionStorage.getItem("id_token"); // callback.htmlで保存したもの
if (!idToken) {
out.textContent = "id_token がありません。login.html からログインし直してください。";
return;
}
const res = await fetch("/api/ping", {
method: "GET",
headers: {
"Authorization": `Bearer ${idToken}`
},
credentials: "include",
cache: "no-store"
});
const text = await res.text();
out.textContent = `status=${res.status}\n${text}`;
});
</script>
</body>
</html>
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Login</title>
</head>
<body>
<h1>Login</h1>
<p>ログイン画面へ遷移します。</p>
<button id="loginBtn">Login with Cognito</button>
<script>
// ===== 設定(後で変更)=====
const COGNITO_DOMAIN = "後で追記";
const CLIENT_ID = "後で追記";
const REDIRECT_URI = `${location.origin}/public/callback.html`;
// 必要最小限: openid(IDトークンを得る)
const SCOPES = "openid";
// ===========================
function base64UrlEncode(uint8) {
let str = "";
for (const ch of uint8) str += String.fromCharCode(ch);
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
async function sha256(str) {
const data = new TextEncoder().encode(str);
const digest = await crypto.subtle.digest("SHA-256", data);
return new Uint8Array(digest);
}
function randomString(len = 64) {
const bytes = new Uint8Array(len);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
async function startLogin() {
const state = randomString(32);
const codeVerifier = randomString(64);
const codeChallenge = base64UrlEncode(await sha256(codeVerifier));
// PKCE/CSRF対策用に保存
sessionStorage.setItem("pkce_state", state);
sessionStorage.setItem("pkce_verifier", codeVerifier);
const authorizeUrl =
`${COGNITO_DOMAIN}/oauth2/authorize` +
`?response_type=code` +
`&client_id=${encodeURIComponent(CLIENT_ID)}` +
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
`&scope=${encodeURIComponent(SCOPES)}` +
`&state=${encodeURIComponent(state)}` +
`&code_challenge=${encodeURIComponent(codeChallenge)}` +
`&code_challenge_method=S256`;
location.href = authorizeUrl;
}
document.getElementById("loginBtn").addEventListener("click", () => {
startLogin().catch(err => {
console.error(err);
alert("ログイン開始に失敗しました。コンソールを確認してください。");
});
});
</script>
</body>
</html>
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Callback</title>
</head>
<body>
<h1>Callback</h1>
<pre id="log">処理中...</pre>
<script>
// ===== 設定(後で変更)=====
const COGNITO_DOMAIN = "後で追記";
const CLIENT_ID = "後で追記";
const REDIRECT_URI = `${location.origin}/public/callback.html`;
// CloudFront配下の同一オリジンで /auth/set-cookie を呼ぶ
const SET_COOKIE_ENDPOINT = `${location.origin}/auth/set-cookie`;
// ===========================
const logEl = document.getElementById("log");
function log(msg) { logEl.textContent += "\n" + msg; }
async function exchangeCodeForTokens(code, codeVerifier) {
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code,
redirect_uri: REDIRECT_URI,
code_verifier: codeVerifier,
});
const res = await fetch(`${COGNITO_DOMAIN}/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`token endpoint failed: ${res.status} ${txt}`);
}
return res.json(); // { id_token, access_token, refresh_token?, ... }
}
async function callSetCookie(idToken) {
const res = await fetch(SET_COOKIE_ENDPOINT, {
method: "POST",
headers: {
"Authorization": `Bearer ${idToken}`,
},
// 同一オリジン想定。明示しておく(Cookieを受け取りたい)
credentials: "include",
cache: "no-store",
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`/auth/set-cookie failed: ${res.status} ${txt}`);
}
return res.json();
}
async function main() {
// code/state を取得
const url = new URL(location.href);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const err = url.searchParams.get("error");
if (err) {
throw new Error(`Cognito error: ${err} ${url.searchParams.get("error_description") || ""}`);
}
if (!code || !state) {
throw new Error("Missing code/state in callback URL");
}
// state検証
const expectedState = sessionStorage.getItem("pkce_state");
const codeVerifier = sessionStorage.getItem("pkce_verifier");
if (!expectedState || !codeVerifier) {
throw new Error("Missing pkce_state/pkce_verifier (sessionStorage). Open login page again.");
}
if (state !== expectedState) {
throw new Error("Invalid state (possible CSRF).");
}
// URLからcodeを消しておく(見た目・ログ漏洩対策)
history.replaceState({}, document.title, "/public/callback.html");
log("Exchanging code for tokens...");
const tokens = await exchangeCodeForTokens(code, codeVerifier);
// 最低限: API呼び出しの検証用に id_token を保存(必要なければ消してOK)
sessionStorage.setItem("id_token", tokens.id_token || "");
sessionStorage.setItem("access_token", tokens.access_token || "");
log("Calling /auth/set-cookie...");
await callSetCookie(tokens.id_token);
// 使い終わったら消す(残してもいいが最小化)
sessionStorage.removeItem("pkce_state");
sessionStorage.removeItem("pkce_verifier");
log("Done. Redirecting to / ...");
location.href = "/";
}
main().catch(e => {
console.error(e);
log("ERROR: " + e.message);
});
</script>
</body>
</html>
2. Amazon Cognitoのユーザープール作成
Amazon Cognitoで、認証基盤となるユーザープールを作成します。
- アプリケーションタイプはシングルページアプリケーション (SPA)
- サインイン識別子のオプションはメールアドレス
- 誰でも登録させたいわけではないので、自己登録のチェックは外す
- サインアップのための必須属性もメールアドレス
ユーザープールができたら、お試し用のユーザーを作成。(メールアドレスとパスワードを設定するだけ)
さらに作成したユーザープールに関して、アプリケーションクライアントの認証フローに「ユーザー名とパスワードを使用してサインイン」を追加。
(簡単な検証なので、他のフローは消しても大丈夫そう)
3. Lambda関数作成
次にAPI Gatewayと統合する2つのLambda関数を作成します。
AuthSetCookie: Cognitoのログインを元に、CloudFrontへのアクセスを許可する関数
APIping: ログイン後のページに付けるAPI用の関数 (ログインしないと使えない)
それぞれ以下の通りです。
(環境変数に秘密鍵をそのまま使うなど、よろしくない部分もあります)
import os
import json
import time
import base64
import logging
from typing import Dict, Any, Optional
import requests
import jwt # PyJWT
from jwt.algorithms import RSAAlgorithm
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# 環境変数
COGNITO_USER_POOL_ID = os.environ["COGNITO_USER_POOL_ID"]
COGNITO_CLIENT_ID = os.environ["COGNITO_CLIENT_ID"]
COGNITO_REGION = os.environ["COGNITO_REGION"]
CLOUDFRONT_DOMAIN = os.environ["CLOUDFRONT_DOMAIN"]
CLOUDFRONT_KEY_PAIR_ID = os.environ["CLOUDFRONT_KEY_PAIR_ID"]
CLOUDFRONT_PRIVATE_KEY_ENV = os.environ["CLOUDFRONT_PRIVATE_KEY"]
# CookieのTTL
COOKIE_TTL_SECONDS = 120
# JWKSの変数 (Issuer: 発行者, JWKS_URL: 公開鍵のリストの場所)
# https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html
ISSUER = f"https://cognito-idp.{COGNITO_REGION}.amazonaws.com/{COGNITO_USER_POOL_ID}"
JWKS_URL = f"{ISSUER}/.well-known/jwks.json"
# 処理中は同じJWKSのClientを使いまわすためのグローバル変数
_JWKS_CLIENT: Optional[jwt.PyJWKClient] = None
def _load_private_key_pem() -> bytes:
"""秘密鍵の読み込み"""
v = CLOUDFRONT_PRIVATE_KEY_ENV.strip()
return base64.b64decode(v)
def _cloudfront_urlsafe_b64(data: bytes) -> str:
"""CloudFront向けのBase64エンコーディングに変換"""
# https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/signed-cookies-PHP.html
s = base64.b64encode(data).decode("utf-8")
return s.replace("+", "-").replace("=", "_").replace("/", "~")
def _jwks_client() -> jwt.PyJWKClient:
"""JWKS Clientの作成"""
global _JWKS_CLIENT
if _JWKS_CLIENT is None:
_JWKS_CLIENT = jwt.PyJWKClient(JWKS_URL)
return _JWKS_CLIENT
def _verify_cognito_jwt(token: str) -> Dict[str, Any]:
"""CognitoのJWT検証"""
key = _jwks_client().get_signing_key_from_jwt(token).key
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
issuer=ISSUER,
options={"verify_aud": False},
)
token_use = claims.get("token_use")
if token_use == "id":
if claims.get("aud") != COGNITO_CLIENT_ID:
raise ValueError("Invalid aud")
elif token_use == "access":
if claims.get("client_id") != COGNITO_CLIENT_ID:
raise ValueError("Invalid client_id")
else:
raise ValueError("Unexpected token_use")
return claims
def _build_policy(resource: str, expires_epoch: int) -> str:
"""Signed Cookieのポリシーの作成 (有効期限)"""
return json.dumps(
{
"Statement": [
{
"Resource": resource,
"Condition": {"DateLessThan": {"AWS:EpochTime": expires_epoch}},
}
]
},
separators=(",", ":"),
)
def _sign(policy_str: str) -> bytes:
"""ポリシーの電子署名"""
private_key = serialization.load_pem_private_key(_load_private_key_pem(), password=None)
return private_key.sign(
policy_str.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA1(),
)
def _response(status: int, body: Dict[str, Any], set_cookies: Optional[list] = None) -> Dict[str, Any]:
resp: Dict[str, Any] = {
"statusCode": status,
"headers": {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
"body": json.dumps(body),
}
if set_cookies:
resp["multiValueHeaders"] = {"Set-Cookie": set_cookies}
return resp
def lambda_handler(event, context):
"""CognitoのJWTを受け取り、CloudFrontのSigned Cookiesを返す"""
# ヘッダー確認
headers = event.get("headers") or {}
auth = headers.get("Authorization") or headers.get("authorization")
if not auth or not auth.lower().startswith("bearer "):
return _response(401, {"message": "Missing Authorization: Bearer <JWT>"})
# JWT検証
token = auth.split(" ", 1)[1].strip()
try:
claims = _verify_cognito_jwt(token)
except Exception as e:
logger.info("JWT verify failed: %s", e)
return _response(401, {"message": "Invalid token"})
# ポリシー作成
exp = int(time.time()) + COOKIE_TTL_SECONDS
policy = _build_policy(f"https://{CLOUDFRONT_DOMAIN}/*", exp)
sig = _sign(policy)
# Signed Cookies作成
attrs = "Path=/; Secure; HttpOnly; SameSite=Lax"
set_cookies = [
f"CloudFront-Policy={_cloudfront_urlsafe_b64(policy.encode())}; {attrs}",
f"CloudFront-Signature={_cloudfront_urlsafe_b64(sig)}; {attrs}",
f"CloudFront-Key-Pair-Id={CLOUDFRONT_KEY_PAIR_ID}; {attrs}",
]
logger.info("Issued signed cookies. token_use=%s sub=%s exp=%s",
claims.get("token_use"), claims.get("sub"), exp)
return _response(200, {"ok": True, "expires_epoch": exp}, set_cookies=set_cookies)
import json
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
# APIにAuthorizationがある時に、200を返すだけの関数 (認証ない場合はAPIGW側で401を返す)
headers = event.get("headers") or {}
auth = headers.get("Authorization")
logger.info("has_authorization_header=%s", bool(auth))
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"ok": True}),
}
なお、1つ目の関数はLayerを使用する必要があります。
以下のrequirements.txtとコマンドで作成したものをLambdaにアップロードして使用しました。
PyJWT==2.10.1
cryptography==45.0.4
requests==2.32.3
mkdir -p layer/python
pip install -r requirements.txt -t layer/python --upgrade --no-cache-dir
cd layer
zip -r layer.zip python
4. REST API作成 (API Gateway)
CloudFrontで配信するコンテンツとLambda関数を繋ぐAPIを作成します。
4-1. リソース作成
今回は以下の構成でAPIのリソースを作成。
/
├── auth
│ └── set-cookie (POST) # ログイン検証用のLambda関数(AuthSetCookie)に接続
└── api
└── ping (GET) # ping用のLambda関数(APIping)に接続
次に、Cognito User PoolのAuthorizerを作成。
API Gatewayの左メニューにある「オーソライザー」を選び、以下の設定で作成。
| 項目 | 設定値 |
|---|---|
| Type | Cognito |
| User pool | 作成したUser Pool |
| Token source | Authorization |
そして、/api/pingのGETメソッドについて、メソッドリクエストのAuthorizerを作成したものに変更。最後にAPIをDeployして反映。
4-2. APIの認証確認
pingのGETメソッドに認証機能が反映されているか確認します。
まず、認証なしの場合を確認します。
# Authorization なし → 401になる
curl -i {APIのURL}/api/ping
# 出力
# HTTP/2 401
# date: ...
# content-type: application/json
# ...
# x-amzn-errortype: UnauthorizedException
# ...
# {"message":"Unauthorized"}
Authorizationヘッダーがないため、401 (Unauthorized)が返されました。
次にCognitoから認証をもらった上で実行します。
まずはAWS CLIで以下のコマンドを実行し、出力にあるIdTokenを控えます。
aws cognito-idp initiate-auth \
--region {リージョン} \
--auth-flow USER_PASSWORD_AUTH \
--client-id {Cognitoで作成したアプリケーションクライアントのID} \
--auth-parameters USERNAME={メールアドレス},PASSWORD={設定したパスワード}
控えたTokenを使って、今度は認証ありの状態でGETメソッドを実行します。
# Authorization あり → 200でOK
curl -i \
-H "Authorization: Bearer {IdToken}" \
{APIのURL}/api/ping
# 出力
# HTTP/2 200
# date: ...
# content-type: application/json
# ...
# {"ok": true}
無事にAPIが200を返し、API GatewayのCognito User Pool AuthorizerがTokenを検証して通し、Lambdaの実行まで到達したことを確認できました。
(CloudWatch Logsを見ても呼ばれていることが確認できます)
5. 公開鍵とKey group作成 (CloudFront Signed Cookies用)
CloudFrontの署名付きCookiesによるアクセス制限を行うため、鍵を作ります。
5-1. 鍵ペアをローカルで作成(例)
CloudFrontに登録する公開鍵とLambdaが署名に使う秘密鍵を生成します。
openssl genrsa -out private_key.pem 2048
openssl rsa -pubout -in private_key.pem -out public_key.pem
5-2. CloudFrontにPublic keyを登録
CloudFrontコンソールの「キー管理」の「パブリックキー」から「パブリックキーの作成」を選択し、5-1で作成した公開鍵を貼り付けて保存します。
5-3. Key group 作成
次に「キー管理」の「キーグループ」から「キーグループの作成」を選択し、5-2で作成した鍵を追加して作成します。
6. CloudFront Distribution作成
コンテンツ配信のため、CloudFront Distributionを作成します。
- プランは無料プランを選択
- オリジンをS3にして、1で作成した3バケットを設定 (パスはそのまま)
- その他の設定もデフォルトで作成 (セキュリティとか)
次にAPIのオリジンを追加。
オリジンのドメインとして、4で作成したAPI Gatewayを選択し、プロトコルは「HTTPSのみ」、オリジンパスは設定したステージ(/prod)を入力して作成。
後はビヘイビアの設定です。Default (*)の設定は以下の通り。
アクセスを制限して、5で作成した鍵を認可タイプにしています。
| 項目 | 設定 |
|---|---|
| オリジン | 1で作成したS3バケット |
| ビューワープロトコルポリシー | Redirect HTTP to HTTPS |
| 許可された HTTP メソッド | GET, HEAD |
| ビューワーのアクセスを制限する | Yes |
| 信頼された認可タイプ | Trusted key groups (5-3で作成したもの) |
/public/*はビューワーのアクセスを制限しない形で配信。
/auth/*と/api/*のAPIについては、以下のように設定。
| 項目 | 設定 |
|---|---|
| オリジン | 4で作成したAPI |
| ビューワープロトコルポリシー | Redirect HTTP to HTTPS |
| 許可された HTTP メソッド | GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE |
| ビューワーのアクセスを制限する | No |
| キャッシュポリシー | CachingDisabled (キャッシュ無効) |
| オリジンリクエストポリシー | AllViewerExceptHostHeader |
APIはCognito側で認証するので、ビューワーのアクセスを制限をしていません。 (APIを署名付きCookieで保護するのは一般的ではないらしい...?)
また、キャッシュは無効化し(必ずオリジンに行かせ)、AuthorizationヘッダーがAPIに転送されるようにしています。
オリジンリクエストポリシーに AllViewer を選ぶと、Hostヘッダーまでオリジンに転送されることがあります。
すると、API GatewayへのリクエストがCloudFrontに対するHost (dxxxxx.cloudfront.net)のままになってしまい、403(Forbidden)になることがあるため、AllViewerExceptHostHeaderが推奨のようです。
最後に、デフォルトルートオブジェクトをindex.htmlに修正して、設定は完了です。
7. CognitoのCallback URLをCloudFrontに更新
6で作成したディストリビューションをCognito側に設定します。
2で作成したユーザープールのアプリケーションクライアントから「ログインページ」を選択し、Callback URLをhttps://{ディストリビューションドメイン名}/public/callback.htmlに、Sign out URLをhttps://{ディストリビューションドメイン名}/public/login.htmlに変更します。
8. S3のコンテンツ修正
7までに作成したリソースの内容を元にS3のファイルの中身を修正。
1で作成したlogin.htmlとcallback.htmlについて、COGNITO_DOMAINとCLIENT_IDを追記すれば完了です。
(CognitoのアプリケーションクライアントのQuick Setupガイドに記載があります)
9. 動作確認
細かい確認は別で調べることにして、想定している挙動をするかだけを確認します。
まず、CloudFrontのデフォルトの挙動として、ブラウザでhttps://dxxxxx.cloudfront.net/にアクセスすると、MissingKeyとしてアクセスできないことが確認できます。
次にhttps://dxxxxx.cloudfront.net/public/login.htmlにアクセスすると、作成したログインページに行き、ログインボタンを押せばCognitoのログインページに飛びます。
ログインするとPrivateのページに飛び、/api/pingのAPIも実行できました。
(認証なしだと弾かれるのは4で確認済み)
まとめ
CloudFrontでのコンテンツ配信(静的サイトやAPI)に関して、Cognitoを使った認証基盤を追加しました。
当初の目的である「アクセスやAPIの実行を制限すること」は達成できたと思います。
一方でJWTや署名付きCookiesがどのように効いているのかは、まだ理解できていないので、次は中身の確認を進めようと思います。
