amplify add auth を実行して amplify push すると作成される CloudFormatin スタックの Lambda カスタムリソースについて

AWS
AWS
この記事は約24分で読めます。

amplify add auth を実行して amplify push を実行すると、CloudFormation により Cognito 関連のリソースが作成されます。
その中に Lambda のカスタムリソースがあり、また add auth で対話形式で設定した内容によって Lambda のカスタムリソース数が異なっていたので、どのような Lambda が作成されているか確認してみました。

デフォルト設定で add auth を行なった場合

実際は add auth の際に以下の選択をしました。

Do you want to use the default authentication and security configuration? Default configuration
 Warning: you will not be able to edit these selections. 
 How do you want users to be able to sign in? Username
 Do you want to configure advanced settings? No, I am done.

上記により amplify push を行うと、CloudFormation スタックにより主に以下リソースが作成されました。

  • Cognito アイデンティティプール
  • Cognito ユーザープール
    • ユーザープールクライアント(ウェブ用)
    • ユーザープールクライアント(ネイティブ用)
  • カスタムリソースの Lambda(論理名:UserPoolClientLambda)

次では、 UserPoolClientLambda が何をしているかみていきます。

UserPoolClientLambda が何をしているか

作成したCognitoユーザープールのアプリクライアントの、クライアントシークレットを返しています。正直なんの用途のため利用されるのかわかりません。
CloudFormatin スタックテンプレートを見る限り、Outputs に上記で取得したクライアントシークレットを Ref で参照しているので、スタックの Outputs に表示するために作成されたのでしょうか。
確かに AWS::Cognito::UserPoolClient リソースを Ref で参照しても確認できるのはクライアント ID だけであり、クライアントシークレットは確認できません。
しかし Outputs で表示するためだけにカスタムリソースを作成するか疑問なので、謎です。

ソースコード

const response = require('cfn-response');
const aws = require('aws-sdk');
const identity = new aws.CognitoIdentityServiceProvider();
exports.handler = (event, context, callback) => {
  if (event.RequestType == 'Delete') {
    response.send(event, context, response.SUCCESS, {});
  }
  if (event.RequestType == 'Update' || event.RequestType == 'Create') {
    const params = {
      ClientId: event.ResourceProperties.clientId,
      UserPoolId: event.ResourceProperties.userpoolId,
    };
    identity
      .describeUserPoolClient(params)
      .promise()
      .then(res => {
        response.send(event, context, response.SUCCESS, { appSecret: res.UserPoolClient.ClientSecret });
      })
      .catch(err => {
        response.send(event, context, response.FAILED, { err });
      });
  }
};

カスタムリソースで取得したクライアントシークレットを Outputs で参照している

CloudFormation テンプレートの Outputs から抜粋。

    "AppClientSecret": {
      "Value": {
        "Fn::GetAtt": [
          "UserPoolClientInputs",
          "appSecret"
        ]
      },
      "Condition": "ShouldOutputAppClientSecrets"
    }

デフォルトのフェデレーション設定で add auth を行なった場合

実際は add auth の際に以下の選択をしました。

Do you want to use the default authentication and security configuration? Default configuration with Social Provider (Federati
on)
 Warning: you will not be able to edit these selections. 
 How do you want users to be able to sign in? Username
 Do you want to configure advanced settings? No, I am done.
 What domain name prefix do you want to use? amplifyauth06292e09-xxx
 Enter your redirect signin URI: http://localhost/
? Do you want to add another redirect signin URI No
 Enter your redirect signout URI: http://localhost/
? Do you want to add another redirect signout URI No
 Select the social providers you want to configure for your user pool:

上記により amplify push を行うと、CloudFormation スタックにより主に以下リソースが作成されました。

  • Cognito アイデンティティプール
  • Cognito ユーザープール
    • ユーザープールクライアント(ウェブ用)
    • ユーザープールクライアント(ネイティブ用)
  • カスタムリソースの Lambda(論理名:HostedUICustomResource)
  • カスタムリソースの Lambda(論理名:HostedUIProvidersCustomResource)
  • カスタムリソースの Lambda(論理名:OAuthCustomResource)
  • カスタムリソースの Lambda(論理名:UserPoolClientLambda)

UserPoolClientLambda は前のセクションで説明したのと同一であったため、その他 3 つのカスタムリソースの Lambda が何をしているかみていきます。

HostedUICustomResource が何をしているか

ドメインの作成・削除を実行する。
CloudTrail を確認すると、この Lambda から CreateUserPoolDomain が実行されているのが確認できました。

ソースコード

