作ったSlackアプリを他のワークスペースにもインストールできるようにする

# 概要

[Slackアプリを作っていました](https://qiita.com/k-akie/items/796f034d6f4b7312894e)が、認証情報を直接設定していました。
他のワークスペースにもインストールするために、認証処理を追加しました。
※Slack App ディレクトリへの公開の手順は含みません。

## 環境
- 言語: Java11
    - Gradle
    - slack-api-client
- 環境:
    - Google Cloud Functions (認証処理)
    - Google Cloud Storage (認証情報の保存先)

# やったこと

大まかにはこのステップで、インストールされ、利用できるようになります。
ステップ3、4の実装と、アプリ設定を行いました。
1. ユーザーが、Slackでアプリのインストールを許可する
2. Slackから、アプリに認証リクエストが送信される
3. アプリで、Slack APIを呼びアクセストークンを取得し、保存する
4. アプリで、3で保存したアクセストークンを使ってSlack APIを利用する

## ステップ3の実装(認証処理)

[Installing with OAuth](https://api.slack.com/authentication/oauth-v2#exchanging) の部分です。
Cloud FunctionsにHTTPトリガーで関数を追加しました。

OAuthConfigでは、以下の値を設定しています。
値はすべてCloud Functionsの環境変数から取得しています([前回と同じやりかた](https://qiita.com/k-akie/items/796f034d6f4b7312894e#token%E3%82%92%E3%82%BD%E3%83%BC%E3%82%B9%E3%81%AB%E6%9B%B8%E3%81%8D%E3%81%9F%E3%81%8F%E3%81%AA%E3%81%84))。

- Google Cloud Storageにアクセスするための情報(プロジェクト名、バケット名)
- Slackアプリの資格情報(Settings > Basic Information > App Credentialsに表示されるもの)
    - Client ID
    - Client Secret
- CSRF対策としてのstate値(今回は固定値にしていますが、本当は毎回生成すべきです)

```OAuthFunction.java
import com.google.cloud.functions.HttpFunction;
import com.google.cloud.functions.HttpRequest;
import com.google.cloud.functions.HttpResponse;
import com.google.gson.JsonObject;
import com.slack.api.Slack;
import com.slack.api.methods.MethodsClient;
import com.slack.api.methods.SlackApiException;
import com.slack.api.methods.response.oauth.OAuthV2AccessResponse;
import java.io.IOException;
import java.util.logging.Logger;

public class OAuthFunction implements HttpFunction {
  private Logger logger = Logger.getLogger(this.getClass().getName());
  private OAuthConfig config = new OAuthConfig();

  @Override
  public void service(HttpRequest request, HttpResponse response) {
    // パラメータの検証
    String state = request.getFirstQueryParameter("state").get();
    if (!state.equals(config.state())) {
      logger.info(String.format("state don't match: ", state));
      response.setStatusCode(500);
      return;
    }

    // 認証処理
    String code = request.getFirstQueryParameter("code").get();
    if (oauthAccess(code)) {
      logger.info("success oauth!!");
      return;
    }

    logger.info("failed oauth...");
    response.setStatusCode(500);
  }

  private boolean oauthAccess(String code) {
    // Slack API を使ってアクセストークンを取得
    MethodsClient client = Slack.getInstance().methods();
    try {
      OAuthV2AccessResponse response =
          client.oauthV2Access(
              builder ->
                  builder
                      .code(code)
                      .clientId(config.clientId())
                      .clientSecret(config.clientSecret()));
      logger.info(response.toString());
      if (!response.isOk()) {
        logger.info(
            String.format("Slack API [oauth.v2.access] response is NG: %s", response.getError()));
        return false;
      }

      // 保存したい情報をまとめる(今回は検証も兼ねて複数項目を取ったのでJSON形式にした)
      JsonObject json = new JsonObject();
      json.addProperty("scope", response.getScope());
      json.addProperty("access_token", response.getAccessToken());
      json.addProperty("token_type", response.getTokenType());
      json.addProperty("authed_user_id", response.getAuthedUser().getId());
      json.addProperty("team_name", response.getTeam().getName());
      json.addProperty("bot_user_id", response.getBotUserId());

      // Cloud Storageに保存する
      // チーム(ワークスペース)ごとにアクセストークンが変わるので、チームIDをファイル名に使う。
      StorageWriter storageWriter = new StorageWriter(config.projectId(), config.bucketName());
      storageWriter.createBlobData(response.getTeam().getId(), json.toString(), "text/json");

    } catch (IOException e) {
      logger.warning(e.getMessage());
    } catch (SlackApiException e) {
      logger.warning(e.getMessage());
    }
    return true;
  }
}
```

```StorageWriter.java
import com.google.cloud.storage.*;
import java.nio.charset.StandardCharsets;

class StorageWriter {
  private String projectId;
  private String bucketName;

  StorageWriter(String projectId, String bucketName) {
    this.projectId = projectId;
    this.bucketName = bucketName;
  }

  void createBlobData(String blobName, String data, String contentType) {
    Storage storage = StorageOptions.newBuilder().setProjectId(projectId).build().getService();
    BlobId blobId = BlobId.of(bucketName, "slack/" + blobName);
    BlobInfo blobInfo = BlobInfo.newBuilder(blobId).setContentType(contentType).build();
    storage.create(blobInfo, data.getBytes(StandardCharsets.UTF_8));
  }
}
```

```build.gradle
dependencies {
	// json
	implementation('com.google.code.gson:gson:2.8.6')
	// Cloud Functions
	implementation('com.google.cloud.functions:functions-framework-api:1.0.2')
	// Stroge
	implementation('com.google.cloud:google-cloud-storage');
	// Slack API client
	implementation("com.slack.api:slack-api-client:1.2.1")
}

// Cloud BuildからCloud Functionsにデプロイすると文字化けするため明示的に指定
tasks.withType(JavaCompile) {
	options.encoding = 'UTF-8'
}
```

## ステップ4の実装(保存したアクセストークンでSlack APIを利用する)

作成済みのCloud Functionsのソースを一部変更しました。
アクセストークンを環境変数から取得していた部分を、Cloud Storageから読み込む形にしています。

```StorageReader.java
import com.google.cloud.storage.*;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import java.io.UnsupportedEncodingException;

public class StorageReader {
  private String projectId;
  private String bucketName;

  public StorageReader(String projectId, String bucketName) {
    this.projectId = projectId;
    this.bucketName = bucketName;
  }

  public String getSlackOAuthToken(String teamId) {
    Storage storage = StorageOptions.newBuilder().setProjectId(projectId).build().getService();
    BlobId blobId = BlobId.of(bucketName, "slack/" + teamId);
    Blob blob = storage.get(blobId);
    try {
      // JSON形式で保存したので欲しい項目値だけを返す(必要な値だけを保存した方がよいかもしれない)
      String content = new String(blob.getContent(), "UTF-8");
      Gson gson = new Gson();
      JsonObject json = gson.fromJson(content, JsonObject.class);
      return json.get("access_token").getAsString();
    } catch (UnsupportedEncodingException e) {
      e.printStackTrace();
    }
    return "";
  }
}

```

## slack apps での設定

**Features > OAuth & Permissions**
まずRedirect URLsを設定します。
設定するURLは、ステップ3の実装で作った認証処理のFunctionsのURLです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628295/bc2406e2-2646-7a88-2366-7392c77d77ba.png)

**Settings > Manage Distribution**
次に公開準備をします。
アプリの権限やリダイレクトURLの設定が済んでいれば、共有用の各種情報が表示されているはずです。
一番下にある`Activate Public Distribution`ボタンを押すことで、他のワークスペースにもインストールできるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628295/85c11742-c2d0-6a89-3fa0-cea4e34bf696.png)

## インストールしてみる

**Settings > Manage Distribution**
ここに表示されているボタンもしくはURLには、stateが設定されません。
もし認証用処理で使っていれば追加してから、アクセスする必要があります。

Slackへの申請まで行っていないため未認証の警告文が表示されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628295/575fafa2-9909-867e-4946-edda6d25fe05.png)

今回、処理後の画面を作っていないため真っ白の表示になりますが、レスポンスコードが200で返ってきたので処理が終わったことが分かります。
実際、ワークスペースでアプリを追加できました！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/628295/7d31cf52-6e2d-4d0e-6b76-0d2091b40e59.png)

# つまづき

## oauth_authorization_url_mismatch
使っているOAuth APIのバージョンが違うと発生します。
`oauthV2Access`を使うべきだったのに`oauthAccess`を使っていました。
新しくアプリを作った場合は、V2になります。

```
OAuthAccessResponse(ok=false, warning=null, error=oauth_authorization_url_mismatch, needed=null, provided=null, tokenType=null, accessToken=null, scope=null, enterpriseId=null, teamName=null, teamId=null, userId=null, incomingWebhook=null, bot=null, authorizingUser=null, installerUser=null, scopes=null)
```

```Java
    MethodsClient client = Slack.getInstance().methods();

    OAuthV2AccessResponse response =
        client.oauthV2Access(
            builder ->
                builder
                    .code(code)
                    .clientId(config.clientId())
                    .clientSecret(config.clientSecret()));
```


## アプリの設定から受信イベントを変えたのに反映されない

すでにアプリをインストールしたあとで、新たな権限が必要なイベントを受信対象に加えると、追加したイベントが受信できませんでした。

アプリ側で設定している権限と、実際にワークスペース側で承認している権限とに差があるためだと思います。
アプリを再インストールすると受信するようになりました。

再インストールは、アプリを削除する必要はなく、インストールURLからもう一度インストールを許可します。
このとき表示される権限が、新しいものに変わっていればOKです。

# 参考
お二方とも、日本語で詳しく書いあり、とても助かりました。ありがとうございます！
[初めてSlack appをつくって審査通すところまでやった知見を晒す - Qiita](https://qiita.com/munky69rock/items/3342c7b7c642fd2c68f3)
[Slack Appに挑戦(4) - OAuthとトークン](https://qiita.com/kanaxx/items/b69efcf196bbfbc2993d)

## 公式ドキュメント
[Understanding OAuth scopes for Bots | Slack](https://api.slack.com/tutorials/understanding-oauth-scopes-bot)

[Installing with OAuth | Slack](https://api.slack.com/authentication/oauth-v2)
[Slack Button | Slack](https://api.slack.com/docs/slack-button)
[oauth.v2.access method | Slack](https://api.slack.com/methods/oauth.v2.access)

# 感想
フローが理解できなくてすごく時間がかかりました。
日本語の公式ドキュメントが見当たらなかったのも、意味を取りかねて難しいところでした。

- まだできていないこと
  - stateを固定値にしたが、本当はCSRF対策で表示する度に生成するようにする
  - インストール処理後の画面の作成
  - Slack App ディレクトリへの公開