React アプリで Amazon Cognito 認証済みユーザーにのみ Amazon S3 静的コンテンツへのアクセスを許可したい -環境編-

こんにちは、広野です。

これまでいろいろと React アプリを AWS リソースと連携させるための仕組みをブログ記事にしてきましたが、絶対に使うであろうユースケースを書いていませんでした。以下、順を追って説明したいと思います。

本記事は環境編です。今後、UI 編を続編記事として用意しています。

前回の記事

前回記事で、背景やアーキテクチャを説明しています。こちらをご覧になった上で、本記事にお戻りください。

 

Lambda@Edge 関数の実装

以下の Lambda@Edge 関数について説明します。

Lambda@Edge 関数の制約

Lambda@Edge 関数を作成するときには、通常の Lambda 関数にはない制約があるので、注意が必要です。

 

詳細は AWS 公式ドキュメントに掲載されていますが、私が気を遣ったところを挙げておきます。総じて、大量のリクエストやレスポンスに対して呼び出されるので、迅速かつ安全に実行できることを優先してつくられている、と感じました。

  • 関数をバージニア北部リージョン (us-east-1) にデプロイしなければならない。これは CloudFront と同じなので妥当ですよね。
  • ただデプロイしただけでは CloudFront と関連付けられない。関数の「バージョン」を作成し、そのバージョンを CloudFront と関連付ける必要がある。
  • Lambda@Edge 関数に割り当てる IAM ロールは、通常の Lambda 関数で設定する lambda.amazonaws.com による引き受けだけでなく、edgelambda.amazonaws.com も含めた両方を設定する必要がある。権限は必要なものを付ければよいが、最低限 Amazon CloudWatch Logs にログを残す設定があれば OK。
  • 環境変数は使用できない。そのため、環境変数はベタにコード内に埋め込む必要あり。AWS CloudFormation でデプロイする場合は !Sub でパラメータを埋め込めばよい。
  • Lambda レイヤーは使用できない。レイヤーが必要な高度な処理はできないということになる。モジュールを使えば一発の処理でも、ベタに JavaScript で書かないといけない局面がある。
  • AWS X-Ray は使用できない。
  • Amazon CloudWatch Logs にログが残らないことがある。これはデバッグで困りました。マネジメントコンソールで関数をテスト実行するときはログが残ったので、そこでデバッグしました。

さらに、私が実施したケースでは以下の件がありました。Lambda@Edge に限った話ではありませんが。

  • Lambda@Edge 関数のコードサンプルは Node.js が多く Python は少ない。そのため、ランタイムは最新の Node.js 24 を採用した。
  • Node.js の Lambda 関数を AWS CloudFormation でデプロイすると、デプロイされるコードのファイル名は index.js 固定になる。しかし Node.js 24 Lambda 関数が期待している拡張子は .mjs であり、その場合、コードを ES Modules (ESM) に従って書かないといけない。拡張子の変更は CloudFormation ではできないため、コードを古い CommonJS に従って書くことになる。
  • 前述した Lambda 関数バージョンを AWS CloudFormation で発行することは可能。Lambda 関数そのものを変更するときに、同時に Lambda 関数バージョンの Description を更新してバージョンを更新する運用にした。
  • アーキテクチャ編にも掲載したが、CORS 対応のためレスポンスに Access-Control-Allow-Origin ヘッダーなどの CORS 関連ヘッダーを含める必要がある。環境変数が使用できないため、ベタにコードに書く必要あり。ただし、私の場合は CloudFront でオーバーライドする設計にしたため、Lambda@Edge 関数コード内に CORS 関連ヘッダーを書かなくて済んだ。

Lambda@Edge 関数コード

実際に動かしたコードはこちら。

上述の制約通り、Node.js 24 ですが CommonJS で書かれています。require(xxx)など。

正当なリクエストには、Authorization ヘッダーに “Bearer ” 付きの JWT が格納されている前提です。

