🌧️

ServerlessでCognitoのcustomMessageを設定する

14 min read

Cognitoでサインアップ時に、CustomMessage Triggerで確認メールの中身を書き換えたかったんですが、Serverless Frameworkから設定しようとするとエラいハマりました。
日本語圏の情報がほとんどなかったので自分用に備忘録。

要件

  • 開発環境
    • Serverless Framework 2.31.0
    • Node.js 12.16.3
  • Backend
    • Cognito
    • Lambda
    • API Gateway
  • Frontend(本記事には関係ない)
    • Vue.js

上記構成で、認証及びメールアドレスの確認を行う。
その際、Cognitoが送る確認メールをCustomMessageで書き換えたい。

VueとSES、DynamoDBやCloudfrontなど本問題に直接関係のないリソースの設定は割愛します。

試行錯誤の筋道

正解だけ欲しい人はこちら
最初は以下のように書いていました。

serverless.yml
service: MyApp

provider:
  name: aws
  runtime: nodejs12.x
  stage: '${opt:stage,"dev"}'
  region: ap-northeast-1

functions:
  customMessage:
    handler: customMessage.handler
    events:
      - cognitoUserPool:
          pool: MyAppUserPool
          trigger: CustomMessage

resources:
  Resources:
    MyAppUserPool:
      Type: 'AWS::Cognito::UserPool'
      Properties:
        AccountRecoverySetting:
          RecoveryMechanisms:
            - Name: 'verified_email'
              Priority: 1
        AutoVerifiedAttributes:
          - 'email'
        AdminCreateUserConfig:
          AllowAdminCreateUserOnly: false
        AliasAttributes:
          - 'email'
        EmailConfiguration:
          EmailSendingAccount: DEVELOPER
          From: '********@example.com'
          ReplyToEmailAddress: '********@example.com'
          SourceArn: 'arn:aws:ses:********:identity/********@example.com'
        EmailVerificationMessage: '{####}.'
        EmailVerificationSubject: '認証コードの送信'
        DeviceConfiguration:
          ChallengeRequiredOnNewDevice: true
          DeviceOnlyRememberedOnUserPrompt: true
        Policies:
          PasswordPolicy:
            MinimumLength: 6
            RequireLowercase: false
            RequireNumbers: false
            RequireSymbols: false
            RequireUppercase: false
            TemporaryPasswordValidityDays: 365
        Schema:
          - AttributeDataType: String
            Mutable: true
            Name: 'email'
            Required: true
          - AttributeDataType: String
            Mutable: true
            Name: 'phone_number'
            Required: false
        UsernameConfiguration:
          CaseSensitive: true
        UserPoolName: '${self:service}-UserPool'
        VerificationMessageTemplate:
          DefaultEmailOption: 'CONFIRM_WITH_CODE'
          EmailMessage: '{####}.'
          EmailSubject: '認証コードの送信'

    MyAppUserPoolClient:
      Type: 'AWS::Cognito::UserPoolClient'
      Properties:
        ClientName: 'MyAppUserPoolClient'
        DefaultRedirectURI: 'https://www.example.com'
        CallbackURLs: 'https://www.example.com'
        LogoutURLs: 'https://www.example.com'
        ExplicitAuthFlows:
          - ALLOW_ADMIN_USER_PASSWORD_AUTH
          - ALLOW_CUSTOM_AUTH
          - ALLOW_USER_PASSWORD_AUTH
          - ALLOW_USER_SRP_AUTH
          - ALLOW_REFRESH_TOKEN_AUTH
        GenerateSecret: false
        PreventUserExistenceErrors: ENABLED
        ReadAttributes:
          - email
          - phone_number
        RefreshTokenValidity: 10
        SupportedIdentityProviders:
          - COGNITO
        UserPoolId: !Ref MyAppUserPool
customMessage.js
exports.handler = (event, context, callback) => {
  if (event.userPoolId === 'process.env.COGNITO_USER_POOL_ID') {
    if (event.triggerSource === 'CustomMessage_AdminCreateUser') {
      event.response.emailSubject = 'Welcome to MyAPP! Please verify your Email Adress.'
      event.response.emailMessage = `
      <!DOCTYPE html>
      <html>
        <head>
            <title>Welcome to MyApp - ${event.userName}</title>    
        </head>
        <body>
          <p><a href="https://app.example.com/auth/confirm?code=${event.request.codeParameter}">認証を完了する</a></p>
        </body>
      </html>    
      `
    }
  }
  callback(null, event)
}

上記コードは動作しませんでした。

UserPoolが二つできる問題

上記のままDeployするとUserPoolが二つ作られてしまいます。なんじゃそりゃ。
かなり理解に苦しみましたが、以下の挙動のようでした。

  1. functions内でCognitoのTriggerを設定すると、指定したPool名をCognitoUserPool{指定したPool名}のように置換されてから新規にUserPoolが作成される
  2. resource内で指定した通り、新規にリソースが作成される

ということで、現状の設定だと

  • CognitoUserPoolMyAppUserPool
  • MyAppUserPool

