GCPやFirebaseにはメール関連の現行サービスがなく、外部のメールサービスを使用する必要があります。 そのため、SendGridやAWS SESなどを組み合わせる必要があります。
今回はAWS SESを使うことになったのですが、そのためにアクセスキーやSMTPパスワードを管理するのはキーの管理方法を考える必要があり、手間です。 ですので、GCPのサービスアカウントでAWSのIAMロールに認証してメールを送信ようにしてみました。
今回の構成
メールを送りたいGCP側のサービスはFirebase Functionsで実装されています。Firebase FunctionsはApp Engineデフォルトサービスアカウントで動作します。
App EngineデフォルトサービスアカウントのIDトークンで、AWS STS(Security Token Service)を呼び出し、AWS側のIAMロールに対応する一時的な認証情報を取得します。
メール送信はnodemailerのSESトランスポートを使用しますので、プログラムからは通常のSMTPとそう変わらない使用感で送信できます。
GCPサービスアカウントの確認と設定
まずは、GCPのサービスアカウントの情報を確認します。 GCPのIAMコンソールで、App Engine デフォルトサービスアカウントの詳細情報を開き「一意のID」を調べます。
次に、サービスアカウントの詳細情報の「権限」タブを開き、「サービスアカウントトークン作成者」権限を付与しておきます。*1
AWS IAMの設定
次に、ターゲットとなるAWSアカウントで、IAMの管理画面を開き、設定を加えていきます。
メール送信のポリシーを作成
メール送信だけの権限があるポリシーを作成します。GCPSendEmailなどといったポリシー名で以下の内容で作成しました。
{ "Version": "2012-10-17", "Statement": [ { "Sid": "SendEmail", "Effect": "Allow", "Action": [ "ses:SendEmail", "ses:SendRawEmail" ], "Resource": "arn:aws:ses:*:000000000000:identity/*" } ] }
ロールの追加
次に、GCPのサービスアカウントをロールとして追加します。
ロールを作成画面で「ウェブアイデンティティ」を指定し、アイデンティティプロバイダでGoogleを指定、Audienceにサービスアカウントの「一意のID」を指定しておきます。
許可を追加で、先ほど作っておいたポリシー(GCPSendEmail)を指定します。
最後のロールの詳細では、GCPSA_(サービスアカウントアドレス) としておくとよいでしょう。この名前はSTSのAPIを呼び出すときに必要になります。
AWSの認証情報を得るプログラムを記述する
それでは、Firebase Function でプログラムを書いていきましょう。
必要となる情報
できるだけ実行環境から設定値を取って、定数を定義せずに済むようにしようと思いますが、定数化しておかなければならない情報は以下の通りです。
Cloud Functionsの環境変数を使って注入しても良いでしょう。
サービスアカウントメールアドレスの取得
gcp-metadata ライブラリを使うことで、実行中のサービスアカウントメールアドレスを取得できます。 ローカルで実行しているときは取得できないため、process.env.FUNCTIONS_EMULATOR が true の場合は定数からサービスアカウントメールアドレスを得るようにします。
import * as gcpMetadata from "gcp-metadata"; const isEmulator = Boolean(process.env.FUNCTIONS_EMULATOR); const emulatorServiceAccount = process.env.EMULATOR_SA; const getServiceAccountEmailAsync = async (): Promise<string> => { if (isEmulator) { return emulatorServiceAccount; } else { return await gcpMetadata.instance( "service-accounts/default/email", ); } };
サービスアカウントのIDトークン取得
サービスアカウントのIDトークンを取得します。
gcp-metadataライブラリを使って取得することもできますが、ローカル動作を考慮してGCPのIAM Credential APIを使用して取得します。(GCPのAPIとサービスコンソールで、IAM Credentials APIの有効化が必要です)
import { IAMCredentialsClient } from "@google-cloud/iam-credentials"; const audience = "https://www.googleapis.com/"; const getServiceAccountIdTokenAsync = async ( sa: string, ): Promise<string | undefined> => { const client = new IAMCredentialsClient(); const [response] = await client.generateIdToken({ name: `projects/-/serviceAccounts/${sa}`, audience, }); if (!response.token) { return undefined; } return response.token; };
サービスアカウントのIDトークンでAWSの認証情報を取得する
サービスアカウントのIDトークンを使ってAWSのSTS APIを呼び出し、AWSの認証情報を取得します。
import { AssumeRoleWithWebIdentityCommand, STSClient, } from "@aws-sdk/client-sts"; const awsAccountId = "000000000000"; type AWSCredentials = { accessKeyId: string; secretAccessKey: string; sessionToken: string; expiration?: Date; }; const getAWSCredentialsByWebIdentityAsync = async ( sa: string, gcpToken: string ): Promise<AWSCredential | undefined> => { const roleArn = `arn:aws:iam::${awsAccountId}:role/GCPSA_${sa}`; const sts = new STSClient({}); const result = await sts.send( new AssumeRoleWithWebIdentityCommand({ RoleArn: roleArn, WebIdentityToken: gcpToken, RoleSessionName: new Date().getTime().toString(), }), ); if (!result.Credentials) { return undefined; } const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = result.Credentials; if (!AccessKeyId || !SecretAccessKey || !SessionToken) { return undefined; } return { accessKeyId: AccessKeyId, secretAccessKey: SecretAccessKey, sessionToken: SessionToken, expiration: Expiration, }; };
取得したAWS認証情報を使ってメールを送信する
それでは、取得した認証情報でメールを送信してみましょう。
import * as aws from "@aws-sdk/client-ses"; import { SES } from "@aws-sdk/client-ses"; import * as nodemailer from "nodemailer"; const sendMailAsync = async (): Promiose<void> => { const sa = await getServiceAccountEmailAsync(); const token = await getServiceAccountIdTokenAsync(sa); if (!token) { return; } const awsCredentials = await getAWSCredentialsByWebIdentityAsync(sa); if (!awsCredentials) { return; } const ses = new SES({ credentials }); const transporter = nodemailer.createTransport({ SES: { ses, aws }, }); await transporter.sendMail({ from: "from@example.com", to: "to@example.com", subject: "test mail", text: "this is a test mail", }); };