wandfuldays

Lambda@EdgeとCognitoでJamstackアプリの開発環境に認証を実装する

2025-02-022025-03-09

microcms製ブログの開発環境に認証を追加した方法を解説。Lambda@EdgeとCognitoを活用し、低コストでセキュリティと利便性を両立。

こんにちは、wandです!

以前の記事で紹介した通り、このブログシステムは AWS 上に CloudFront + S3 構成で作られています。

AWS + Next.js + microCMS で Jamstack ブログシステムを構築しました

今回は、その開発環境に Lambda@Edge と Cognito を使って認証を実装した方法を詳しくご紹介します。

このアプローチにより、コストを抑えつつ、高いセキュリティと利便性を両立させることができます。

具体的な設定コードは GitHub リポジトリに公開していますので、ぜひご覧ください。

GitHub - wand2016/blog: AWS infra + Next.js microCMS template

開発環境にアクセス制御が必要な理由

このブログシステムは、本番環境と開発環境の2環境を用意しています。

  • 本番環境: 読者が利用する公開環境
  • 開発環境: 以下の用途を兼ねる内部環境
    • インフラやフロントエンド修正の動作確認・テスト
    • 下書き記事のプレビューや公開前の最終チェック

開発環境が誰でもアクセスできる状態だと、以下のような問題が発生する可能性があります。

  • 未公開記事の流出
    下書き記事が不特定多数に閲覧されるリスクがあります。
  • API Key の漏洩
    本ブログは Next.js の SSG (静的サイト生成) でページを生成していますが、下書きプレビューは SPA(シングルページアプリケーション)で動作します。この際、クライアントサイドで microCMS の API を呼び出しているため、開発環境が一般公開されると API Key が漏洩する恐れがあります。

特に、 API Key が悪用されると、レートリミットに達し、記事の更新や公開ができなくなるリスクが発生します。このようなリスクを避けるため、開発環境へのアクセス制御は必須となります。

アクセス制御の方法

AWS WAF + IP 制限の課題

一般的な手法として AWS WAF(Web Application Firewall)を用いた IP 制限がありますが、次のような課題があります。

  • 柔軟性の欠如
    • 出先の Wi-Fi やモバイルネットワークからアクセスできない
    • 家庭用インターネット環境では IP アドレスが変動するため、都度設定の更新が必要
  • 高コスト
    • WAF の月額料金(約 5 USD ~)は、個人開発者にとって負担が大きい
  • 境界型セキュリティの限界
    • 「安全な接続元 IP アドレス」を全面的に信頼することは、近年のランサムウェア被害を考慮するとリスクが伴う

解決策: Lambda@Edge + Cognito による認証

前述の課題を解決するため、CloudFront 上で Lambda@Edge と Cognito User Pool を組み合わせた認証方式を採用しました。この構成のメリットは以下の通りです。

  • 柔軟なアクセス制御
    • IP アドレスに依存せず、ユーザー名とパスワードで認証が可能
    • 「Google でログイン」などのソーシャルログインにも対応可能
  • 低コスト
    • Lambda@Edge と Cognito の無料枠を活用し、ほぼ無料で運用できる
  • ゼロトラストネットワークへの対応
    • 境界型セキュリティを超えた、より進んだセキュリティアプローチを取り入れられる

「Google でログイン」の設定については下記の記事で解説しています!

Amazon Cognito と Google 個人アカウントで「Google でログイン」を実装する方法

補足: セキュリティの割り切り

この構成は、静的コンテンツ配信におけるコスト最適化を目的としています。CloudFront + S3 の構成では、攻撃リスクは比較的低いと判断しています。

ただし、将来的に動的なバックエンドを追加し、攻撃リスクが高まる場合には、WAF の導入を検討する予定です。

実装

AWS インフラは AWS SAM (Serverless Application Model) を使って IaC(Infrastructure as Code)で管理しているため、SAM テンプレートを交えて実装方法を説明します。

UseAccessControl condition の定義

SAM(CloudFormation)では、condition (条件)に基づいてリソースを生成できます。

  CognitoUserPool:
    Type: AWS::Cognito::UserPool
    Condition: UseAccessControl # UseAccessControl が true の場合のみリソースが生成される