の二つのUserPoolが作成されます。その際、CustomMessageトリガーはCognitoUserPoolMyAppUserPoolのみに設定されますし、resource側で指定した細かい設定はMyUserPoolのみに適用されます。
これでは使い物になりません。

調べた結果、functions側で決めたUserPool名に対し、先頭に'CognitoUserPool'を追加してresources側のUserPool名とするのがお作法らしいです。functions側でUserPoolリソースを作成し、resource側でその設定をオーバーライドしている扱いみたいな。

各所でRefしてた場合は、その指定も変えるべきなので注意。

serverless.yml
...
functions:
  customMessage:
    handler: customMessage.handler
    environment:
-     COGNITO_USER_POOL_ID: !ref MyAppUserPool
+     COGNITO_USER_POOL_ID: !ref CognitouserPoolMyAppUserPool # リソース名が変わったので参照名を変更
    events:
      - cognitoUserPool:
          pool: MyAppUserPool
          trigger: CustomMessage

resources:
  Resources:
-   MyAppUserPool:
+   CognitouserPoolMyAppUserPool: # CognitoUserPool{functions側で指定したPool名}
      Type: 'AWS::Cognito::UserPool'
...

...
    MyAppUserPoolClient:
      Type: 'AWS::Cognito::UserPoolClient'
...

...
        RefreshTokenValidity: 10
        SupportedIdentityProviders:
          - COGNITO
-       UserPoolId: !Ref MyAppUserPool
+       UserPoolId: !Ref CognitouserPoolMyAppUserPool # リソース名が変わったので参照名を変更

はい、上記はまだ動作しません。

循環参照問題

上記の修正を行なってDeployしようとすると、以下のようなエラーが出てCloudFormationのチェックが通らなくなっていると思います。

Circular dependency between resources: [***************...].

リソースの作成主体がfunctions内のcustomMessageだから、その中でCognitoUserPoolMyAppUserPoolを参照しようとすると、自分の中での循環参照になるみたいです。

元々この環境変数は、Handler内部でTriggerが渡してきたUserPoolIdと環境変数経由のUserPoolIdを比較して複数のUserPoolから任意のUserPoolを選択する機能のためだったのですが、今回はUserPool一つで運用するため単純に機能を削減しました。

serverless.yml
...
functions:
  customMessage:
    handler: customMessage.handler
    environment:
-    COGNITO_USER_POOL_ID: !ref CognitouserPoolMyAppUserPool
    events:
      - cognitoUserPool:
          pool: MyAppUserPool
          trigger: CustomMessage

resources:
  Resources:
    CognitouserPoolMyAppUserPool:
      Type: 'AWS::Cognito::UserPool'
...
customMessage.js
exports.handler = (event, context, callback) => {
- if (event.userPoolId === 'process.env.COGNITO_USER_POOL_ID') {
-  if (event.triggerSource === 'CustomMessage_AdminCreateUser') {
-    event.response.emailSubject = 'Welcome to MyAPP! Please verify your Email Adress.'
-    event.response.emailMessage = `
-    <!DOCTYPE html>
-    <html>
-      <head>
-          <title>Welcome to MyApp - ${event.userName}</title>    
-      </head>
-      <body>
-        <p><a href="https://app.example.com/auth/confirm?us=${event.request.usernameParameter}&cp=${event.request.codeParameter}">認証を完了する</a></p>
-      </body>
-    </html>    
-     `
-   }
- }
+ if (event.triggerSource === 'CustomMessage_AdminCreateUser') {
+   event.response.emailSubject = 'Welcome to MyAPP! Please verify your Email Adress.'
+   event.response.emailMessage = `
+   <!DOCTYPE html>
+   <html>
+     <head>
+         <title>Welcome to MyApp - ${event.userName}</title>    
+     </head>
+     <body>
+       <p><a href="https://app.example.com/auth/confirm?code=${event.request.codeParameter}">認証を完了する</a></p>
+     </body>
+   </html>    
+    `
+   }
  callback(null, event)
}

なぜか本文が置換されない問題

ここまででDeployできるようになり、サインアップ時にメールが飛ぶようになりました。そのメールを見てみると、サブジェクトは置換されているのですが、本文が置換されていませんでした。

またまた調べたところ、CustomMessage_AdminCreateUserではevent.response.emailMessageにevent.request.usernameParameterとevent.request.codeParameterが含まれていないと機能しないようでした。この際、CloudWatchでもエラーメッセージが見当たらなかったので、問題の特定に苦労しました。

普通のSignUp処理で構わないので、CustomMessage_SignUpに変更して対応。

customMessage.js
exports.handler = (event, context, callback) => {
- if (event.triggerSource === 'CustomMessage_AdminCreateUser') {
+ if (event.triggerSource === 'CustomMessage_SignUp') {
    event.response.emailSubject = 'Welcome to MyAPP! Please verify your Email Adress.'
    event.response.emailMessage = `
    <!DOCTYPE html>
    <html>
      <head>
          <title>Welcome to MyApp - ${event.userName}</title>    
      </head>
      <body>
        <p><a href="https://app.example.com/auth/confirm?code=${event.request.codeParameter}">認証を完了する</a></p>
      </body>
    </html>    
    `
  }
  callback(null, event)
}

上記対応はフロントエンド又はConfirm受けのAPIもAdminCreateUserからSignUpに変更が必要です。もしAdminCreateUserの使用が必須なら以下。

customMessage.js
exports.handler = (event, context, callback) => {
  if (event.triggerSource === 'CustomMessage_AdminCreateUser') {
    event.response.emailSubject = 'Welcome to MyAPP! Please verify your Email Adress.'
    event.response.emailMessage = `
    <!DOCTYPE html>
    <html>
      <head>
          <title>Welcome to MyApp - ${event.userName}</title>    
      </head>
      <body>
-       <p><a href="https://app.example.com/auth/confirm?code=${event.request.codeParameter}">認証を完了する</a></p>
+       <p><a href="https://app.example.com/auth/confirm?code=${event.request.codeParameter}&user=${event.request.usernameParameter}">認証を完了する</a></p>
      </body>
    </html>    
    `
  }
  callback(null, event)
}

正解

上記修正を施した正解は以下。

serverless.yml
service: MyApp

provider:
  name: aws
  runtime: nodejs12.x
  stage: '${opt:stage,"dev"}'
  region: ap-northeast-1

functions:
  customMessage:
    handler: customMessage.handler
    events:
      - cognitoUserPool:
          pool: MyAppUserPool
          trigger: CustomMessage

resources:
  Resources:
    CognitoUserPoolMyAppUserPool:
      Type: 'AWS::Cognito::UserPool'
      Properties:
        AccountRecoverySetting:
          RecoveryMechanisms:
            - Name: 'verified_email'
              Priority: 1
        AutoVerifiedAttributes:
          - 'email'
        AdminCreateUserConfig:
          AllowAdminCreateUserOnly: false
        AliasAttributes:
          - 'email'
        EmailConfiguration:
          EmailSendingAccount: DEVELOPER
          From: '********@example.com'
          ReplyToEmailAddress: '********@example.com'
          SourceArn: 'arn:aws:ses:********:identity/********@example.com'
        EmailVerificationMessage: '{####}.'
        EmailVerificationSubject: '認証コードの送信'
        DeviceConfiguration:
          ChallengeRequiredOnNewDevice: true
          DeviceOnlyRememberedOnUserPrompt: true
        Policies:
          PasswordPolicy:
            MinimumLength: 6
            RequireLowercase: false
            RequireNumbers: false
            RequireSymbols: false
            RequireUppercase: false
            TemporaryPasswordValidityDays: 365
        Schema:
          - AttributeDataType: String
            Mutable: true
            Name: 'email'
            Required: true
          - AttributeDataType: String
            Mutable: true
            Name: 'phone_number'
            Required: false
        UsernameConfiguration:
          CaseSensitive: true
        UserPoolName: '${self:service}-UserPool'
        VerificationMessageTemplate:
          DefaultEmailOption: 'CONFIRM_WITH_CODE'
          EmailMessage: '{####}.'
          EmailSubject: '認証コードの送信'

    MyAppUserPoolClient:
      Type: 'AWS::Cognito::UserPoolClient'
      Properties:
        ClientName: 'MyAppUserPoolClient'
        DefaultRedirectURI: 'https://www.example.com'
        CallbackURLs: 'https://www.example.com'
        LogoutURLs: 'https://www.example.com'
        ExplicitAuthFlows:
          - ALLOW_ADMIN_USER_PASSWORD_AUTH
          - ALLOW_CUSTOM_AUTH
          - ALLOW_USER_PASSWORD_AUTH
          - ALLOW_USER_SRP_AUTH
          - ALLOW_REFRESH_TOKEN_AUTH
        GenerateSecret: false
        PreventUserExistenceErrors: ENABLED
        ReadAttributes:
          - email
          - phone_number
        RefreshTokenValidity: 10
        SupportedIdentityProviders:
          - COGNITO
        UserPoolId: !Ref CognitoUserPoolMyAppUserPool
customMessage.js
exports.handler = (event, context, callback) => {
  if (event.triggerSource === 'CustomMessage_SignUp') {
    event.response.emailSubject = 'Welcome to MyAPP! Please verify your Email Adress.'
    event.response.emailMessage = `
    <!DOCTYPE html>
    <html>
      <head>
          <title>Welcome to MyApp - ${event.userName}</title>    
      </head>
      <body>
        <p><a href="https://app.example.com/auth/confirm?code=${event.request.codeParameter}">認証を完了する</a></p>
      </body>
    </html>    
    `
  }
  callback(null, event)
}

結論

結局リファレンスに大抵のことは書いてあったのですが、直感に反する仕様だと、リファレンスの読み解き力が試されるなぁ、と。

参考

https://www.serverless.com/framework/docs/providers/aws/events/cognito-user-pool/
https://bleepcoder.com/serverless/358827977/duplicate-cognito-user-pool-create-when-resource-and
https://levelup.gitconnected.com/one-step-aws-cognito-user-pool-with-lambda-triggers-using-serverless-framework-d3bdf07b62c1
https://wp-kyoto.net/send-html-email-from-cognito-userpool-trigger-as-admin-create-user/
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html