OAuth2 に対応した API の OmniAuth Strategy を書いてみる

初めて書いたのでメモ程度ですが記録を残しておきます。

## はじめに

### どの API の Strategy を書いた？

Timely というタイムトラッキング系のアプリです。

https://timelyapp.com/

会社だと Toggl や自動で記録する RescueTime などを使ってるというのを聞きましたが、細かい時間管理というよりは、一日のどれぐらいの割合をレビューとかタスクに使ってるのかというのを知りたかったので、単純にタスクの見積もりと実績を計測するだけという Timely にしました。

> 見積もりを入れるという行為が、タスクを小さくしたり、レビューを一定時間で打ち切る動機にもなりますし、集中スイッチ的に働いて良いです。が、まあこの手のツールはいくらでもあるので、これが特段素晴らしいと push するつもりもないですね :sweat_smile: 
> 個人的には、ノルウェー産（？）というので少しポイント上がりました、心からどうでもいいけど。

### API はどんなもの？

https://dev.timelyapp.com/ 

上記ページが全てのようです。 __OAuth2 Authentication__ となっています。

## 書いてみた

書いたものはこちら https://github.com/dany1468/omniauth-timely

### omniauth-oauth2 を使う

OAuth2 対応となっていたので、`omniauth-oauth2` を利用しました。

https://github.com/intridea/omniauth-oauth2

OAuth2 を用いた Strategy を簡単に書けるようにしてくれています。