const response = require('cfn-response');
const aws = require('aws-sdk');
const identity = new aws.CognitoIdentityServiceProvider();
exports.handler = (event, context, callback) => {
  const userPoolId = event.ResourceProperties.userPoolId;
  const inputDomainName = event.ResourceProperties.hostedUIDomainName;
  let deleteUserPoolDomain = domainName => {
    let params = { Domain: domainName, UserPoolId: userPoolId };
    return identity.deleteUserPoolDomain(params).promise();
  };
  if (event.RequestType == 'Delete') {
    deleteUserPoolDomain(inputDomainName)
      .then(() => {
        response.send(event, context, response.SUCCESS, {});
      })
      .catch(err => {
        console.log(err);
        response.send(event, context, response.FAILED, { err });
      });
  }
  if (event.RequestType == 'Update' || event.RequestType == 'Create') {
    let checkDomainAvailability = domainName => {
      let params = { Domain: domainName };
      return identity
        .describeUserPoolDomain(params)
        .promise()
        .then(res => {
          if (res.DomainDescription && res.DomainDescription.UserPool) {
            return false;
          }
          return true;
        })
        .catch(err => {
          return false;
        });
    };
    let createUserPoolDomain = domainName => {
      let params = { Domain: domainName, UserPoolId: userPoolId };
      return identity.createUserPoolDomain(params).promise();
    };
    identity
      .describeUserPool({ UserPoolId: userPoolId })
      .promise()
      .then(result => {
        if (inputDomainName) {
          if (result.UserPool.Domain === inputDomainName) {
            return;
          } else {
            if (!result.UserPool.Domain) {
              return checkDomainAvailability(inputDomainName).then(isDomainAvailable => {
                if (isDomainAvailable) {
                  return createUserPoolDomain(inputDomainName);
                } else {
                  throw new Error('Domain not available');
                }
              });
            } else {
              return checkDomainAvailability(inputDomainName).then(isDomainAvailable => {
                if (isDomainAvailable) {
                  return deleteUserPoolDomain(result.UserPool.Domain).then(() => createUserPoolDomain(inputDomainName));
                } else {
                  throw new Error('Domain not available');
                }
              });
            }
          }
        } else {
          if (result.UserPool.Domain) {
            return deleteUserPoolDomain(result.UserPool.Domain);
          }
        }
      })
      .then(() => {
        response.send(event, context, response.SUCCESS, {});
      })
      .catch(err => {
        console.log(err);
        response.send(event, context, response.FAILED, { err });
      });
  }
};

HostedUIProvidersCustomResource が何をしているか

IdPの作成・更新・削除を実行しています。
ただし、amplify override auth で SAML の IdP を追加した際、この Lambda では作成されませんでした。
override でリソースを作成する際は CDK で設定するため、CloudFormation スタックの方から作成されるようです。
IdP を作成する方法によっては、この Lambda からは作成されません。
なお、amplify cli から対話形式で Login With Amazon を IdP に追加して amplify push を行うと、CloudTrail から、この Lambda が CreateIdentityProvider を実行していることが確認できました。

ソースコード

const response = require('cfn-response');
const aws = require('aws-sdk');
const identity = new aws.CognitoIdentityServiceProvider();
exports.handler = (event, context, callback) => {
  try {
    const userPoolId = event.ResourceProperties.userPoolId;
    let hostedUIProviderMeta = JSON.parse(event.ResourceProperties.hostedUIProviderMeta);
    let hostedUIProviderCreds = JSON.parse(event.ResourceProperties.hostedUIProviderCreds);
    if (hostedUIProviderCreds.length === 0) {
      response.send(event, context, response.SUCCESS, {});
    }
    if (event.RequestType == 'Delete') {
      response.send(event, context, response.SUCCESS, {});
    }
    if (event.RequestType == 'Update' || event.RequestType == 'Create') {
      let getRequestParams = providerName => {
        let providerMetaIndex = hostedUIProviderMeta.findIndex(provider => provider.ProviderName === providerName);
        let providerMeta = hostedUIProviderMeta[providerMetaIndex];
        let providerCredsIndex = hostedUIProviderCreds.findIndex(provider => provider.ProviderName === providerName);
        let providerCreds = hostedUIProviderCreds[providerCredsIndex];
        let requestParams = {
          ProviderName: providerMeta.ProviderName,
          UserPoolId: userPoolId,
          AttributeMapping: providerMeta.AttributeMapping,
        };
        if (providerMeta.ProviderName === 'SignInWithApple') {
          if (providerCreds.client_id && providerCreds.team_id && providerCreds.key_id && providerCreds.private_key) {
            requestParams.ProviderDetails = {
              client_id: providerCreds.client_id,
              team_id: providerCreds.team_id,
              key_id: providerCreds.key_id,
              private_key: providerCreds.private_key,
              authorize_scopes: providerMeta.authorize_scopes,
            };
          } else {
            requestParams = null;
          }
        } else {
          if (providerCreds.client_id && providerCreds.client_secret) {
            requestParams.ProviderDetails = {
              client_id: providerCreds.client_id,
              client_secret: providerCreds.client_secret,
              authorize_scopes: providerMeta.authorize_scopes,
            };
          } else {
            requestParams = null;
          }
        }
        return requestParams;
      };
      let createIdentityProvider = providerName => {
        let requestParams = getRequestParams(providerName);
        if (!requestParams) {
          return Promise.resolve();
        }
        requestParams.ProviderType = requestParams.ProviderName;
        return identity.createIdentityProvider(requestParams).promise();
      };
      let updateIdentityProvider = providerName => {
        let requestParams = getRequestParams(providerName);
        if (!requestParams) {
          return Promise.resolve();
        }
        return identity.updateIdentityProvider(requestParams).promise();
      };
      let deleteIdentityProvider = providerName => {
        let params = { ProviderName: providerName, UserPoolId: userPoolId };
        return identity.deleteIdentityProvider(params).promise();
      };
      let providerPromises = [];
      identity
        .listIdentityProviders({ UserPoolId: userPoolId, MaxResults: 60 })
        .promise()
        .then(result => {
          console.log(result);
          let providerList = result.Providers.map(provider => provider.ProviderName);
          let providerListInParameters = hostedUIProviderMeta.map(provider => provider.ProviderName);
          hostedUIProviderMeta.forEach(providerMetadata => {
            if (providerList.indexOf(providerMetadata.ProviderName) > -1) {
              providerPromises.push(updateIdentityProvider(providerMetadata.ProviderName));
            } else {
              providerPromises.push(createIdentityProvider(providerMetadata.ProviderName));
            }
          });
          providerList.forEach(provider => {
            if (providerListInParameters.indexOf(provider) < 0) {
              providerPromises.push(deleteIdentityProvider(provider));
            }
          });
          return Promise.all(providerPromises);
        })
        .then(() => {
          response.send(event, context, response.SUCCESS, {});
        })
        .catch(err => {
          console.log(err.stack);
          response.send(event, context, response.FAILED, { err });
        });
    }
  } catch (err) {
    console.log(err.stack);
    response.send(event, context, response.FAILED, { err });
  }
};

