DnsValidatedCertificateがdeprecatedになったのでCertificateに置き換えたらクロスリージョン対応が必要になったメモ

## 概要

[Route53とCloudFrontの紐づけとSSL証明書の発行をCDKを使って行ったメモ](https://qiita.com/hibohiboo/items/ddae8b4740952e9ac373) や [AWS CDK v2を使ってSPA用のCloudFrontをデプロイしたメモ](https://qiita.com/hibohiboo/items/68bcb56350c74106aaa4) では、Route53とCloudFrontの紐づけのための証明書取得に`DnsValidatedCertificate`クラスを使っていたが、deprecatedになってしまった。
そのまま`Certificate`を書いたら、regionの指定が無くて困ってしまった。(CloudFrontの証明書は`us-east-1`に必要)
クロスリージョンは下記で行えそうだったので、試してみた。

https://twitter.com/pahudnet/status/1587522689740046336


## 環境
- cdk: 2.79.1

## ソースコード

[github - cdk](https://github.com/hibohiboo/RinneCircle/tree/999b57cad886f0496c8a1a95003fc99e9c812da8/cdk)

<details><summary>.envは変更なし</summary><div>

```bash:.env
PROJECT_ID=hoge
ROOT_DOMAIN=hoge.huga.co.jp
DEPLOY_DOMAIN=www.aaa.hoge.huga.co.jp
TAG_PROJECT_NAME=sample
BUCKET_NAME=aws-sample-cloundfront-s3-bucket
SUB_DIR_PATH_BUILDER=sample-builder
```

</div></details>

```diff:bin/cdk.ts
#!/usr/bin/env node
/* eslint-disable turbo/no-undeclared-env-vars */
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import * as dotenv from "dotenv";
import { RinnneCircleFrontCdkStack } from "../lib/RinnneCircleFrontCdkStack";
import { HostedZone } from "aws-cdk-lib/aws-route53";
import {
  Certificate,
  CertificateValidation,
} from "aws-cdk-lib/aws-certificatemanager";

dotenv.config();
const envList = [
  "PROJECT_ID",
  "ROOT_DOMAIN",
  "DEPLOY_DOMAIN",
  "TAG_PROJECT_NAME",
  "BUCKET_NAME",
  "SUB_DIR_PATH_BUILDER",
] as const;
for (const key of envList) {
  if (!process.env[key]) throw new Error(`please add ${key} to .env`);
}
const processEnv = process.env as Record<(typeof envList)[number], string>;

const app = new cdk.App();

const env = {
  account: process.env.CDK_DEFAULT_ACCOUNT,
  region: process.env.CDK_DEFAULT_REGION,
};
const domainName = processEnv.ROOT_DOMAIN;

+ // 証明書はus-east-1リージョンで作成する必要がある
+ const usStack = new cdk.Stack(app, `${processEnv.PROJECT_ID}-FrontCdkUsStack`, {
+   env: { ...env, region: "us-east-1" },
+   crossRegionReferences: true,
+ });

+ const zone = HostedZone.fromLookup(usStack, `${domainName}-hosted-zone`, {
+   domainName: processEnv.ROOT_DOMAIN,
+ });
+ const cert = new Certificate(usStack, `${domainName}-certificate`, {
+   domainName: processEnv.DEPLOY_DOMAIN,
+   validation: CertificateValidation.fromDns(zone),
+ });

new RinnneCircleFrontCdkStack(app, `${processEnv.PROJECT_ID}-FrontCdkStack`, {
  bucketName: processEnv.BUCKET_NAME,
  identityName: `${processEnv.PROJECT_ID}-origin-access-identity-to-s3-bucket`,
  defaultCachePolicyName: `${processEnv.PROJECT_ID}-cache-policy-default`,
  functionName: `${processEnv.PROJECT_ID}-lambda-edge-ogp`,
  distributionName: `${processEnv.PROJECT_ID}-distribution-cloudfront`,
  rootDomain: processEnv.ROOT_DOMAIN,
  deployDomain: processEnv.DEPLOY_DOMAIN,
  projectNameTag: processEnv.TAG_PROJECT_NAME,
  subDirectoryPath: {
    builder: processEnv.SUB_DIR_PATH_BUILDER,
  },
  env,
+   crossRegionReferences: true,
+   zone,
+   cert,
});
```

<details><summary>lib/RinnneCircleFrontCdkStack.tsはDnsValidatedCertificateを削除して、zoneとcertを受け取るようにした以外変更なし</summary>
<div>

```diff:lib/RinnneCircleFrontCdkStack.ts
import * as cdk from "aws-cdk-lib";
import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
import {
  CachePolicy,
  FunctionCode,
  OriginAccessIdentity,
  Function,
  AllowedMethods,
  ViewerProtocolPolicy,
  CacheHeaderBehavior,
  Distribution,
  PriceClass,
  FunctionEventType,
  ResponseHeadersPolicy,
  HeadersFrameOption,
  HeadersReferrerPolicy,
  IDistribution,
} from "aws-cdk-lib/aws-cloudfront";
import { S3Origin } from "aws-cdk-lib/aws-cloudfront-origins";
import {
  CanonicalUserPrincipal,
  Effect,
  PolicyStatement,
} from "aws-cdk-lib/aws-iam";
import {
  ARecord,
  AaaaRecord,
  IHostedZone,
  RecordTarget,
} from "aws-cdk-lib/aws-route53";
import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets";
import { BlockPublicAccess, Bucket, IBucket } from "aws-cdk-lib/aws-s3";
import { BucketDeployment, Source } from "aws-cdk-lib/aws-s3-deployment";
import { Construct } from "constructs";

type SubDirectoryPath = {
  builder: string;
};
interface Props extends cdk.StackProps {
  bucketName: string;
  identityName: string;
  defaultCachePolicyName: string;
  functionName: string;
  distributionName: string;
  rootDomain: string;
  deployDomain: string;
  projectNameTag: string;
  subDirectoryPath: SubDirectoryPath;
+  zone: IHostedZone;
+  cert: Certificate;
}
export class RinnneCircleFrontCdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: Props) {
    super(scope, id, props);
    // CloudFront オリジン用のS3バケットを作成
    const bucket = this.createS3(props.bucketName);

    // CloudFront で設定する オリジンアクセスアイデンティティ を作成
    const identity = this.createIdentity(bucket, props.identityName);

    // S3バケットポリシーで、CloudFrontのオリジンアクセスアイデンティティを許可
    this.createPolicy(bucket, identity);
-    const zone = this.findRoute53HostedZone(props.rootDomain)
-    const cert = this.createTLSCertificate(props.deployDomain, zone)
    // CloudFrontディストリビューションを作成
    const distribution = this.createCloudFront(
      bucket,
      identity,
      props.cert,
      props,
    );
    // // 指定したディレクトリをデプロイ
    this.deployS3(
      bucket,
      distribution,
      "../apps/front/dist",
      props.subDirectoryPath.builder,
      props.bucketName,
    );

    // route53 の CloudFrontに紐づくレコード作成
    this.addRoute53Records(props.zone, props.deployDomain, distribution);

    // 確認用にCloudFrontのURLに整形して出力
    new cdk.CfnOutput(this, `${props.distributionName}-top-url`, {
      value: `https://${distribution.distributionDomainName}/${props.subDirectoryPath.builder}`,
    });

    cdk.Tags.of(this).add("Project", props.projectNameTag);
  }

