python-social-authでSlackのOAuth2.0と仲良くする

# はじめに
[mixiグループ Advent Calendar 2017](https://qiita.com/advent-calendar/2017/mixi) の7日目の記事です。
[チケットキャンプ](https://ticketcamp.net/)を運営している[株式会社フンザ](https://hunza.jp)でサーバサイドの開発を担当している@cgetcです。

弊社では業務でSlackを利用しており、煩雑なコミュニケーションを改善するために、SlackBotの開発を試みました。
しかし2点ほど、OAuth2.0関連で嵌まることがあったので、その事象と解決法をまとめました。
弊社はPython/Djangoで開発することが多いので、PythonでメジャーなOAuth2.0のライブラリであるpython-social-auth/social-app-djangoのサンプルを載せています。

# Q. SlackのOAuth認証では認証時のScopeが指定できない

SlackのOAuth認証では認証する際にユーザ情報を取得するスコープのみが指定でき、投稿に必要なパーミッションは一度では取得できません。
なので、SlackのOAuth認証をした後にもう一度、ユーザにはスコープを変更してリンクを踏んでもらう必要があります。
([Using OAuth 2.0　Handling Multiple Authorizations](https://api.slack.com/docs/oauth)を参照ください。)

## A. OAuth認証後にスコープを変更した`Authorization　Request`のURLにリダイレクトさせる
`Authorization　Grant`　のダイアログが2度表示されてしまいますが、Slackの仕様上、これがベストプラクティスだと思います。

### パーミッションを許可してもらうパイプラインを実装
必要なパーミッションがあるかを確認して、ない場合にはリダイレクトさせるだけです。
リダイレクト後にも再度パイプラインを通りますが、そのときにはすでにパーミッションが付与されているので、何もせず次のパイプラインが実行されます。

``` sample.pipline.py
from django.shortcuts import redirect


def extra_scope(backend, details, response, *args, **kwargs):
    if 'chat:write:user' not in response.get('scope'):
        backend.get_scope = lambda: ['chat:write:user']
        auth_url = backend.auth_url()
        return redirect(auth_url)

```

### パイプラインを設定に追加
パーミッションがあるユーザと無いユーザが混在するとややこしくなるので、なるべく始めの方に定義しています。

```settings.py

SOCIAL_AUTH_PIPELINE = (
    # Get the information we can about the user and return it in a simple
    # format to create the user instance later. On some cases the details are
    # already part of the auth response from the provider, but sometimes this
    # could hit a provider API.
    'social_core.pipeline.social_auth.social_details',

    # Get the social uid from whichever service we're authing thru. The uid is
    # the unique identifier of the given user in the provider.
    'social_core.pipeline.social_auth.social_uid',

    """ 省略 """

    # Update the user record with any changed info from the auth service.
    'social_core.pipeline.user.user_details',
)
```

# Q. Add To SlackのOAuth認証でstateパラメータがない

python-socail-authのSlackバックエンドは`state`パラメータが必須になっています。しかし、Add To　SlackのAuthorization Request にはstateパラメータが含まないために　Authorization Responseにも含まれていません。

## A. Slackバックエンドを継承したバックエンドを作成する
`STATE_PARAMETER = False`　にして対応します。
（stateパラメータはOAuth 2.0のCSRF対策として重要なパラメータなので、無効にするのは避けるべきです。今回はこのような対応をしましたが、state パラメータを含む方法があれば、ご教授ください。Add To SlackがボタンなどでURLを埋め込んで利用される前提なので、仕様上stateパラメータをAuthorization Requestに含めるのが難しいため、このような仕様だと思われます。）

これだけでも動きますが、Add To Slackの場合に必要な情報は、ユーザ情報ではなく、チーム情報の方が多いと思います。ですので、チーム情報を取得・格納するように `get_user_details` `user_data`を実装します。

```sample/social/backends/slack.py
from social_core.backends.slack import SlackOAuth2


class AddToSlackOAuth2(SlackOAuth2):
    STATE_PARAMETER = False

    def get_user_details(self, response):
        """Return user details from Slack team"""
        # Build the username with the team $username@$team_url
        # Necessary to get unique names for all of slack
        team = response['team']

        return {
            'username': team['name'],
            'email': '{domain}@{email_domain}'.format(**team),
            'fullname': team['domain'],
        }

    def user_data(self, access_token, *args, **kwargs):
        """Loads user data from service"""
        response = self.get_json('https://slack.com/api/team.info', params={'token': access_token})
        if not response.get('id', None):
            response['id'] = response['team']['id']

        return response
```

`AUTHENTICATION_BACKENDS` を上記のクラスに差し替えます。

```settings.py
AUTHENTICATION_BACKENDS = (
    'sample.social.backends.slack.AddToSlackOAuth2',
)
```

認証が完了すると`User`にチーム情報が格納されます。
アクセストークンを取得するには、python-socail-authと変わりません。

```py
from social_django.models import UserSocialAuth


def get_access_token(team_id):
    return UserSocialAuth.get_social_auth('slack', team_id).extra_data['access_token']

```

`team_id`はWebhookのPayloadなどから取得できます。


# さいごに
Djangoでの例のみを示しましたが、他のWEBフレームワークでも応用できますし、SlackのOAuth2.0の実装による問題なので、言語問わず参考になるかと思います。
SlackBotの開発の助けになったら幸いです。