LoginSignup
3
2

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-06-05

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

はじめに

どの API の Strategy を書いた?

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

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

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

API はどんなもの?

https://dev.timelyapp.com/

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

書いてみた

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

omniauth-oauth2 を使う

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

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

以下の記事がとても詳しいので合わせて参照すると良さそうです。
OmniAuth OAuth2 を使って OAuth2 のストラテジーを作るときに知っていると幸せになれるかもしれないこと

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

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 もこのライブラリを利用していますね。(カスタマイズはされていますが)

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

Strategy 作成時につまずいた点

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

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

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 を無くした形にしました。 コード

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

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

まず、access_token は何者か?

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

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 で取得したトークンをどういう形式で渡すかを設定できるのが分かります。

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_tokencredential 情報として認識してくれません。

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 のコードです。

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

# 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 箇所になりそうです。

デバッグについて

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

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

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

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

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

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

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2