アクセス制御関連の AWS リソースは開発環境限定で生成したいため、判定用の condition を定義します。

Parameters:
  # 1. 環境を受け取る
  Environment:
    Type: String
    Description: "デプロイ先環境"
    Default: stg
    AllowedValues:
      - stg
      - prd
# ...

Conditions:
  # 2. 「本番環境であるか」を判定する IsProd 条件
  IsProd: !Equals
    - !Ref Environment
    - 'prd'
  # 3. 「本番環境でない場合」にアクセス制御を有効化する UseAccessControl 条件
  UseAccessControl: !Not
    - !Condition IsProd
  1. SAM テンプレートの Parameters セクションで Environmentパラメータを定義し、デプロイ先環境を受け取ります。
  2. Conditions セクションで、「本番環境であるか」を判定する IsProd 条件を定義します。
  3. 「本番環境でない場合」にアクセス制御を有効化するための UseAccessControl 条件を定義します。

将来的に stg 以外の開発環境を増やす可能性を考慮し、「本番環境でない」という定義にしました。

Cognito

以下の3リソースを定義します:

  • UserPool: ユーザープール本体
  • UserPoolClient: OpenID Connect 準拠のクライアント。認証を付けたいアプリケーションに対する設定を記述する
  • UserPoolDomain: Cognito のログイン画面のドメイン

テンプレートは以下のようになります:

# ...
  CognitoUserPool:
    Type: AWS::Cognito::UserPool
    Condition: UseAccessControl
    Properties:
      UserPoolName: !Sub ${AWS::StackName}-userpool
      # (A) デフォルト ESSENTIALS なので注意!
      UserPoolTier: LITE
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireLowercase: true
          RequireNumbers: true
          RequireSymbols: true
          RequireUppercase: true
          TemporaryPasswordValidityDays: 7
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: true
      AccountRecoverySetting:
        RecoveryMechanisms:
          - Name: verified_email
            Priority: 1
      UsernameConfiguration:
        CaseSensitive: false
      VerificationMessageTemplate:
        DefaultEmailOption: CONFIRM_WITH_CODE
      MfaConfiguration: 'ON'
      EnabledMfas:
        - SOFTWARE_TOKEN_MFA
      Schema:
        - Name: email
          AttributeDataType: String
          Mutable: true
          Required: true
      EmailConfiguration:
        EmailSendingAccount: COGNITO_DEFAULT
  CognitoUserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Condition: UseAccessControl
    Properties:
      UserPoolId: !Ref CognitoUserPool
      ClientName: !Sub ${AWS::StackName}-userpoolclient
      GenerateSecret: false
      ExplicitAuthFlows:
        - ALLOW_USER_SRP_AUTH
        - ALLOW_REFRESH_TOKEN_AUTH
      PreventUserExistenceErrors: ENABLED
      AllowedOAuthFlows:
        - code
      AllowedOAuthScopes:
        - email
        - openid
        - phone
        - profile
      AllowedOAuthFlowsUserPoolClient: true
      SupportedIdentityProviders:
        - COGNITO
      CallbackURLs:
        # 本番環境以外はサブドメインがつくので条件分岐
        - !If
          - IsProd
          - !Sub https://${Domain}
          - !Sub https://${Environment}.${Domain}
      LogoutURLs: []
  CognitoUserPoolDomain:
    Type: AWS::Cognito::UserPoolDomain
    Condition: UseAccessControl
    Properties:
      UserPoolId: !Ref CognitoUserPool
      Domain: !Sub ${AWS::StackName}
      # (B) 1 を指定すると従来の「 Hosted UI 」となる
      ManagedLoginVersion: 1

Cognito UserPool は2024年11月に大幅アップデートがあり、ログイン画面のカスタマイズ (「マネージドログイン」機能) などが導入され、料金プランが改定されました。

執筆時点では、デフォルトは ESSENTIALS というミドルレンジのプランとなっています。 今回のケースでは、ログイン画面を使用するのは自分だけなので、画面カスタマイズは不要とし、料金プランも最も安価な LITE を選択しました(A)。