OAuthCustomResource が何をしているか

アプリクライアントの更新を実行しています。
CloudFormation スタックでは土台となる AWS::Cognito::UserPoolClient リソースを作成していますが、詳細設定はこの Lambda で UpdateUserPoolClient を実行して行なっているようです。

ソースコード

const response = require('cfn-response');
const aws = require('aws-sdk');
const identity = new aws.CognitoIdentityServiceProvider();
exports.handler = (event, context, callback) => {
  try {
    const userPoolId = event.ResourceProperties.userPoolId;
    let webClientId = event.ResourceProperties.webClientId;
    let nativeClientId = event.ResourceProperties.nativeClientId;
    let hostedUIProviderMeta = JSON.parse(event.ResourceProperties.hostedUIProviderMeta);
    let oAuthMetadata = JSON.parse(event.ResourceProperties.oAuthMetadata);
    let providerList = hostedUIProviderMeta.map(provider => provider.ProviderName);
    providerList.push('COGNITO');
    if (event.RequestType == 'Delete') {
      response.send(event, context, response.SUCCESS, {});
    }
    if (event.RequestType == 'Update' || event.RequestType == 'Create') {
      let params = {
        UserPoolId: userPoolId,
        AllowedOAuthFlows: oAuthMetadata.AllowedOAuthFlows,
        AllowedOAuthFlowsUserPoolClient: true,
        AllowedOAuthScopes: oAuthMetadata.AllowedOAuthScopes,
        CallbackURLs: oAuthMetadata.CallbackURLs,
        LogoutURLs: oAuthMetadata.LogoutURLs,
        SupportedIdentityProviders: providerList,
      };
      console.log(params);
      let updateUserPoolClientPromises = [];
      params.ClientId = webClientId;
      updateUserPoolClientPromises.push(identity.updateUserPoolClient(params).promise());
      params.ClientId = nativeClientId;
      updateUserPoolClientPromises.push(identity.updateUserPoolClient(params).promise());
      Promise.all(updateUserPoolClientPromises)
        .then(() => {
          response.send(event, context, response.SUCCESS, {});
        })
        .catch(err => {
          console.log(err.stack);
          response.send(event, context, response.FAILED, { err });
        });
    }
  } catch (err) {
    console.log(err.stack);
    response.send(event, context, response.FAILED, { err });
  }
};

まとめ

amplify push を行うとさまざまなリソースが CloudFormation により作成されますが、全てを CloudFormation テンプレートで定義しているのではなく、カスタムリソースを利用して詳細設定を行なっていることがわかりました。

おそらく全てを CloudFormation テンプレート上で定義すると更新時の置換処理などで不都合が生じるからだと思いますが、Amplify でリソースをカスタマイズする際に苦労するのが今回の調査で納得しました。
更新を行う主体が更新方法によって異なる場合があるなら、カスタマイズ時に色々と予期せぬ結果が生じそうですよね。CloudFormation だけでも更新時の挙動が予想と異なるのはよくあることなので(大抵ドキュメントにも記載してありますが自分の認識と異なっていたり)。

タイトルとURLをコピーしました