以下の記事がとても詳しいので合わせて参照すると良さそうです。
[OmniAuth OAuth2 を使って OAuth2 のストラテジーを作るときに知っていると幸せになれるかもしれないこと](http://qiita.com/tyamagu2/items/d242b6dd4a20b657bb39)

README にある通りですが、もっともそのまま使える状態だと以下のような箇所だけ書けばよいようです。

```rb
require 'omniauth-oauth2'

module OmniAuth
  module Strategies
    class SomeSite < OmniAuth::Strategies::OAuth2
      # NOTE strategy の名前になるので指定する。
      option :name, 'some_site'

      # NOTE user_id など unique な id。
      uid { raw_info['id'] }

      info do
        {
          name: raw_info['name'],
          email: raw_info['email']
        }
      end

      # NOTE skip_info の判定は有効にしておいて良さそう
      extra do
        skip_info? ? {} : {raw_info: raw_info}
      end

      def raw_info
        @raw_info ||= access_token.get('/me').parsed
      end
    end
  end
end
```

以下のようなメジャーなサービスの Strategy もこのライブラリを利用していますね。（カスタマイズはされていますが）

- https://github.com/mkdynamic/omniauth-facebook
- https://github.com/intridea/omniauth-github

ここからは Timely の Strategy を作っていくにあたり、つまずいた点を中心に紹介します。

## Strategy 作成時につまずいた点

### Timely は callback_url が登録されたものと完全一致しか許可しない

omniauth はデフォルトだと callback_url を以下のように生成します。

https://github.com/intridea/omniauth/blob/1cc1cf4b2821a7d2a4a376a5ca93c61b6bd8b5f1/lib/omniauth/strategy.rb#L431-L433

```rb
def callback_url
  full_host + script_name + callback_path + query_string
end
```

しかし Timely は `query_string` を含んだ URL を callback_url に指定すると `invalid_credentials` となってしまいます。どうやら、アプリケーション登録時に設定した URL と全く同じでないといけないようです。

よって、 `callback_url` をオーバーライドし、`query_string` を無くした形にしました。 [コード](https://github.com/dany1468/omniauth-timely/blob/master/lib/omniauth/strategies/timely.rb#L30-L32)

### raw_info の取得後のパースでエラーが出る

Timely は `/accounts` にアクセスすることでユーザー情報を取得できるはずなのですが、 `access_token.get('/1.0/accounts').parsed` とするだけではエラーになってしまいました。

#### まず、access_token は何者か？

`omniauth-oauth2` に出てくる `access_token` は `oauth2` ライブラリのクラスを指しています。

access_token クラス
https://github.com/intridea/oauth2/blob/master/lib/oauth2/access_token.rb

access_token 内部で使われる client と access_token の生成箇所 
https://github.com/intridea/oauth2/blob/master/lib/oauth2/client.rb#L128-L142

#### access_token.get はどうなっているか

以下の 2 箇所を中心に見ていきます。
- https://github.com/intridea/oauth2/blob/master/lib/oauth2/access_token.rb#L107-L117
- https://github.com/intridea/oauth2/blob/master/lib/oauth2/client.rb#L88-L120

client クラスの `#request` は全てのリクエストで使われるため get に限定されてものではありませんが、option や Response をどう処理しているかが分かります。

##### 取得したトークンをどう用いて API にアクセスするか

以下の access_token の `#initialize` で取得したトークンをどういう形式で渡すかを設定できるのが分かります。

- https://github.com/intridea/oauth2/blob/master/lib/oauth2/access_token.rb#L51-L53
- https://github.com/intridea/oauth2/blob/master/lib/oauth2/access_token.rb#L148-L150

```rb
def initialize(client, token, opts = {})
  # 略
  @options = {:mode          => opts.delete(:mode) || :header,
              :header_format => opts.delete(:header_format) || 'Bearer %s',
              :param_name    => opts.delete(:param_name) || 'access_token'}
  # 略
end

def headers
  {'Authorization' => options[:header_format] % token}
end
```

デフォルトは header に `"Authorization: Bearer #{access_token}"` のような形で渡されることになります。もし、Strategy を作りたい API がこれ以外の渡し方を必要とするならば、変更する必要がありますね。

#### パースエラーの対処方法

`access_token.get` には option を渡せます。上述した client クラスに `parse` オプションがあるのがわかると思います。

これは、返却された値をパースする方式を指定するのですが、デフォルトは `automatic` となっており、Content-Type によって自動で選択されるようです。

今回は直接 `parse: :json` とオプションを指定することで解決できました。

### refresh_token はあるが、expires が取得できない

API ドキュメントの通りですが、Timely API はなぜか `refresh_token` は取得できるのですが、`expires` に関する情報が取れません。

`omniauth-oauth2` は以下のように `expires` 系が存在しないと `refresh_token` を `credential` 情報として認識してくれません。

- https://github.com/intridea/omniauth-oauth2/blob/master/lib/omniauth/strategies/oauth2.rb#L39-L45

```rb
credentials do
  hash = {"token" => access_token.token}
  # expires が無いと refresh_token があってもダメ
  hash.merge!("refresh_token" => access_token.refresh_token) if access_token.expires? && access_token.refresh_token
  hash.merge!("expires_at" => access_token.expires_at) if access_token.expires?
  hash.merge!("expires" => access_token.expires?)
  hash
end
```

もちろん、今回も Strategy 側でこの Credential を上書きすれば良かったのですが、`expires` が無い以上は使いみちの無い `refresh_token` を受け取ったところで保存することも無さそうなので取得しない事にしました。

## その他

### テストを書く際の Strategy の initialize について

Strategy のテストコードを書くのに、この Strategy はどういう風にインスタンス化されるのか追ってみると以下のような箇所にたどり着きました。両方とも `omniauth` のコードです。

- https://github.com/intridea/omniauth/blob/1cc1cf4b2821a7d2a4a376a5ca93c61b6bd8b5f1/lib/omniauth/strategy.rb#L129-L146
- https://github.com/intridea/omniauth/blob/1cc1cf4b2821a7d2a4a376a5ca93c61b6bd8b5f1/lib/omniauth/builder.rb

`Rack::Builder` 継承のクラスがどのように振る舞うのかきちんと理解できていませんが、Strategy は initialize 時の第一引数が Rack application、第二引数以降で、Strategy の引数、オプションと続くということが分かったことでテストが書けました。

```rb
# Initializes the strategy by passing in the Rack endpoint,
# the unique URL segment name for this strategy, and any
# additional arguments. An `options` hash is automatically
# created from the last argument if it is a hash.
#
# @param app [Rack application] The application on which this middleware is applied.
#
# @overload new(app, options = {})
#   If nothing but a hash is supplied, initialized with the supplied options
#   overriding the strategy's default options via a deep merge.
# @overload new(app, *args, options = {})
#   If the strategy has supplied custom arguments that it accepts, they may
#   will be passed through and set to the appropriate values.
#
# @yield [Options] Yields options to block for further configuration.
def initialize(app, *args, &block) # rubocop:disable UnusedMethodArgument
```

### API 側と omniauth-oauth2 とのギャップを埋めることのできるポイント

今回も `callback_url` をオーバーライドし、やりませんでしたが `credentials` も検討しました。では、どこまでが簡易にオーバーライドできる対象なのかということですが、結局は `Strategy` として include される対象しか直接のオーバーライドは難しいので以下の 2 箇所になりそうです。

- https://github.com/intridea/omniauth/blob/1cc1cf4b2821a7d2a4a376a5ca93c61b6bd8b5f1/lib/omniauth/strategy.rb
- https://github.com/intridea/omniauth-oauth2/blob/master/lib/omniauth/strategies/oauth2.rb

### デバッグについて

今回も `callback_url` の問題とパースの問題は多少面倒で、簡単な Sinatra アプリを書いて動作確認をしつつでした。

パースの方は、自分で書いた Strategy の方にログを入れて状況確認し、後はコードを追ってなんとかしましたが、`callback_url` の方は何が起こっているのかさっぱりでした。

その時役に立ったのが`oauth2` ライブラリの client クラスは DEBUG オプションです。
https://github.com/intridea/oauth2/blob/master/lib/oauth2/client.rb#L89

```rb
connection.response :logger, ::Logger.new($stdout) if ENV['OAUTH_DEBUG'] == 'true'
```

これで Faraday のロガーを有効にしてくれるので、どのフェーズでエラーになったのかや、どういうリクエストを送っているのかが分かりデバッグが捗りました。

ただ、この DEBUG オプションは罠が潜んでいて以下の issue の事象が発生します。よって、トークンの取得まで進んだらこのオプションは外しましょう。

https://github.com/intridea/oauth2/issues/189