'use strict';
const crypto = require('crypto');
const https = require('https');
// Cognito のアプリケーションクライアントID
const CLIENT_ID = "xxxxxxxxxxxxxxxxxxxxxxxxxx";
// Cognito のユーザープールID とリージョン名を埋め込む
const ISSUER = "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_xxxxxxxxx";
const JWKS_URL = `${ISSUER}/.well-known/jwks.json`;
let cachedKeys = null;
let cachedAt = 0;
function fetchJson(url) {
    return new Promise((resolve, reject) => {
        https.get(url, res => {
            let data = "";
            res.on("data", c => data += c);
            res.on("end", () => resolve(JSON.parse(data)));
        }).on("error", reject);
    });
}
async function getJwks() {
    if (cachedKeys && Date.now() - cachedAt < 3600000) return cachedKeys; cachedKeys = await fetchJson(JWKS_URL); cachedAt = Date.now(); return cachedKeys; } function b64u(str) { str += "=".repeat((4 - str.length % 4) % 4); return Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64"); } exports.handler = async (event) => {
    const req = event.Records[0].cf.request;
    // 1. CORS プリフライトチェックであればすぐにレスポンスを戻す。
    // status コードを入れることでユーザーに戻る。
    /* ========== OPTIONS preflight ========== */
    if (req.method === "OPTIONS") {
        return {
            status: "204",
            statusDescription: "No Content"
        };
    }
    // 2. 認証不要なリクエストであればそのまま S3 に流す。
    // ここではパスが /public/ から始まるリクエストであれば認証なしで通すようにしている。
    // return req と書くことで S3 に流れる。
    /* ========== Public images bypass ========== */
    if (req.uri.startsWith("/public/")) {
        return req;
    }
    /* ========== Authorization required ========== */
    // 3. ここからはトークンのチェックをしている。正当なトークンでなければ fail のレスポンスを返す。
    const fail = { status: "403", statusDescription: "Forbidden" };
    const auth = req.headers.authorization?.[0]?.value;
    if (!auth) return fail;
    try {
        const token = auth.replace("Bearer ", "");
        const parts = token.split(".");
        if (parts.length !== 3) return fail;
        const [h, p, s] = parts;
        const header = JSON.parse(b64u(h).toString());
        const payload = JSON.parse(b64u(p).toString());
        if (payload.iss !== ISSUER || payload.aud !== CLIENT_ID || payload.exp < Date.now() / 1000) { return fail; } const jwks = await getJwks(); const key = jwks.keys.find(k => k.kid === header.kid);
        if (!key) return fail;
        const pub = crypto.createPublicKey({ key, format: "jwk" });
        const ok = crypto.verify("RSA-SHA256", Buffer.from(`${h}.${p}`), pub, b64u(s));
        return ok ? req : fail;
    } catch (e) {
        return fail;
    }
};

 

Amazon CloudFront 側の実装

セキュリティ設計上、以下の構成であることが前提です。説明は省略します。

  • オリジンは Amazon S3 バケットで、OAC が設定されている。(関連付けられた CloudFront でなければ S3 バケットにアクセスできない)

 

ここまでで話した Lambda@Edge 関数ができていれば、Amazon CloudFront ディストリビューションと関連付けるのは簡単です。注意すべきは Lambda 関数のバージョンが発行できていないと関連付けできないことぐらいでしょうか。Amazon CloudFront なので IAM ロールを意識する必要はありません。

詳細な設定はこの後紹介する AWS CloudFormation テンプレートで紹介します。

 

AWS CloudFormation テンプレート

設定詳細は CloudFormation テンプレートで紹介します。適宜インラインで補足します。

Lambda@Edge 関数 (バージニア北部リージョン限定!!)

AWSTemplateFormatVersion: 2010-09-09
Description: The CloudFormation template that creates a Lambda@Edge function to authorize users access S3 contents. The resources must be deployed in us-east-1 region.

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  SubName:
    Type: String
    Description: System sub name. (e.g. prod or test)
    Default: test
    MaxLength: 10
    MinLength: 1

  // 重要。このパラメータを Lambda@Edge 関数変更の都度、書き換える必要がある。これにより関数のバージョンが更新される。
  // さらに、新しいバージョンで Amazon CloudFront ディストリビューションも更新する必要あり。忘れないように。
  Updated:
    Type: String
    Description: The updated date and time of the lambda function. You must update this value when you update the lambda function, and update the relevant CloudFront stack.
    Default: 2026-01-10T0900
    MaxLength: 20
    MinLength: 10

  CognitoUserPoolId:
    Type: String
    Description: Cognito user pool ID.
    Default: ap-northeast-1_xxxxxxxxx
    MaxLength: 50
    MinLength: 1

  CognitoUserPoolRegion:
    Type: String
    Description: The region name where Cognito user pool is deployed.
    Default: ap-northeast-1
    MaxLength: 30
    MinLength: 1

  CognitoUserPoolAppClientId:
    Type: String
    Description: Cognito user pool app client ID.
    Default: xxxxxxxxxxxxxxxxxxxxxxxxxx
    MaxLength: 50
    MinLength: 1

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "General Configuration"
        Parameters:
          - SubName
          - Updated
      - Label:
          default: "Cognito Configuration"
        Parameters:
          - CognitoUserPoolId
          - CognitoUserPoolAppClientId
          - CognitoUserPoolRegion

Resources:
# ------------------------------------------------------------#
# Lambda@Edge Role (IAM)
# ------------------------------------------------------------#
  LambdaAtEdgeRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub example-LambdaAtEdge-${SubName}
      Description: This role allows Lambda functions to push logs.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
                - edgelambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

# ------------------------------------------------------------#
# Lambda
# ------------------------------------------------------------#
  LambdaAtEdge:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub example-s3auth-${SubName}
      Description: Lambda@Edge function to check authorization to access S3 contents
      Runtime: nodejs24.x
      Timeout: 3
      MemorySize: 128
      Role: !GetAtt LambdaAtEdgeRole.Arn
      Handler: index.handler
      Tags:
        - Key: Cost
          Value: !Sub example-${SubName}
      Code:
        // !Sub で環境変数をパラメータから埋め込んでいます。
        ZipFile: !Sub |
          'use strict';
          const crypto = require('crypto');
          const https = require('https');
          const CLIENT_ID = "${CognitoUserPoolAppClientId}";
          const ISSUER = "https://cognito-idp.${CognitoUserPoolRegion}.amazonaws.com/${CognitoUserPoolId}";
          const JWKS_URL = `${!ISSUER}/.well-known/jwks.json`;
          let cachedKeys = null;
          let cachedAt = 0;
          function fetchJson(url) {
            return new Promise((resolve, reject) => {
              https.get(url, res => {
                let data = "";
                res.on("data", c => data += c);
                res.on("end", () => resolve(JSON.parse(data)));
              }).on("error", reject);
            });
          }
          async function getJwks() {
            if (cachedKeys && Date.now() - cachedAt < 3600000) return cachedKeys; cachedKeys = await fetchJson(JWKS_URL); cachedAt = Date.now(); return cachedKeys; } function b64u(str) { str += "=".repeat((4 - str.length % 4) % 4); return Buffer.from(str.replace(/-/g, "+").replace(/_/g, "/"), "base64"); } exports.handler = async (event) => {
            const req = event.Records[0].cf.request;
            /* ========== OPTIONS preflight ========== */
            if (req.method === "OPTIONS") {
              return {
                status: "204",
                statusDescription: "No Content"
              };
            }
            /* ========== Public images bypass ========== */
            if (req.uri.startsWith("/public/")) {
              return req;
            }
            /* ========== Authorization required ========== */
            const fail = { status: "403", statusDescription: "Forbidden" };
            const auth = req.headers.authorization?.[0]?.value;
            if (!auth) return fail;
            try {
              const token = auth.replace("Bearer ", "");
              const parts = token.split(".");
              if (parts.length !== 3) return fail;
              const [h, p, s] = parts;
              const header = JSON.parse(b64u(h).toString());
              const payload = JSON.parse(b64u(p).toString());
              if (payload.iss !== ISSUER || payload.aud !== CLIENT_ID || payload.exp < Date.now() / 1000) { return fail; } const jwks = await getJwks(); const key = jwks.keys.find(k => k.kid === header.kid);
              if (!key) return fail;
              const pub = crypto.createPublicKey({ key, format: "jwk" });
              const ok = crypto.verify("RSA-SHA256", Buffer.from(`${!h}.${!p}`), pub, b64u(s));
              return ok ? req : fail;
            } catch (e) {
              return fail;
            }
          };
    DependsOn:
      - LambdaAtEdgeRole

# You must update the version description when you update the lambda function.
  LambdaAtEdgeVersion:
    Type: AWS::Lambda::Version
    Properties:
      Description: !Sub "The updated date and time of the lambda function: ${Updated}"
      FunctionName: !Ref LambdaAtEdge
    DependsOn:
      - LambdaAtEdge

# ------------------------------------------------------------#
# Output Parameters
# ------------------------------------------------------------#
Outputs:
# Lambda
  LambdaAtEdgeVersionFunctionArn:
    Value: !GetAtt LambdaAtEdgeVersion.FunctionArn
  LambdaAtEdgeVersionVersion:
    Value: !GetAtt LambdaAtEdgeVersion.Version

Amazon CloudFront

Content Security Policy や レスポンスヘッダーはオリジンからのレスポンスにオーバーライド (上書き) する設定にしています。

