これまでに以下2本の記事を書きました。
1本目ではAPI Gateway + Lmabdaで簡単な処理をする静的サイトをCloudFrontで公開する構成をCDKで実装。ただし、URLを知っている人は誰でもAPIを叩ける状態だったのが改善点でした。
2本目は1本目の改善として、認証機能 (Cognito, Signed Cookies)をAWSコンソールから手作業で構築しました。
本記事では、2本目で構築したような内容を、cdk deploy1回で完結するように実装することに取り組みました。
取り組む過程で知ったCDK特有の対応や詰まりポイントに関して簡単にまとめます。
全体アーキテクチャ
構成そのものは1本目の記事とほとんど同じです。
root/
├── app.py
├── private_site/
│ └── private_site_stack.py # AWSリソース
├── assets/ # 配信コンテンツ
│ ├── index.html
│ └── public
│ ├── callback.html
│ └── login.html
├── lambda/ # Lambda関数の中身
│ ├── api_ping.html
│ │ └── app.py
│ └── auth_set_cookie
│ └── app.py
├── layer/ # Lambda関数のlayer
│ ├── python/
│ └── requirements.txt
├── keys/ # 秘密鍵/公開鍵
│ ├── private_key.pem
│ └── public_key.pem
├── ... (cdk.jsonやrequirements.txtなど)
CDK化の際のポイント
鍵ペア・依存パッケージをdeploy時のアセットとして扱う
以前の記事では、CloudFrontのSigned Cookies用の鍵ペア生成と、Lambda Layer用の依存パッケージのインストールは、コンソール操作の前に行っていました。今回もcdk deployの前にこの手順を行い、以下のように入力ファイルとしてそれぞれ渡します。
# 秘密鍵はSecrets Managerへ
private_key_pem = pathlib.Path("keys/private_key.pem").read_text(encoding="utf-8")
private_key_secret = secretsmanager.Secret(
self,
"CloudFrontPrivateKeySecret",
secret_string_value=SecretValue.unsafe_plain_text(private_key_pem),
)
# 公開鍵はCloudFrontへ
public_key_pem = pathlib.Path("keys/public_key.pem").read_text(encoding="utf-8")
cf_public_key = cloudfront.PublicKey(
self,
"CfPublicKey",
encoded_key=public_key_pem,
)
deps_layer = _lambda.LayerVersion(
self,
"DepsLayer",
code=_lambda.Code.from_asset("layer"),
compatible_runtimes=[_lambda.Runtime.PYTHON_3_14],
description="lambda layer for private site",
)
Code.from_asset("layer") は cdk deploy 実行時にそのディレクトリを自動でzip化してS3にアップロードしてくれるので、手作業でzipを作る工程は不要です。
Signed Cookies関連リソースの定義
コンソールの操作ではPublicKeyの登録、KeyGroupの作成、DistributionのTrusted Key Groups設定などを個別の画面で行っていました。CDKでは以下のように定義します。
key_group = cloudfront.KeyGroup(
self,
"CfKeyGroup",
items=[cf_public_key],
)
distribution = cloudfront.Distribution(
self,
"Distribution",
default_root_object="index.html",
default_behavior=cloudfront.BehaviorOptions(
origin=s3_origin,
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowed_methods=cloudfront.AllowedMethods.ALLOW_GET_HEAD,
trusted_key_groups=[key_group],
),
additional_behaviors={
"public/*": cloudfront.BehaviorOptions(
origin=s3_origin,
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowed_methods=cloudfront.AllowedMethods.ALLOW_GET_HEAD,
),
"auth/*": cloudfront.BehaviorOptions(
origin=api_origin,
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowed_methods=cloudfront.AllowedMethods.ALLOW_ALL,
cache_policy=cloudfront.CachePolicy.CACHING_DISABLED,
origin_request_policy=cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
),
"api/*": cloudfront.BehaviorOptions(
origin=api_origin,
viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
allowed_methods=cloudfront.AllowedMethods.ALLOW_ALL,
cache_policy=cloudfront.CachePolicy.CACHING_DISABLED,
origin_request_policy=cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
),
},
)
パス単位のアクセス制御が、default_behaviorとadditional_behaviorsで整理できるのは、コンソール操作よりも楽で助かります。キャッシュ無効化といった設定も、どのパスで有効にしているかが、CDKだとコードで一目瞭然でいいですね。
Cognito関連リソースの定義
Cognito側も、UserPool・Domain・App Clientの3つをコード上で定義します。
user_pool = cognito.UserPool(
self,
"UserPool",
self_sign_up_enabled=False,
sign_in_aliases=cognito.SignInAliases(email=True),
removal_policy=RemovalPolicy.DESTROY,
)
domain_prefix = self.node.try_get_context("cognitoDomainPrefix")
if not domain_prefix:
raise ValueError(
"context cognitoDomainPrefix が必要です。例: cdk deploy -c cognitoDomainPrefix=your-unique-prefix"
)
user_pool_domain = user_pool.add_domain(
"UserPoolDomain",
cognito_domain=cognito.CognitoDomainOptions(domain_prefix=domain_prefix),
)
user_pool_client = user_pool.add_client(
"UserPoolClient",
generate_secret=False,
o_auth=cognito.OAuthSettings(
flows=cognito.OAuthFlows(authorization_code_grant=True),
scopes=[cognito.OAuthScope.OPENID],
callback_urls=[...],
logout_urls=[...],
),
)
2本目の記事と同様に、サインアップは許可せず管理者が作る前提にしているので、self_sign_up_enabled=Falseで設定。
domain_prefix は一意である必要があるので、デプロイ時に-c cognitoDomainPrefix=...で指定する形にしています。
後半の方でCognitoの認証部分を定義しています。リダイレクト先のURLもここで設定しますが、CloudFrontのドメインはこの時点では未確定なので、URLは仮のものとして、Distribution作成後にAwsCustomResourceで更新する形にしています。(後述)
API Gateway + Cognito Authorizerの統合
/api/ping はログイン済みユーザーだけが呼べるAPIなので、CDKでもその設定を定義する必要があります。
一方で/auth/set-cookieはログイン直後・Cookie未発行の状態で呼ばれるので、API Gatewayの認可は付けず、Lambda内部でJWTを検証。
authorizer = apigw.CognitoUserPoolsAuthorizer(
self,
"CognitoAuthorizer",
cognito_user_pools=[user_pool],
)
ping_res = api.root.add_resource("api").add_resource("ping")
ping_res.add_method(
"GET",
apigw.LambdaIntegration(ping_fn),
authorization_type=apigw.AuthorizationType.COGNITO,
authorizer=authorizer,
)
デプロイ時に確定する値をS3の静的ファイルへ動的注入
配信コンテンツの内、login.html / callback.htmlはCognitoのドメインとClient IDを知る必要があります。cdk deployを実行した後に手動でJSファイルを書き換えるのは不便なので、今回の実装では次のように組み込んでいます。
cognito_domain_base = user_pool_domain.base_url()
config_js = f"""// Generated by CDK
window.__APP_CONFIG__ = {{
COGNITO_DOMAIN: "{cognito_domain_base}",
CLIENT_ID: "{user_pool_client.user_pool_client_id}",
}};
"""
s3deploy.BucketDeployment(
self,
"DeployWebsite",
destination_bucket=bucket,
distribution=distribution,
distribution_paths=["/*"],
sources=[
s3deploy.Source.asset("assets"),
s3deploy.Source.data("public/config.js", config_js),
],
)
静的ファイルはassets/からそのままデプロイしつつ、CDKが生成したCognitoに関する設定情報をconfig.jsとしてS3に格納。login.html / callback.htmlは格納されたconfig.jsを読み込んで、リダイレクト先のURLを組み立てる形にしています。
循環依存問題の解消
コンソールの操作では、「CloudFrontでDistributionを作る」 → 「決定したドメインをCognitoのコールバックURLとLambdaの環境変数に埋め込む」という手順にしていました。
ただし、CDKで実装する場合にCloudFrontのドメインをそのままCognitoやLambdaで使おうとすると、以下のエラーで弾かれました。
ValidationError: Circular dependency between resources: [...]
CloudFormationは「後から値を書き換える」ことができないので、まだ決定していないCloudFrontのドメインを他のリソースで参照しようとすると循環依存が発生してエラーになるようです。
コンソールでの構築なら、「後から値を書き換える」ことで参照の問題を回避できますが、CDKではこの部分を工夫する必要があるようです。
上で行った、config.jsもその工夫の1つでdeploy後に確定する部分は仮として、最終的に生成されたものを読み込む形にして回避したものです。
CognitoとLambdaについても、工夫を行います。
まず、Lambdaについては、Signed Cookieのポリシーを、特定ドメインではなくワイルドカードにしました。
# CloudFrontドメインをそのまま使うと循環依存になるため、ワイルドカードにして特定ドメインに依存しないポリシーにする
policy = _build_policy("https://*/*", exp)
これでLambdaが決定前のドメインを参照する必要がなくなります。
今回のようにDistributionが1つしかない構成ではOKですが、複数の場合は動的に組み立てるか、SSM Parameter Storeに書き込んだものをLambdaが読み込む形にすれば循環が回避できるようです。
次にCognitoのUserPoolClientについて、コールバックURLはワイルドカードで誤魔化せないので、まずは仮のURLで作成し、Distributionが確定した後にAwsCustomResourceを使って更新しました。
# 仮のURLで作成
user_pool_client = user_pool.add_client(
"UserPoolClient",
generate_secret=False,
o_auth=cognito.OAuthSettings(
flows=cognito.OAuthFlows(authorization_code_grant=True),
scopes=[cognito.OAuthScope.OPENID],
callback_urls=["https://placeholder.invalid/public/callback.html"],
logout_urls=["https://placeholder.invalid/public/login.html"],
),
)
```省略```
# URLの更新
update_user_pool_client_urls = cr.AwsCustomResource(
self,
"UpdateUserPoolClientUrls",
on_update=cr.AwsSdkCall(
service="CognitoIdentityServiceProvider",
action="updateUserPoolClient",
parameters={
"UserPoolId": user_pool.user_pool_id,
"ClientId": user_pool_client.user_pool_client_id,
"AllowedOAuthFlows": ["code"],
"AllowedOAuthScopes": ["openid"],
"AllowedOAuthFlowsUserPoolClient": True,
"SupportedIdentityProviders": ["COGNITO"],
"CallbackURLs": [callback_url],
"LogoutURLs": [logout_url],
},
physical_resource_id=cr.PhysicalResourceId.of("UpdateUserPoolClientUrls"),
),
policy=cr.AwsCustomResourcePolicy.from_sdk_calls(
resources=[user_pool.user_pool_arn],
),
)
update_user_pool_client_urls.node.add_dependency(distribution)
AwsCustomResourceがDistributionの値を読み取り、内部的にCognito API (UpdateUserPoolClient)を呼ぶLambdaを生成して、Distribution作成後にURLを書き換えています。Distribution作成後に実行することは、update_user_pool_client_urls.node.add_dependency(distribution)で明示的にしています。
これにより、人間がDistribution作成後に手動で行っていた更新作業をcdk deployの中で自動的に完結するようになりました。(もしかしたら分割してdeployしたり、他の方法で更新したりする方が良いのかもしれません)
まとめ
認証機能をCDKで実装を行い、その際の注意点を残しました。
- 鍵ペアや依存パッケージはアセットとして渡すだけ
- Signed Cookies関連リソースやAPIはCDKの方が楽 (パス単位の設定がパっと確認できる)
- Distributionのドメインのような「後からできるもの」は動的注入したり、後から更新するなどの工夫が必要 (循環依存に注意)
特に循環依存については、まだ理解できていない部分もありますが、CDKで実装する際には、リソース間の依存関係を解決できる形で設計し直すことにコツが要りそうです。
これまでの記事では、CDKを使って静的サイトを公開するために、どんな構成が要るのかという点について、お試し的に検討してきました。
今後はお試しではなく、具体的なものを作りたいと思うので、実際に作りながら循環依存の問題とその対応を把握していきたいと思います。