アクセスキーを使わずにAWS SESを使って、Firebaseからメール送信できるようにする

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_(サービスアカウントアドレス) としておくとよいでしょう。この名前はSTSAPIを呼び出すときに必要になります。

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を使用して取得します。(GCPAPIとサービスコンソールで、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トークンを使ってAWSSTS 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",
  });
};

*1:より踏み込むのであれば、AWSアクセス専用のサービスアカウントを作り、サービスアカウントの権限借用で必要な場合にのみトークンを生成するようにしても良いでしょう。