AWSTemplateFormatVersion: 2010-09-09
Description: The CloudFormation template that creates a S3 bucket, a CloudFront distribution and DNS records in Route 53.

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  SubName:
    Type: String
    Description: System sub name. (e.g. prod or test)
    Default: test8
    MaxLength: 10
    MinLength: 1
    AllowedPattern: "^[a-z0-9]+$"

  DomainName:
    Type: String
    Description: Domain name for URL. xxxxx.xxx
    Default: xxxxx.xxx
    AllowedPattern: "^(?!-)(?:[a-zA-Z0-9-]{0,62}[a-zA-Z0-9])(?:\\.(?!-)(?:[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]))*$"

  SubDomainName:
    Type: String
    Description: Sub domain name for URL.
    Default: xxxxx
    MaxLength: 20
    MinLength: 1

  CertificateArn:
    Type: String
    Description: ACM certificate ARN. AWS Amplify supports the certificate only in us-east-1 region.
    Default: arn:aws:acm:us-east-1:999999999999:certificate/xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    MaxLength: 90
    MinLength: 80
    AllowedPattern: ^arn:aws:acm:us-east-1:\d{12}:certificate\/.+$

  LambdaAtEdgeVersionArn:
    Type: String
    Description: Lambda@Edge function version ARN in us-east-1 region.
    Default: arn:aws:lambda:us-east-1:999999999999:function:example-s3auth-xxxxx:1
    MaxLength: 200
    MinLength: 40
    AllowedPattern: ^arn:aws:lambda:us-east-1:\d{12}:function:.+:.+$

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "General Configuration"
        Parameters:
          - SubName
      - Label:
          default: "Domain Configuration"
        Parameters:
          - DomainName
          - SubDomainName
      - Label:
          default: "Security Configuration"
        Parameters:
          - CertificateArn
          - LambdaAtEdgeVersionArn

Resources:
### 中略 - CloudFront 以外を省略します。 ###