LITE プランでは「マネージドログイン」機能は使用できず、従来の「 Hosted UI 」のみが使用可能です。
ManagedLoginVersion パラメータに 1 を指定すると従来の「 Hosted UI 」を選択できます (B)。

「マネージドログイン」機能を利用時の注意

ESSENTIALS 以上のプランで「マネージドログイン」機能を利用する場合、ログイン画面のカスタマイズが必須となります。

カスタマイズせずにログイン画面にアクセスすると、以下のようなわかりにくいエラーメッセージが表示されるので注意が必要です。

Login pages unavailable

Please contact an administrator.

Cognito マネージドログイン使用時、画面カスタマイズを行っていないときのエラー画面
Login pages unavailable Please contact an administrator.
開発者ツールでログイン画面レスポンスを確認。403が返ってきている
開発者ツールでログイン画面レスポンスを確認。403が返ってきている

これは、2024年11月以前の感覚で、マネジメントコンソール上で Cognito を構成した場合に陥りやすいミスです。

SSM Parameter

Lambda@Edge にて認証ロジックを実装するためには、以下の情報が必要となります:

  • UserPoolId
  • UserPoolClientId
  • UserPoolDomain

Lambda@Edge は環境変数をサポートおらず、またハードコードも避けるべきです。そのため、これらの情報を SSM Parameter Store に格納し、Lambda@Edge から読み出す方法を採用しました。

次に問題となるのは、「Lambda@Edge に Parameter 名をどのように渡すか?」です。次の案を検討しました:

  • 没: Parameter 名を環境変数で渡す案
    これは Lambda@Edge の仕様上不可能です。
  • 没: Parameter 名をグローバル変数定義し、関数のビルド時に埋め込む
    例えば Vite ならば define の設定で実現が可能です。

    しかし今回は SAM の esbuild plugin を利用しており、 esbuild 本体にこのようなオプションはありませんでした。除外します。

  • 採用: Parameter 名を関数ソースコード中にハードコードする案

    今回はこのアプローチをとることとしました。

以下は SAM テンプレートの設定です:

# ...
  CognitoUserPoolIdParameter:
    Type: AWS::SSM::Parameter
    Condition: UseAccessControl
    Properties:
      Name: !Sub '/${AWS::StackName}-lambdaedge-access-control/user-pool-id'
      Tier: Standard
      Type: String
      Value: !GetAtt CognitoUserPool.UserPoolId
  CognitoUserPoolClientIdParameter:
    Type: AWS::SSM::Parameter
    Condition: UseAccessControl
    Properties:
      Name: !Sub '/${AWS::StackName}-lambdaedge-access-control/user-pool-client-id'
      Tier: Standard
      Type: String
      Value: !GetAtt CognitoUserPoolClient.ClientId
  CognitoUserPoolDomainParameter:
    Type: AWS::SSM::Parameter
    Condition: UseAccessControl
    Properties:
      Name: !Sub '/${AWS::StackName}-lambdaedge-access-control/user-pool-domain'
      Tier: Standard
      Type: String
      Value: !Sub ${CognitoUserPoolDomain}.auth.${AWS::Region}.amazoncognito.com

/(関数名)/user-pool-id のような Path 設計 を採用し、(関数名) の部分は Lambda の handler の context から取得します。

Lambda@Edge

handler

Cognito による認証を Lambda@Edge で実装するために、npm パッケージ「cognito-at-edge」が awslabs によって公開されています。

npm: cognito-at-edge

このパッケージを利用し、 handler の実装は以下のようになりました:

import { Authenticator } from "cognito-at-edge";
import type { CloudFrontRequestHandler } from "aws-lambda";
import { fetchParams } from "./fetchParams";

export const handler: CloudFrontRequestHandler = async (request, context) => {
  // NOTE: region が . 区切りで結合されているので、除去
  const functionName = context.functionName.split(".").pop();
  const { userPoolId, userPoolAppId, userPoolDomain } =
    await fetchParams(functionName);

  const authenticator = new Authenticator({
    region: "us-east-1",
    userPoolId,
    userPoolAppId,
    userPoolDomain,
    cookiePath: "/",
  });

  return authenticator.handle(request);
};