-  private findRoute53HostedZone(rootDomain: string) {
-    return route53.HostedZone.fromLookup(this, `${rootDomain}-hosted-zone`, {
-      domainName: rootDomain,
-    })
-  }
-  private createTLSCertificate(
-    deployDomain: string,
-    hostedZone: route53.IHostedZone,
-  ) {
-    return new certManager.DnsValidatedCertificate(
-      this,
-      `${deployDomain}-certificate`,
-      {
-        domainName: deployDomain,
-        hostedZone, // DNS 認証に Route 53 のホストゾーンを使う
-        region: 'us-east-1', // 必ず us-east-1 リージョン
-        validation: certManager.CertificateValidation.fromDns(),
-      },
-    )
-  }

  private createS3(bucketName: string) {
    const bucket = new Bucket(this, bucketName, {
      bucketName,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
      // デフォルト = accessControl: BucketAccessControl.PRIVATE,
    });
    return bucket;
  }

  private createIdentity(bucket: Bucket, identityName: string) {
    const identity = new OriginAccessIdentity(this, identityName, {
      comment: `${bucket.bucketName} access identity`,
    });
    return identity;
  }
  private createPolicy(bucket: Bucket, identity: OriginAccessIdentity) {
    const myBucketPolicy = new PolicyStatement({
      effect: Effect.ALLOW,
      actions: ["s3:GetObject", "s3:ListBucket"],
      principals: [
        new CanonicalUserPrincipal(
          identity.cloudFrontOriginAccessIdentityS3CanonicalUserId,
        ),
      ],
      resources: [bucket.bucketArn + "/*", bucket.bucketArn],
    });
    bucket.addToResourcePolicy(myBucketPolicy);
  }
  private createCloudFront(
    bucket: Bucket,
    identity: OriginAccessIdentity,
    cert: Certificate,
    props: {
      defaultCachePolicyName: string;
      distributionName: string;
      deployDomain: string;
      subDirectoryPath: SubDirectoryPath;
    },
  ) {
    const { defaultCachePolicyName, distributionName, deployDomain } = props;
    const defaultPolicyOption = {
      cachePolicyName: defaultCachePolicyName,
      comment: "輪廻サークルポリシー",
      enableAcceptEncodingGzip: true,
      enableAcceptEncodingBrotli: true,
    };
    const myCachePolicy = new CachePolicy(
      this,
      defaultCachePolicyName,
      defaultPolicyOption,
    );

    const origin = new S3Origin(bucket, {
      originAccessIdentity: identity,
    });
    const spaRoutingFunction = new Function(this, "SpaRoutingFunction", {
      functionName: `rinne-circle-SpaRoutingFunction`,
      // 拡張子が含まれないURLはSPAファイルにリダイレクト
      code: FunctionCode.fromInline(`
      function handler(event) {
        var request = event.request;
        if(request.uri.includes('.')){
          return request;
        }
        if (request.uri.startsWith('/${props.subDirectoryPath.builder}')) {
          request.uri = '/${props.subDirectoryPath.builder}/index.html';
        } else {
          request.uri = '/rinne-circle/index.html';
        } 
        return request;
      }
      `),
    });
    cdk.Tags.of(spaRoutingFunction).add("Service", "Cloud Front Function");

    const responseHeadersPolicy = this.createResponseHeadersPolicy();
    const additionalBehaviors = {
      "data/*": {
        origin,
        allowedMethods: AllowedMethods.ALLOW_ALL,
        viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: new CachePolicy(
          this,
          `${distributionName}-data-cache-policy`,
          {
            cachePolicyName: `${distributionName}-data-cache-cache-policy`,
            comment: "CloudFront データ部用ポリシー",
            defaultTtl: cdk.Duration.seconds(0),
            maxTtl: cdk.Duration.seconds(10),
            headerBehavior: CacheHeaderBehavior.allowList("content-type"),
          },
        ),
      },
    };
    const d = new Distribution(this, distributionName, {
      comment: "RinneCircle",
      defaultRootObject: "/index.html",
      priceClass: PriceClass.PRICE_CLASS_200,
      defaultBehavior: {
        origin,
        cachePolicy: myCachePolicy,
        viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        responseHeadersPolicy,
        functionAssociations: [
          {
            eventType: FunctionEventType.VIEWER_REQUEST,
            function: spaRoutingFunction,
          },
        ],
      },
      additionalBehaviors,
      certificate: cert,
      domainNames: [deployDomain],
    });
    cdk.Tags.of(d).add("Service", "Cloud Front");

    return d;
  }

  private createResponseHeadersPolicy() {
    const responseHeadersPolicy = new ResponseHeadersPolicy(
      this,
      "ResponseHeadersPolicy",
      {
        securityHeadersBehavior: {
          contentTypeOptions: { override: true },
          frameOptions: {
            frameOption: HeadersFrameOption.DENY,
            override: true,
          },
          referrerPolicy: {
            referrerPolicy: HeadersReferrerPolicy.SAME_ORIGIN,
            override: true,
          },
          strictTransportSecurity: {
            accessControlMaxAge: cdk.Duration.seconds(63072000),
            includeSubdomains: true,
            preload: true,
            override: true,
          },
          xssProtection: {
            protection: true,
            modeBlock: true,
            override: true,
          },
        },
        customHeadersBehavior: {
          customHeaders: [
            {
              header: "Cache-Control",
              value: "no-cache",
              override: true,
            },
            {
              header: "pragma",
              value: "no-cache",
              override: true,
            },
            {
              header: "server",
              value: "",
              override: true,
            },
          ],
        },
      },
    );
    return responseHeadersPolicy;
  }

  private addRoute53Records(
    zone: IHostedZone,
    deployDomain: string,
    cf: Distribution,
  ) {
    const propsForRoute53Records = {
      zone,
      recordName: deployDomain,
      target: RecordTarget.fromAlias(new CloudFrontTarget(cf)),
    };
    new ARecord(this, "ARecord", propsForRoute53Records);
    new AaaaRecord(this, "AaaaRecord", propsForRoute53Records);
  }
  private deployS3(
    siteBucket: IBucket,
    distribution: IDistribution,
    sourcePath: string,
    bucketName: string,
    basepath: string,
  ) {
    new BucketDeployment(this, `${bucketName}-deploy-with-invalidation`, {
      sources: [Source.asset(sourcePath)],
      destinationBucket: siteBucket,
      distribution,
      distributionPaths: [`/${basepath}/*`],
      destinationKeyPrefix: basepath,
    });
  }
}
```

</div></details>

## 確認
デプロイページを確認できた。
https://d11wfkhmuej13d.cloudfront.net/rinne-circle-builder/

Route53も効いていることを確認。

https://rinne.eisei-teien.ml/rinne-circle-builder/

## 参考

現在はRemoteOutputsを使わなくてもよくなっている模様。
[AWS CDK（cdk-remote-stack）でACMとCloudFrontのクロスリージョン参照を実装する](https://dev.classmethod.jp/articles/cdk-remote-stack-for-acm-and-cloudfront/)
[RemoteOutputsのおかげで好きなリージョンCloudFrontにWebACLを紐付けれる話](https://tech.furyu.jp/entry/2023/02/14/120000)
[WS Solutions ConstructsでWAF＋CloudFront＋S3構成を作る](https://zenn.dev/watany/articles/856b6154203fe4)
https://speakerdeck.com/watany/aws-solutions-constructs-dejing-de-hosuteingugale-ninaru?slide=20