# ------------------------------------------------------------#
# CloudFront
# ------------------------------------------------------------#
  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        Comment: !Sub CloudFront distribution for example-${SubName}-img
        Aliases:
          - !Sub ${SubDomainName}-img.${DomainName}
        HttpVersion: http2
        IPV6Enabled: true
        PriceClass: PriceClass_200
        DefaultCacheBehavior:
          TargetOriginId: !Sub S3Origin-${SubDomainName}-img
          ViewerProtocolPolicy: redirect-to-https
          AllowedMethods:
            - GET
            - HEAD
            - OPTIONS
          CachedMethods:
            - GET
            - HEAD
          CachePolicyId: !Ref CloudFrontCachePolicy
          OriginRequestPolicyId: !Ref CloudFrontOriginRequestPolicy
          ResponseHeadersPolicyId: !Ref CloudFrontResponseHeadersPolicy
          # ここに、Lambda@Edge 関数の関連付け設定がある
          LambdaFunctionAssociations:
            - EventType: viewer-request
              LambdaFunctionARN: !Ref LambdaAtEdgeVersionArn
              IncludeBody: false
          Compress: true
          SmoothStreaming: false
        Origins:
          - Id: !Sub S3Origin-${SubDomainName}-img
            DomainName: !Sub ${S3Bucket}.s3.${AWS::Region}.amazonaws.com
            S3OriginConfig:
              OriginAccessIdentity: ""
            OriginAccessControlId: !GetAtt CloudFrontOriginAccessControl.Id
            ConnectionAttempts: 3
            ConnectionTimeout: 10
        ViewerCertificate:
          AcmCertificateArn: !Ref CertificateArn
          MinimumProtocolVersion: TLSv1.2_2025
          SslSupportMethod: sni-only
      Tags:
        - Key: Cost
          Value: !Sub example-${SubName}
    DependsOn:
      - CloudFrontCachePolicy
      - CloudFrontOriginRequestPolicy
      - CloudFrontOriginAccessControl
      - CloudFrontResponseHeadersPolicy
  CloudFrontOriginAccessControl:
    Type: AWS::CloudFront::OriginAccessControl
    Properties:
      OriginAccessControlConfig:
        Description: !Sub CloudFront OAC for example-${SubName}
        Name: !Sub OriginAccessControl-${SubDomainName}-img
        OriginAccessControlOriginType: s3
        SigningBehavior: always
        SigningProtocol: sigv4
  CloudFrontCachePolicy:
    Type: AWS::CloudFront::CachePolicy
    Properties:
      CachePolicyConfig:
        Name: !Sub CachePolicy-${SubDomainName}-img
        Comment: !Sub CloudFront Cache Policy for example-${SubName}-img
        DefaultTTL: 3600
        MaxTTL: 86400
        MinTTL: 60
        ParametersInCacheKeyAndForwardedToOrigin:
          CookiesConfig:
            CookieBehavior: none
          EnableAcceptEncodingBrotli: true
          EnableAcceptEncodingGzip: true
          HeadersConfig:
            HeaderBehavior: whitelist
            Headers:
              - Authorization
              - Access-Control-Request-Headers
              - Access-Control-Request-Method
              - Origin
          QueryStringsConfig:
            QueryStringBehavior: none
  CloudFrontOriginRequestPolicy:
    Type: AWS::CloudFront::OriginRequestPolicy
    Properties:
      OriginRequestPolicyConfig:
        Name: !Sub OriginRequestPolicy-${SubDomainName}-img
        Comment: !Sub CloudFront Origin Request Policy for example-${SubName}-img
        CookiesConfig:
          CookieBehavior: none
        HeadersConfig:
          HeaderBehavior: whitelist
          Headers:
            - Access-Control-Request-Headers
            - Access-Control-Request-Method
            - Origin
            - referer
            - user-agent
        QueryStringsConfig:
          QueryStringBehavior: none
  CloudFrontResponseHeadersPolicy:
    Type: AWS::CloudFront::ResponseHeadersPolicy
    Properties:
      ResponseHeadersPolicyConfig:
        Name: !Sub ResponseHeadersPolicy-${SubDomainName}-img
        Comment: !Sub CloudFront Response Headers Policy for example-${SubName}-img
        CorsConfig:
          AccessControlAllowCredentials: false
          AccessControlAllowHeaders:
            Items:
              - "Authorization"
              - "Content-Type"
          AccessControlAllowMethods:
            Items:
              - GET
              - HEAD
              - OPTIONS
          AccessControlAllowOrigins:
            Items:
              - !Sub https://${SubDomainName}.${DomainName}
          AccessControlExposeHeaders:
            Items:
              - "*"
          AccessControlMaxAgeSec: 600
          OriginOverride: true
        SecurityHeadersConfig:
          ContentSecurityPolicy:
            # 注意。ここで CSP に blob: を入れておかないと後々エラーになります。UI編で説明します。
            ContentSecurityPolicy: !Sub "default-src 'self' *.${DomainName} blob:; object-src 'self' blob:;"
            Override: true
          ContentTypeOptions:
            Override: true
          FrameOptions:
            FrameOption: DENY
            Override: true
          ReferrerPolicy:
            Override: true
            ReferrerPolicy: strict-origin-when-cross-origin
          StrictTransportSecurity:
            AccessControlMaxAgeSec: 31536000
            IncludeSubdomains: true
            Override: true
            Preload: true
          XSSProtection:
            ModeBlock: true
            Override: true
            Protection: true
        CustomHeadersConfig:
          Items:
            - Header: Cache-Control
              Value: no-store
              Override: true

 

続編記事

UI 編では、React アプリからどのようにリクエストを書けばよいか解説します。

 

まとめ

いかがでしたでしょうか?

実運用を考えると、CI/CD 環境までつくれると良いですね。でも一度作ればあまり変更が入らない環境なので、そこまでやるか?とも思いますね。

本記事が皆様のお役に立てれば幸いです。

著者について
広野 祐司

AWS サーバーレスアーキテクチャと React を使用して社内向け e-Learning アプリ開発とコンテンツ作成に勤しんでいます。React でアプリを書き始めたら、快適すぎて他の言語には戻れなくなりました。近年は社内外への AWS 技術支援にも従事しています。AWS サービスには AWS が考える IT 設計思想が詰め込まれているので、いつも AWS を通して勉強させて頂いてまます。
取得資格:AWS 認定は15資格、IT サービスマネージャ、ITIL v3 Expert 等
2020 - 2025 Japan AWS Top Engineer 受賞
2022 - 2025 AWS Ambassador 受賞
2023 当社初代フルスタックエンジニア認定
好きなAWSサービス:AWS AppSync Events / AWS Step Functions / AWS CloudFormation

広野 祐司をフォローする

クラウドに強いによるエンジニアブログです。

SCSKクラウドサービス(AWS)は、企業価値の向上につながるAWS 導入を全面支援するオールインワンサービスです。AWS最上位パートナーとして、多種多様な業界のシステム構築実績を持つSCSKが、お客様のDX推進を強力にサポートします。

AWSアプリケーション開発クラウドソリューション
シェアする
タイトルとURLをコピーしました