関数名を用いて SSM Parameter パラメータを取得し、 cognito-at-edge パッケージの Authenticator クラスに渡します。

注意点として、Lambda は CloudFront から Lambda@Edge として呼び出される際に、context.functionName に region が結合される仕様です。

例えば、関数名が blog-stg-lambdaedge-access-control の場合、context.functionNameus-east-1.blog-stg-lambdaedge-access-control となります。
(CloudWatch Logs のロググループ名も /aws/lambda/us-east-1.blog-stg-lambdaedge-access-control となります)

今回は blog-stg-lambdaedge-access-control の部分だけが必要なので、 . で分割して region 部分を捨てています:

  // NOTE: region が . 区切りで結合されているので、除去
  const functionName = context.functionName.split(".").pop();

SSM Parameter 取得部

SSM Parameter 取得部分は以下のように実装しました:

import { GetParameterCommand, SSMClient } from "@aws-sdk/client-ssm";

/**
 * ハンドラ外領域にキャッシュする
 */
let memo: {
  userPoolId: string;
  userPoolAppId: string;
  userPoolDomain: string;
} | null = null;

/**
 * Lambda@Edge では環境変数が取得できないので SSM Parameter Store から取得
 */
export const fetchParams = async (prefix: string) => {
  if (memo) return memo;

  const ssmClient = new SSMClient({
    region: "us-east-1",
  });

  // NOTE: GetParameters は順序が保証されないので使用しない
  const [
    {
      Parameter: { Value: userPoolId },
    },
    {
      Parameter: { Value: userPoolAppId },
    },
    {
      Parameter: { Value: userPoolDomain },
    },
  ] = await Promise.all([
    ssmClient.send(
      new GetParameterCommand({
        Name: `/${prefix}/user-pool-id`,
      }),
    ),
    ssmClient.send(
      new GetParameterCommand({
        Name: `/${prefix}/user-pool-client-id`,
      }),
    ),
    ssmClient.send(
      new GetParameterCommand({
        Name: `/${prefix}/user-pool-domain`,
      }),
    ),
  ]);

  return memo = { userPoolId, userPoolAppId, userPoolDomain };
};

SSM の GetParameter API を呼び出し、3つのパラメータを並列で取得しています。また、取得した結果を Lambda のハンドラ外領域にキャッシュし、warm start 時に再利用できるようにしています。

なお、SSM には GetParameters という API もあり、これを使えば一度の API 呼び出しで 3 つのパラメータを取得できます。
しかし、AWS 公式ドキュメント では以下のように説明されています:

The results for GetParameters requests are listed in alphabetical order in query responses.

つまり、GetParameters API では、パラメータ名ではなく取得した値に基づいてアルファベット順に並べ替えられるため、順序が重要なこのケースでは使用できませんでした。

dynamic require のワークアラウンド

sam deploy を実行して動作確認を行った際、以下のエラーが出力されました:

{
    "errorType": "Error",
    "errorMessage": "Dynamic require of \"crypto\" is not supported",
    "stack": [
        "Error: Dynamic require of \"crypto\" is not supported",
        "    at file:///var/task/app.mjs:12:9",
        "    at node_modules/aws-jwt-verify/dist/cjs/jwt-rsa.js (file:///var/task/app.mjs:819:20)",
        "    at __require2 (file:///var/task/app.mjs:15:50)",
        "    at node_modules/aws-jwt-verify/dist/cjs/index.js (file:///var/task/app.mjs:1245:24)",
        "    at __require2 (file:///var/task/app.mjs:15:50)",
        "    at node_modules/cognito-at-edge/dist/index.js (file:///var/task/app.mjs:18882:28)",
        "    at __require2 (file:///var/task/app.mjs:15:50)",
        "    at file:///var/task/app.mjs:19635:38",
        "    at ModuleJob.run (node:internal/modules/esm/module_job:234:25)",
        "    at async ModuleLoader.import (node:internal/modules/esm/loader:473:24)"
    ]
}

