Slack API のトークンローテーション完全ガイド

こんにちは、Slack で公式 SDK 開発と日本の DevRel を担当しております @seratch と申します。

こちらの記事では、今年 Slack がリリースしたトークンローテーションに関する詳細な説明を日本語でチュートリアル形式でやっていきたいと思います。

## トークンローテーションとは

Slack の OAuth アクセストークンは、長らく [refresh token](https://datatracker.ietf.org/doc/html/rfc6749#section-1.5) がなく、一度発行した access token は明に無効化（[`auth.revoke` API](https://api.slack.com/methods/auth.revoke) や[ワークスペースのアプリ管理画面](https://my.slack.com/apps)から revoke できます）しない限り、無期限で使える仕様でした。

しかし、2021 年に [refresh token とともに access token を発行する機能](https://api.slack.com/authentication/rotation)が提供されるようになりました。

https://api.slack.com/authentication/rotation

この記事ではこれを「トークンローテーション（Token Rotation）」と呼びます。なお、このトークンローテーションはデフォルトでは有効ではなく Slack アプリの管理画面から（または App Manifest の設定によって） opt-in する必要があります。この記事では、この辺の手順・注意点も改めて日本語で紹介していきます。

## 通常の Slack アプリのトークンローテーションを試してみる

通常の bot token / user token を持つアプリのトークンローテーションの利用法を説明していきます。

### Slack アプリを作成〜設定する

[こちらの URL](https://api.slack.com/apps?new_app=1&manifest_yaml=_metadata%3A%0A++major_version%3A+1%0A++minor_version%3A+1%0Adisplay_information%3A%0A++name%3A+token-rotation-test-app%0Afeatures%3A%0A++bot_user%3A%0A++++display_name%3A+token-rotation-test-app%0Aoauth_config%3A%0A++redirect_urls%3A%0A++++-+https%3A%2F%2FTOBEUPDATED.ngrok.io%2Fslack%2Foauth_redirect%0A++scopes%3A%0A++++user%3A%0A++++++-+chat%3Awrite%0A++++bot%3A%0A++++++-+app_mentions%3Aread%0A++++++-+chat%3Awrite%0A%0Asettings%3A%0A++token_rotation_enabled%3A+true%0A++event_subscriptions%3A%0A++++request_url%3A+https%3A%2F%2FTOBEUPDATED.ngrok.io%2Fslack%2Fevents%0A++++bot_events%3A%0A++++++-+app_mention%0A) にアクセスして、新しいアプリ設定をつくります。以下の動画のように、開発用に使用するホームのワークスペースを選んだら、自動的に設定が読み込まれているはずです。まずはそのままアプリを作ってみてください。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/77f9f936-c6bf-cc8b-bf82-1ab619516d75.gif" width=400 />

上のリンクには [App Manifest という YAML 形式の設定](https://api.slack.com/reference/manifests)がクエリストリングとして仕込んでありました。その内容を以下に貼っておきます。手動で設定するときは、これをベースに設定してみてください。各項目の説明は[こちら](https://api.slack.com/reference/manifests#fields)を参考にしてください。

```yaml
_metadata:
  major_version: 1
  minor_version: 1
display_information:
  name: token-rotation-test-app
features:
  bot_user:
    display_name: token-rotation-test-app
oauth_config:
  redirect_urls:
    - https://TOBEUPDATED.ngrok.io/slack/oauth_redirect
  scopes:
    user:
      - chat:write
    bot:
      - app_mentions:read
      - chat:write
settings:
  event_subscriptions:
    request_url: https://TOBEUPDATED.ngrok.io/slack/events
    bot_events:
      - app_mention
  token_rotation_enabled: true
```

画面から設定する場合は **Settings** > **OAuth & Permissions** のページで Opt in します。英語で書かれている通り OAuth の Redirect URL の設定が必須となります（つまり、この管理画面からのインストールではなく OAuth フローでのみ refresh token は発行されます）。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/8ade7fa0-79f3-f2ef-ead9-13fbff892c88.png" width=500 />
これでアプリの設定はできたので、実際に OAuth フローによる Slack ワークスペースへのインストールを実行してローテーション可能なアクセストークンを取得してみましょう。ここからは Python と Node.js でそれぞれサンプルアプリを動かしていきます。

なお、ここでの例ではソケットモードを使っていませんが、OAuth フロー以外のイベントを処理する部分などにはソケットモードを使用することも可能です。ソケットモードを使いたい場合は、以下のサンプルや記事を参考にしてみてください。

* [Slack ソケットモードの最も簡単な始め方](https://qiita.com/seratch/items/1a460c08c3e245b56441)
* [Python での OAuth とソケットーモードの併用コード例](https://github.com/slackapi/bolt-python/blob/v1.10.0/examples/socket_mode_oauth.py)
* [JavaScript での OAuth とソケットモードの併用コード例](https://github.com/slackapi/bolt-js/tree/%40slack/bolt%403.8.1/examples/socket-mode)


### Python でアプリを実装する

Python で Slack の [OAuth フロー（アクセストークンを発行して Slack ワークスペースでアプリを有効にする手順）](https://api.slack.com/legacy/oauth)と [Slack から来たイベント](https://api.slack.com/apis/connections/events-api)に対してトークンローテーションをしながら応答するアプリを動かしてみましょう。

#### プロジェクトの新規作成

まず使用する Python のバージョンが 3.6 以上であるかを確認してください。

最近では、システム標準の `python3` や `pip3` などのコマンドも 3.6 以上のバージョンだとは思いますが、常に最新のバージョン（この記事投稿時点で 3.10 です）を使用するために [pyenv](https://github.com/pyenv/pyenv) などのツールを使って Python のランタイムを管理することをお勧めします。

その 3.6 以上の Python で、以下のような依存ライブラリを解決したまっさらな環境を作ります。

```bash
echo 'slack-bolt>=1.10,<2' > requirements.txt
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```

Poetry を使うなら、以下のコマンドで同じことができます。

```bash
poetry init -n
poetry shell
poetry add slack-bolt
```

仮想環境を準備できたので、ここでアプリを実装して起動していきます。

#### 環境変数を設定して、アプリケーションを起動

以下のような Python コードを `app.py` という名前で保存してください。

```python
# より詳細なログを出力するためにログレベルを DEBUG に変更します
import logging
logging.basicConfig(level=logging.DEBUG)

import os
from slack_bolt import App
from slack_bolt.oauth.oauth_settings import OAuthSettings
from slack_sdk.oauth.installation_store import FileInstallationStore
from slack_sdk.oauth.state_store import FileOAuthStateStore

oauth_settings = OAuthSettings(
    # ここの二つの環境変数は必須で、正しく対象のアプリのものが設定されている必要があります
    client_id=os.environ["SLACK_CLIENT_ID"],
    client_secret=os.environ["SLACK_CLIENT_SECRET"],
    # これは管理画面における Bot Scopes です
    scopes=["app_mentions:read", "chat:write"],
    # これは管理画面における User Scopes です
    user_scopes=["chat:write"],
    # Slack ワークスペースへのインストール情報を管理する実装、ここではローカルファイルに保存します
    installation_store=FileInstallationStore(base_dir="./data/installations"),
    # OAuth フローの state パラメーターの永続化の実装、ローカルファイルに保存します
    state_store=FileOAuthStateStore(expiration_seconds=600, base_dir="./data/states"),
    # 以下の設定は後ほど説明します
    # token_rotation_expiration_minutes=60 * 24,
)

app = App(
    # これは OAuth フローでは使いません、Slack からのイベントリクエストの検証に使います
    signing_secret=os.environ["SLACK_SIGNING_SECRET"],
    # OAuth 関連の設定をここで渡します
    oauth_settings=oauth_settings
)

# これはこのアプリの bot user をメンションしたときのイベントに対して応答するリスナーです
@app.event("app_mention")
def handle_app_mention_events(event, client, context, say):
    if context.user_token is not None:
        # インストールしたユーザー自身からのメンションだったとき
        client.chat_postMessage(
            token=context.user_token,
            channel=context.channel_id,
            text=f"<@{event['user']}> のユーザートークンをお預かりしているので、こんなことができます :nerd_face:",
        )
    else:
        say(f"<@{event['user']}> こんにちは！")

if __name__ == "__main__":
    # アプリを http://localhost:3000/ で起動します
    app.start(port=int(os.environ.get("PORT", 3000)))

    # このアプリは 3 つの URL をサーブします
    # - http://localhost:3000/slack/install
    # - http://localhost:3000/slack/oauth_redirect
    # - http://localhost:3000/slack/events

```

まずは、そのままの状態で動作させてみましょう。この状態で以下の環境変数を設定します。

**Settings** > **Basic Information** のページに **App Credentials** というセクションがありますので、
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/0f30d807-602e-2937-83d8-44516a1c3516.png" width=500 />
そこから **Client ID**、**Client Secret**、**Signing Secret** の値を、それぞれ環境変数 `SLACK_CLIENT_ID`、`SLACK_CLIENT_SECRET`、`SLACK_SIGNING_SECRET` に設定します。

```bash
# OAuth フローのために必要
export SLACK_CLIENT_ID=1234567890.1234567890123
export SLACK_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXX
# Slack からのリクエストか検証するために必要
export SLACK_SIGNING_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXX
```

そして、アプリを以下のコマンドで起動してみてください。

```bash
python app.py
```

以下のようなログがコンソールに出力されていれば、とりあえずは OK です！

```
INFO:slack_bolt.App:⚡️ Bolt app is running! (development server)
```

#### ngrok で公開された URL を用意して設定に反映

アプリ起動の最後のステップとして公開された URL を準備するために [ngrok](https://ngrok.com/) というツールを使います。使ったことがない方は https://ngrok.com/ でダウンロードして設定してみてください。

ngrok を使えるようになったら、以下のコマンドを実行してください。これによってインターネットに公開された URL に来たリクエストを `http://localhost:3000/` で起動している上の Python アプリにフォーワードすることができるようになります。

```bash
ngrok http 3000
```

ngrok の有料プランのアカウントをお持ちの場合は

```bash
ngrok http 3000 --subdomain my-token-rotation-app
```

のように固定されたサブドメインを使うことが可能ですが、無料利用の場合は毎回ランダムに決まります。いずれにせよ、ngrok を起動して表示された `https://{あなたのサブドメイン}.ngrok.io` のドメイン名で App Manifest に埋め込まれている `TOBEUPDATED.ngrok.io` を差し替えてください。

YAML を直接編集するモードの場合は、以下のように Edit Manifest ボタンから編集モードに移動して
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/1c310a66-16ec-ab8c-0f08-b6a697b23651.png" width=500 />
`oauth_config.redirect_urls[0]` と `settings.event_subscriptions.request_url` の二箇所を編集します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/c70127df-a06c-6d54-c69b-23e17ccac0e8.png" width=500 />

`settings.event_subscriptions.request_url` の方は以下のようなワーニングメッセージが出てくるかと思います。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/28538df5-ab13-a57d-4bcf-97f8e3f4a03d.png" width=500 />
`python app.py` でアプリが起動していて、かつ ngrok も手元で動いている前提で Click here to verify というリンクを押すと、検証リクエストが送信されます。疎通ができたら設定完了です。

なお、この YAML 設定のエディターのやり方はちょっと慣れない・やりづらいという場合、左の方に「Revert to the old design」というリンクがありますので、ここから旧来の画面に戻すこともできます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/0c2bd330-0914-2b97-bd4e-4ae9df1f37d1.png" width=500 />
旧画面から設定する場合は、それぞれ **Features** > **OAuth & Permissions** > **Redirect URLs** と **Features** > **Event Subscriptions** > **Request URL** を適切に設定してください。その際、URL の末尾が OAuth の Redirect URL は `/slack/oauth_redirect` で Event Subscriptions の Request URL は `/slack/events` であることに注意してください。

#### Slack アプリのインストールを実行

ここまでできたら、ブラウザーを開いて `https://{あなたのサブドメイン}.ngrok.io/slack/install` にアクセスしてみてください。

以下のようなシンプルなボタンだけの画面が表示されれば OK です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/ac5c6c37-2320-4d2b-6067-cbfa89a4ad5d.png" width=300 />
「Add to Slack」ボタンからインストールを実行していきます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/ec938cc8-023e-a374-f3d7-96c98365bdb7.png" width=400 />
「Allow」ボタンをクリックして、以下のような画面に遷移すれば成功です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/5d93d453-856e-d70d-c345-52ae6b2bffb5.png" width=500 />
これでブラウザを使ったインストール作業は完了です。

では、実際インストールによってどのようなトークンを得られたかを見てみましょう。このサンプルアプリでは、インストール結果をローカルファイルに保存するようになっていますので、以下のようなファイルがつくられているはずです。

```bash
$ tree data
data
├── installations
│   └── none-T12345678
│       ├── bot-1637909589.684739
│       ├── bot-latest
│       ├── installer-1637909589.684739
│       ├── installer-U12345678-1637909589.684739
│       ├── installer-U12345678-latest
│       └── installer-latest
└── states
```

これらのファイルについて軽く説明しておきます。`none-T12345678` はワークスペースの階層（`none` のところは Enterprise Grid の場合に enterprise_id が入ります）で、その下のファイルは installer は一度のインストールに含まれる bot やそのユーザー固有の設定、Incoming Webhooks など全てが含まれるファイルで bot は bot に関するものに絞ったものです。履歴の保持がデフォルトでオンになっているので `-latest` とタイムスタンプごとのファイルがそれぞれ同じ内容で保存しされています。もう一度インストールすると `-latest` の方は上書きとなります。


インストール情報の中身を見てみてましょう。 `cat data/installations/none-T12345678/installer-latest | jq` のようにして表示してみてください。ここでの説明に関係ない null の値は削っていますが、以下のようなものが表示されるはずです。

```json
{
  "app_id": "A1234567890",
  "team_id": "T1234567890",
  "team_name": "Acme Corp",
  "bot_token": "xoxe.xoxb-1-xxx",
  "bot_id": "B1234567890",
  "bot_user_id": "U1234567890",
  "bot_scopes": [
    "app_mentions:read",
    "chat:write"
  ],
  "bot_refresh_token": "xoxe-1-xxx",
  "bot_token_expires_at": 1637952233,
  "user_id": "U2222222222",
  "user_token": "xoxe.xoxp-1-xxx",
  "user_scopes": [
    "chat:write"
  ],
  "user_refresh_token": "xoxe-1-xxx",
  "user_token_expires_at": 1637952232,
  "token_type": "bot",
  "installed_at": 1637909589.684739
}
```

以下のテーブルは、トークンローテーションにおいて知っておくべき項目について簡単に解説しています。

|項目|説明|
|---|---|
|bot_token|bot のアクセストークンです。一定時間が経過すると（デフォルトでは 12 時間）期限切れになります|
|bot_refresh_token|bot のアクセストークンを更新するための refresh token です|
|bot_token_expires_at|token が発行されたときの Unix time に `oauth.v2.access` API から返された expires_in (秒) を加算したものを保持しています|
|user_token|アプリのインストールを実行したユーザー個人のアクセストークンです。一定時間が経過すると（デフォルトでは 12 時間）期限切れになります|
|user_refresh_token|ユーザー個人のアクセストークンを更新するための refresh token です|
|user_token_expires_at|token が発行されたときの Unix time に `oauth.v2.access` API から返された expires_in (秒) を加算したものを保持しています|

なお、コマンドラインで現在の Unix time （秒）を手軽に知りたい場合、以下のコマンドで値を取得することができます。

```bash
$ python3 -c 'import time; print(int(time.time()))'
1637916854
```

#### トークンローテーションの様子を確認

それでは、このアプリをインストールした Slack ワークスペースの画面を開いてください。テスト用のチャンネルにこのアプリの bot user を招待してください。`@token-rotation-test-app` をメンションしたら、「招待しますか？」と聞かれますので、そのまま招待してあげてください。

招待したら、そのボットユーザーを再度メンションしてみてください。すると、以下のように自分自身から返信が来るはずです。これは先程発行したトークンのうち、ユーザートークン側を使っているためです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/6803c822-5234-1e30-9cc7-e54fb49d57c1.gif" width=400 />
コードを以下のようにシンプルなものにして起動し直してみてください。

```python
# これはこのアプリの bot user をメンションしたときのイベントに対して応答するリスナーです
@app.event("app_mention")
def handle_app_mention_events(event, say):
    say(f"<@{event['user']}> こんにちは！")
```
今度は bot からの返事に変わります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/2915c4fc-13a7-14c5-a344-e1d3108a510d.gif" width=400 />

ともあれ、アプリは正常に動いているようです。ここまでのアプリのコンソールログを見てみてください。トークンはローテーションされているのでしょうか？

いえ、デフォルトでは、毎回ローテーションはしない挙動になっています。上の各項目を説明するテーブルでも書いた通り、発行されたトークンは 12 時間程度有効なので、それよりは少し短い時間の間、リフレッシュせずにそのまま使う挙動になっています。これはアプリの実行パフォーマンスにオーバーヘッドを与えないための配慮です。

しかし、（ローテーションの挙動を確認したいときなどのために）この設定をカスタマイズできるようになっています。最初に貼ったコードの以下の部分のコメントアウトを外して起動し直してみてください。

```python
    # 以下の設定は後ほど説明します
    token_rotation_expiration_minutes=60 * 24,
```

この設定にするとアクセストークンの期限切れ 24 時間前になったら、リフレッシュするようになります。つまり、常にトークンをリフレッシュするようになるということです。

この状態でまた app_mention のイベントを送信すると、先ほどのログとは違う点として、以下の二つの API コールが追加されていることに気づくはずです。

これらがやっていることは、このワークスペースの bot token とこのアクセスユーザーに紐づく user token 両方を oauth.v2.access API を使って refresh しています。その結果は Bolt の内部実装によって自動的に `InstallationStore` が管理するデータに反映されます。

```bash
DEBUG:slack_sdk.web.base_client:Sending a request - url: https://www.slack.com/api/oauth.v2.access, query_params: {}, body_params: {'grant_type': 'refresh_token', 'refresh_token': 'xoxe-1-xxx'}, files: {}, json_body: None, headers: {'Authorization': '(redacted)'}
DEBUG:slack_sdk.web.base_client:Received the following response - status: 200, headers: {}, body: {"ok":true,"access_token":"xoxe.xoxb-1-xxx","expires_in":43200,"refresh_token":"xoxe-1-xxx","token_type":"bot","app_id":"A1234567890","scope":"app_mentions:read,chat:write","bot_user_id":"U1234567890","team":{"id":"T1234567890","name":"Acme Corp"},"enterprise":null,"is_enterprise_install":false}

DEBUG:slack_sdk.web.base_client:Sending a request - url: https://www.slack.com/api/oauth.v2.access, query_params: {}, body_params: {'grant_type': 'refresh_token', 'refresh_token': 'xoxe-1-xxx'}, files: {}, json_body: None, headers: {'Authorization': '(redacted)'}
DEBUG:slack_sdk.web.base_client:Received the following response - status: 200, headers: {}, body: {"ok":true,"access_token":"xoxe.xoxp-1-xxx","expires_in":43200,"refresh_token":"xoxe-1-xxx","token_type":"user","app_id":"A1234567890","scope":"identify,chat:write","user_id":"U2222222222","team":{"id":"T1234567890","name":"Acme Corp"},"enterprise":null,"is_enterprise_install":false}
```

先程の `data/installations` が管理するデータを見ると refresh される度に `-latest` が更新され、また履歴データが増えていくことがわかります（もしこの挙動を変えたい場合は、履歴を全て取る実装をオフにする設定にしてください）。

実装に興味がある方は以下のコードを見てみてください。

* [この Bolt のコード](https://github.com/slackapi/bolt-python/blob/v1.10.0/slack_bolt/authorization/authorize.py)で `self.token_rotator` がやっていること
* [TokenRotator](https://github.com/slackapi/python-slack-sdk/blob/v3.12.0/slack_sdk/oauth/token_rotation/rotator.py) と[そのテストコード](https://github.com/slackapi/python-slack-sdk/blob/v3.12.0/tests/slack_sdk/oauth/token_rotation/test_token_rotator.py)

今回の例では、一度にデモするために bot token と user token を両方使っていますが、もちろん bot token だけ、user token だけを利用する場合にも `TokenRotator` はそのまま使用することができます。

また、実際に運用するアプリであればデータベースやより安全な場所にトークンを保持することになるかと思います。Python SDK では、組み込みでファイルに加えて SQLAlchemy、Amazon S3、SQLite3 に対応しています。これらのモジュールのソースコードは[こちら](https://github.com/slackapi/python-slack-sdk/tree/main/slack_sdk/oauth/installation_store)を参照してください。Django のアプリケーションでトークンローテーションしたい場合は、[こちらの Bolt for Python を使ったサンプル](https://github.com/slackapi/bolt-python/blob/main/examples/django/oauth_app/slack_datastores.py)を参考にしてみてください。

### Node.js でアプリを実装する

繰り返しを避けるために、上記の Python との差分のところのみ紹介しておきます。

Node.js の場合、プロジェクト自体は

```bash
npm init -y
npm install @slack/bolt
```

でつくります。Node.js の利用可能バージョンは、[こちらのバージョン指定](https://github.com/slackapi/bolt-js/blob/main/package.json)で確認してください。

そして `index.js` として以下のコードを保存します。

```javascript
const { LogLevel } = require("@slack/logger");
const { App, FileInstallationStore } = require("@slack/bolt");

const app = new App({
  // Basic Information のページから設定してください
  clientId: process.env.SLACK_CLIENT_ID,
  // Basic Information のページから設定してください
  clientSecret: process.env.SLACK_CLIENT_SECRET,
  // これは管理画面における Bot Scopes です
  scopes: ['commands', 'chat:write'],
  // これは管理画面における User Scopes です
  userScopes: ['chat:write'],
  // Slack ワークスペースへのインストール情報を管理する実装、ここではローカルファイルに保存します
  installationStore: new FileInstallationStore({
    baseDir: './data/installations',
    clientId: process.env.SLACK_CLIENT_ID,
  }),
  // これは OAuth フローの state パラメーターの値を生成する際に使われます
  stateSecret: 'my-state-secret',
  // これは OAuth フローでは使いません、Slack からのイベントリクエストの検証に使います
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  // ログレベルを変更しています、指定しない場合はより詳細なログを出力するために DEBUG
  logLevel: process.env.SLACK_LOG_LEVEL || LogLevel.DEBUG,
});

// これはこのアプリの bot user をメンションしたときのイベントに対して応答するリスナーです
app.event("app_mention", async ({ logger, event, say }) => {
  logger.debug("app_mention event payload:\n\n" + JSON.stringify(event, null, 2) + "\n");
  await say(`<@${event.user}> こんにちは！`);
});

(async () => {
  // アプリを http://localhost:3000/ で起動します
  await app.start(process.env.PORT || 3000);
  console.log("⚡️ Bolt app is running!");
})();

// このアプリは 3 つの URL をサーブします
// - http://localhost:3000/slack/install
// - http://localhost:3000/slack/oauth_redirect
// - http://localhost:3000/slack/events
```

上の Python と同様に環境変数を設定した上で

```bash
# OAuth フローのために必要
export SLACK_CLIENT_ID=1234567890.1234567890123
export SLACK_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXX
# Slack からのリクエストか検証するために必要
export SLACK_SIGNING_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXX
```

以下のコマンドでアプリを起動したら

```bash
npx node index.js
```

Python と同様に ngrok で公開 URL からリクエストがフォーワードされるようにします。インストールが完了すると、若干命名規則は異なるものの、同様にインストール情報が保存されます。

```bash
$ tree data
data
└── installations
    └── 3485157640.2764408644502
        └── T1234567890
            ├── app-1637925605512
            ├── app-latest
            ├── user-U1234567890-1637925605512
            └── user-U1234567890-latest

3 directories, 4 files
```

Node の SDK での rotation の実装は、[こちらのコード](https://github.com/slackapi/node-slack-sdk/blob/%40slack/oauth%402.3.0/packages/oauth/src/index.ts#L185-L234)を参考にしてみてください。

## Sign in with Slack のトークンローテーションを試してみる

[OpenID Connect 互換の Sign in with Slack](https://api.slack.com/authentication/sign-in-with-slack) のフローで発行されたトークンもトークンローテーションに対応しています。こちらについてもどのように処理すべきか、簡単に紹介します。

以下のような App Manifest で Slack アプリの設定を作ってください。

```yaml
_metadata:
  major_version: 1
  minor_version: 1
display_information:
  name: token-rotation-siws-test-app
features:
  bot_user:
    display_name: token-rotation-siws-test-app
oauth_config:
  redirect_urls:
    - https://TOBEUPDATED.ngrok.io/slack/oauth_redirect
  scopes:
    user:
      - openid
      - email
      - profile
    bot:
      - commands
settings:
  token_rotation_enabled: true
```

以下は、Sing in with Slack をハンドリングする Flask の Web アプリケーション例です。必要な依存ライブラリは以下の 2 つです。

```bash
pip install flask slack-sdk
```

大きな違いとして、リフレッシュするための API が [`openid.connect.token`](https://api.slack.com/methods/openid.connect.token) であること、[`auth.test`](https://api.slack.com/methods/auth.test) などのメソッドは使えず [`openid.connect.userInfo`](https://api.slack.com/methods/openid.connect.userInfo) だけを使える点が異なります。ですが、基本的には同じようなフローでトークンを管理するだけで OK です。

なお、`TokenRotator` は [Sign in with Slack (OpenID Connect)](https://api.slack.com/authentication/sign-in-with-slack) には対応していないので、以下のコード例のように直接 API を呼び出すコードを必要に応じて書いてください。

```python
# より詳細なログを出力するためにログレベルを DEBUG に変更します
import logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)

import json
import os

# 必要な設定をあらかじめ環境変数に設定しておきます
client_id = os.environ["SLACK_CLIENT_ID"]
client_secret = os.environ["SLACK_CLIENT_SECRET"]
redirect_uri = os.environ["SLACK_REDIRECT_URI"]
# 最低限必要なのは "openid" です
scopes = ["openid", "email", "profile"]

from slack_sdk.web import WebClient
from slack_sdk.oauth import OpenIDConnectAuthorizeUrlGenerator, RedirectUriPageRenderer
from slack_sdk.oauth.state_store import FileOAuthStateStore

state_store = FileOAuthStateStore(expiration_seconds=300)

# https://slack.com/openid/connect/authorize?... な URL を生成します
authorization_url_generator = OpenIDConnectAuthorizeUrlGenerator(
    client_id=client_id,
    scopes=scopes,
    redirect_uri=redirect_uri,
)

# Flask のアプリケーションとして実装していますが、他のフレームワークでももちろん OK です
from flask import Flask, request, make_response

app = Flask(__name__)
app.debug = True

# Sign in with Slack の画面を表示します
@app.route("/slack/install", methods=["GET"])
def oauth_start():
    state = state_store.issue()
    url = authorization_url_generator.generate(state=state)
    return (
        '<html><head><link rel="icon" href="data:,"></head><body>'
        f'<a href="{url}" style="align-items:center;color:#000;background-color:#fff;border:1px solid #ddd;border-radius:4px;display:inline-flex;font-family:Lato, sans-serif;font-size:16px;font-weight:600;height:48px;justify-content:center;text-decoration:none;width:256px"><svg xmlns="http://www.w3.org/2000/svg" style="height:20px;width:20px;margin-right:12px" viewBox="0 0 122.8 122.8"><path d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9zm6.5 0c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z" fill="#e01e5a"></path><path d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2zm0 6.5c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z" fill="#36c5f0"></path><path d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2zm-6.5 0c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z" fill="#2eb67d"></path><path d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9zm0-6.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z" fill="#ecb22e"></path></svg>Sign in with Slack</a>'
        "</body></html>"
    )

# デフォルトのエラー画面を表示するために使います
redirect_page_renderer = RedirectUriPageRenderer(
    install_path="/slack/install",
    redirect_uri_path="/slack/oauth_redirect",
)

# Slack の確認画面から遷移してきたときの URL です
@app.route("/slack/oauth_redirect", methods=["GET"])
def oauth_callback():
    # クエリストリングから code, state パラメーターを取得してチェックします
    if "code" in request.args:
        state = request.args["state"]
        if state_store.consume(state):
            code = request.args["code"]
            try:
                client = WebClient()
                # code パラメーターを使って access_token / refresh_token を取得します
                token_response = client.openid_connect_token(
                    client_id=client_id,
                    client_secret=client_secret,
                    code=code,
                )

                # refresh token を使ってリフレッシュを実施します
                refreshed_token_response = client.openid_connect_token(
                    client_id=client_id,
                    client_secret=client_secret,
                    token=token_response.get("access_token"),
                    refresh_token=token_response.get("refresh_token"),
                    grant_type="refresh_token",
                )
                # リフレッシュされた token が問題ないか openid.connect.userInfo API を呼び出してテスト
                refreshed_user_token = refreshed_token_response.get("access_token")
                user_info_response = client.openid_connect_userInfo(token=refreshed_user_token)
                logger.info(f"openid.connect.userInfo response: {user_info_response}")

                # 実際にはここで access_token / refresh_token を何らかの形で保存することになります

                # あくまでデモとして結果を Web ページに表示しています
                return f"""
            <html>
            <head>
            <style>
            body h2 {{
              padding: 10px 15px;
              font-family: verdana;
              text-align: center;
            }}
            </style>
            </head>
            <body>
            <h2>openid.connect.userInfo response</h2>
            <pre>{json.dumps(user_info_response.data, indent=2)}</pre>
            </body>
            </html>
            """

            except Exception:
                logger.exception("Failed to perform openid.connect.token API call")
                return redirect_page_renderer.render_failure_page(
                    "Failed to perform openid.connect.token API call"
                )
        else:
            return redirect_page_renderer.render_failure_page(
                "The state value is already expired"
            )

    error = request.args["error"] if "error" in request.args else ""
    return make_response(
        f"Something is wrong with the installation (error: {error})", 400
    )


if __name__ == "__main__":
    # export SLACK_CLIENT_ID=111.222
    # export SLACK_CLIENT_SECRET=xxx
    # export FLASK_ENV=development
    # export SLACK_REDIRECT_URI=https://{your-domain}/slack/oauth_redirect
    # python3 app.py

    app.run("localhost", 3000)

    # このアプリは 2 つの URL をサーブします
    # - http://localhost:3000/slack/install
    # - http://localhost:3000/slack/oauth_redirect
```

Node.js の実装については[こちらのコード](https://github.com/slackapi/node-slack-sdk/tree/main/examples/openid-connect)などを同様に参考にしてみてください。

## その他の FAQ

### Java のサンプルもありますか？

はい、あります！以下のサンプルコードを確認してみてください。

* [通常のアプリのトークンローテーション](https://github.com/slackapi/java-slack-sdk/blob/v1.14.0/bolt-servlet/src/test/java/samples/OAuthSample.java)
* [Sign in with Slack のトークンローテーション](https://github.com/slackapi/java-slack-sdk/blob/v1.14.0/bolt-servlet/src/test/java/samples/OpenIDConnectSample.java#L35-L55)

### Bolt を使っていないアプリやスクリプトではどうすればよいですか？

なお、Bolt を使っていないアプリやスクリプト内での利用の場合は、この `TokenRotator` だけをアプリケーション内で利用することも可能です。

その場合は[何らかの `InstallationStore` の実装](https://github.com/slackapi/python-slack-sdk/blob/v3.12.0/slack_sdk/oauth/installation_store/installation_store.py)で `find_bot()` / `find_installation()` でトークンを取得し、それを使う前に [`TokenRotator`](https://github.com/slackapi/python-slack-sdk/blob/v3.12.0/slack_sdk/oauth/token_rotation/rotator.py) を呼び出して `InstallationStore#save()` でリフレッシュしたものを保存しておくという流れになります。

### 既存のアプリを切り替えるにはどうすればよいですか？

[英語の方のドキュメント](https://api.slack.com/authentication/rotation#migration)に詳細がありますが、refresh token とペアになっていない access token を [`oauth.v2.exchange` という API](https://api.slack.com/methods/oauth.v2.exchange) を使って切り替えるという流れになります。この API に client_id、client_secret、token をパラメーターとして渡すと access token + refresh token がレスポンスで返ってきます。

上の Python のコード例で言うと `InstallationStore` から存在する access token を全部持ってきて、一つずつ切り替えて、それを保存するということになります。

トークンローテーションに opt-in したらすぐに一気にやってしまえるよう、あらかじめスクリプトを用意しておくとよいでしょう。


## 最後に

トークンローテーションは Slack の OAuth フローを実装しないと試せない機能なので、実際に検証したことがない方も多かったのではないかと思います。

こちらの記事の手順通りにやれば簡単に試せるかと思いますので、ぜひやってみてください :wave: