📌

Bolt for JavaScript で S3 にユーザートークンを保存する。そして削除できるようにもする。

2024/01/04に公開

Bolt for Python には (より厳密には、Bolt for Python の依存パッケージである slack-sdk パッケージに) アクセストークンを S3 に永続化する機能 が標準で備わっている一方で Bolt for JavaScript にはそのような機能が存在していなかったので、それっぽい機能を npm パッケージとして仕立てました、というお話です。

https://www.npmjs.com/package/@k11i/bolt-s3

またあわせて、Bolt for JavaScript において永続化されたアクセストークンをちゃんと削除する方法もご紹介します。

TypeScript/JavaScript でも S3 にアクセストークンを保存したい!

背景

ちょっとしたお試し的な Slack アプリのバックエンドを TypeScript で実装していて、そのバックエンドアプリケーションのフレームワークとして Bolt for JavaScript を採用しています。

そして Slack アプリに組み込もうとしている機能を実現するためにユーザートークンの取得が必要になったため、OAuth フローを通じて取得したユーザートークンを永続化するために、以下の記事にあるように S3 を利用する方法を検討していました。

https://qiita.com/seratch/items/12b39d636daf8b1e5fbf

同記事では Bolt for Python を用いて Slack アプリのバックエンドを実装しており、それゆえ前述したようにアクセストークンの S3 への永続化が容易に実現できています。しかし悲しいことに、Bolt for JavaScript 並びに @slack/oauth パッケージのどちらにも S3 への永続化機能が用意されておらず、自前で実装する必要がありました。

ゆえに今回、Bolt for JavaScript でも S3 への永続化を実現するための npm パッケージ @k11i/bolt-s3 を作ってみました。コードは以下になります。

https://github.com/komiya-atsushi/slack-bolt-s3

実装方針

当初は Bolt for Python におけるアクセストークンの S3 永続化機能を参考に、それと互換性のあるキー構成やシリアライズ方法を達成すべく実装を進めていたのですが、永続化対象である Installation オブジェクトの構造がどうやら Bolt for JavaScript のそれ と Bolt for Python のそれとで異なるっぽいことに途中で気づき、そこから方針を変えてシリアライズ方法の互換性を諦めるに至っています。

なおシリアライズ方法の互換性を諦めたことをこれ幸いと(?)、逆に Python 実装にはない付加的な機能として Brotli による Installation オブジェクト (を JSON シリアライズした文字列) の圧縮機能と、AES などによる暗号化機能[1]を実装することにしました。これらはどちらも Node.js のコアモジュール (node:zlib と node:crypto) のみで実現しています。

暗号化に関してはパスワードからの鍵導出に scrypt を利用している都合上、パスワードだけではなく salt の指定も必要になってしまっていますが、まあこんなもんだと思っていただき気にしないでください。

使い方

import {App} from '@slack/bolt';
import {S3} from '@aws-sdk/client-s3';
import {S3InstallationStore} from '@k11i/bolt-s3';

const s3 = new S3({ region: 'ap-northeast-1' });

const installationStore = new S3InstallationStore(
  s3,
  'アクセストークンを保存する先のバケット名をここに指定する',
  // S3 オブジェクトのキープレフィックスとしてこのクライアント ID を利用している
  process.env.SLACK_CLIENT_ID,
  // 圧縮も暗号化も不要ということであれば、以降の行を省いていただいて結構です
  {
    installationCodec: BinaryInstallationCodec.createDefault(
      'パスワードをここに指定する',
      'Salt をここに指定する',
    ),
  }
);

const app = new App({
  // (諸々を省略)
  installationStore,
});

諸注意

S3 永続化機能の Python 実装を見ていて思ったことではあるのですが、この npm パッケージはそれと比較して Enterprise Grid 関連の対応がちょっと甘い可能性があります。こればかりは綿密な動作確認が困難であるため、想定外の挙動が見られる可能性があることにご注意ください。

Slack アプリのアンインストールやユーザートークンの revoke に応じてアクセストークンをちゃんと削除したい!

今回実装した npm パッケージは永続化されているアクセストークンを削除する機能も有しているのですが、ちょっと困ったことに、Bolt for JavaScript はアプリのアンインストールイベント app_uninstalled やユーザートークンの revoke イベント tokens_revoked に応じてその削除機能を自動的に呼び出してくれるわけではない… というちょっとした(?)落とし穴があります[2]

従って、アンインストールイベントなどに応じる形で対応するアクセストークンをちゃんと削除するためには、自前でそれらのイベントハンドリングを実装する必要があります。具体的な実装は例えば以下のようになるでしょう (Slack アプリ側の設定で app_uninstalled イベントと tokens_revoked イベントを購読することをお忘れなく!)。

const installationStore = new S3InstallationStore(/* ... */);

const app = new App(/* ... */);

// ...

app.event('tokens_revoked', async ({context, event, logger}) => {
  const userIds = event.tokens.oauth;
  if (!userIds) {
    return;
  }

  const promises = userIds
    .map(userId => ({
      teamId: context.teamId,
      enterpriseId: context.enterpriseId,
      userId: userId,
      isEnterpriseInstall: context.isEnterpriseInstall,
    }))
    .map(query => installationStore.deleteInstallation(query, logger));

  await Promise.all(promises);
});

app.event('app_uninstalled', async ({context, logger}) => {
  await installationStore.deleteInstallation(
    {
      teamId: context.teamId,
      enterpriseId: context.enterpriseId,
      isEnterpriseInstall: context.isEnterpriseInstall,
    },
    logger
  );
});
脚注
  1. 「なんで暗号化? S3 はデフォルトで暗号化機能が有効になっているじゃん」と思われる方もいらっしゃるかと思いますが、この S3 のサーバサイドでの暗号化機能は AWS アカウントにアクセスするための認証情報をお漏らししてしまうケースに対してはまったくの無力なので、あえてこのクライアントサイドでの暗号化機能を実装しているわけです。 ↩︎

  2. 詳しくは 詳しくは https://github.com/slackapi/bolt-js/issues/1203 をご覧ください。 ↩︎

Discussion