内部的に crypto パッケージを動的に require している部分があり、そのためエラーが発生しています。

GitHub の issue を参考にし、以下のように esbuild の Banner オプションでワークアラウンドを追加したところ、エラーが解消しました。

#...
  AccessControlFunction:
    Type: AWS::Serverless::Function
    Condition: UseAccessControl
    Properties:
      # ...
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        Format: esm
        Minify: false
        OutExtension:
          - .js=.mjs
        Target: "es2020"
        Sourcemap: false
        EntryPoints:
          - app.ts
        External:
          - aws-lambda
          - '@aws-sdk/*'
        # HACK: crypto の dynamic require のエラーを回避する
        # see https://github.com/aws/aws-sam-cli/issues/4827#issuecomment-1574080427
        Banner:
          - js=import { createRequire } from 'module'; const require = createRequire(import.meta.url);

「この方法で SSM Parameter 名も渡せたのでは…?」という気がしなくもないですが、いろいろなアプローチを試せるということで、今回はこれで良しとしました!

CloudFront の Cache Behavior に設定

作成した Lambda 関数を DefaultCacheBehavior に紐づけ、開発環境限定で動作させます:

# ...
  Distribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        DefaultCacheBehavior:
          # ...
          CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized
          OriginRequestPolicyId: acba4595-bd28-49b8-b9fe-13317c0390fa # UserAgentRefererHeaders
          LambdaFunctionAssociations:
            - !If
              - UseAccessControl
              - LambdaFunctionARN: !Sub ${AccessControlFunction.Version}
                EventType: viewer-request
              - !Ref AWS::NoValue
            - LambdaFunctionARN: !Sub ${IndexSupplierFunction.Version}
              EventType: origin-request
        # ...

LambdaFunctionAssociations には配列を指定します。本番環境と開発環境で異なる内容を設定するために、CloudFormation の Fn::If 関数と AWS::NoValue を活用して実現します。

認証状態がキャッシュされないように、origin-request ではなく viewer-request に設定することに注意しましょう。

ユーザ登録~認証動作確認

今回はセルフサインアップを無効化しているため、ユーザプールにあらかじめユーザを登録しておきます。

ユーザープール

MFA(多要素認証)などの設定も可能です。

ユーザー

準備が整いましたので、開発環境にアクセスするとログイン画面 (Cognito Hosted にリダイレクトされます。

ログイン画面

ユーザ名とパスワードを入力し、「Sign in」を押下します。

初回ログイン時には、パスワード変更や多要素認証の設定が求められます。

初期パスワード変更
多要素認証設定

私は Google Authenticatorを使用しているため、そこで生成されたコードを入力します。

次回以降のログインでは、多要素認証が求められます。

多要素認証

コードを入力し、再度「Sign in」を押下すると、開発環境に正常にアクセスできました!

認証成功

CognitoIdentityServiceProvider.*という Cookie に OpenID Connect の idToken や accessToken などが格納されているのが確認できます。

まとめ

Lambda@Edge と Cognito を組み合わせることで、開発環境へのアクセス制御が簡単に実現できました。

特に、AWS WAF による IP 制限と比較して柔軟性が高く、コスト面でも個人開発者にとって非常に優れた選択肢です。
将来的に動的なバックエンドを追加する場合や、より高度なセキュリティが求められるシナリオにおいては、WAFとの併用も可能です。

境界型セキュリティに加え、Lambda@Edge と Cognito を活用した新しいアプローチを手札に加えることで、より堅牢で柔軟なシステム構築が可能になります。要件に合えばぜひ検討してみてください!

今後も有益な情報を発信していきます。どうぞご期待ください!


wand

「wand」は魔法の杖を意味します。魔法のようにさまざまなものを自分の手で生み出せるようになりたい、そんな思いを込めました。 ハンドメイド、家庭菜園、DIY、プログラミング等、「つくる」をテーマに色々なことをしていきたいと思っています。 Amazonのアソシエイトとして、wand は適格販売により収入を得ています。 GitHub: https://github.com/wand2016