2017-10-10

omniauthコードリーディング

omniauthのコードリーディングをしてみたので備忘録。

omniauthはRackミドルウェアとして提供されており、 /auth/:provider や /auth/:provider/callback のパスへのアクセスをフックしてIdPへのAuthorization RequestやCallback時の処理を定義することで、ソーシャルログインを実現しています。

OmniAuth::Builderについて

omniauthの設定はOmniAuth::BuilderをRackミドルウェアとして設定しつつ、ブロックで各プロバイダの設定をします

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :facebook, ENV['FACEBOOK_KEY'], ENV['FACEBOOK_SECRET'], scope: 'email'
end

OmniAuth::BuilderはRack::Builderを継承したクラスで、複数のミドルウェア(ストラテジ)を一つのミドルウェアとしてまとめる役割があります。

module OmniAuth
  class Builder < ::Rack::Builder
    def initialize(app, &block)
      @options = nil
      if rack14? || rack2?
        super
      else
        @app = app
        super(&block)
        @ins << @app
      end
    end
    # snip...
    def provider(klass, *args, &block)
      if klass.is_a?(Class)
        middleware = klass
      else
        begin
          middleware = OmniAuth::Strategies.const_get(OmniAuth::Utils.camelize(klass.to_s).to_s)
        rescue NameError
          raise(LoadError.new("Could not find matching strategy for #{klass.inspect}. You may need to install an additional gem (such as omniauth-#{klass})."))
        end
      end

      args.last.is_a?(Hash) ? args.push(options.merge(args.pop)) : args.push(options)
      use middleware, *args, &block
    end

    def call(env)
      to_app.call(env)
    end
  end
end

Rack::Builderにブロックが渡されると、instance_evalによって、そのブロックがRack::Builderのインスタンスのコンテキストで実行されます。よってブロック内のproviderの呼び出しはOminiAuth::Builderのproviderメソッドを呼び出すことになります。providerメソッドではuseメソッドを使って、各ストラテジのクラスをミドルウェアとして登録します。

OmniAuth::Builderがミドルウェアとしてcallメソッドが呼び出されるとto_app.callが呼び出されます。

to_appはRack::Builderのto_appメソッドでuseで登録したミドルウェアからメインのアプリケーションをデコレートしたインスタンスを生成します。

たとえばtwitterとfacebookのproviderを以下のように設定したとします。

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :twitter, ENV['TWITTER_API_KEY'], ENV['TWITTER_API_SECRET']
  provider :facebook, ENV['FACEBOOK_KEY'], ENV['FACEBOOK_SECRET'], scope: 'email'
end

そうすると、OmuniAuth::Builder#to_appで作成されるRackアプリケーションは、以下のようにミドルウェアで順にラップ(ネスト)されたものになります。

OmniAuth::Strategies::Twitter(
  app: OmniAuth::Strategies::Facebook(
    app: Application
  )
)

全体のuse呼び出しの順番が Middleware1 → Middleware2 → OmniAuth → Middleware3 の場合はRack::Builder#to_appは以下のようなネストしたRackアプリケーションになります。

Middleware1(
  app: Middleware2(
    app: OmniAuth::Strategies::Twitter(
      app: OmniAuth::Strategies::Facebook(
        app: Middleware3(
          app: Application
        )
      )
    )
  )
)

OmniAuth::Strategy

OmniAuthのストラテジクラスは OmniAuth::Strategyをインクルードする必要があります。

OmniAuth::Strategyのcallメソッドは以下のようにパスやHTTPメソッドを見て各処理にdispatchされます。

module OmniAuth
  module Strategy
    # snip ...
    def call(env)
      dup.call!(env)
    end

    def call!(env) # rubocop:disable CyclomaticComplexity, PerceivedComplexity
      unless env['rack.session']
        error = OmniAuth::NoSessionError.new('You must provide a session to use OmniAuth.')
        raise(error)
      end

      @env = env
      @env['omniauth.strategy'] = self if on_auth_path?

      return mock_call!(env) if OmniAuth.config.test_mode
      return options_call if on_auth_path? && options_request?
      return request_call if on_request_path? && OmniAuth.config.allowed_request_methods.include?(request.request_method.downcase.to_sym)
      return callback_call if on_callback_path?
      return other_phase if respond_to?(:other_phase)
      @app.call(env)
    end
    # snip...

OAuth2.0やOpenID Connectによる認証の場合は、Authorization Requestのrequest_callメソッドとコールバックURLでToken Requestをするためのcallback_callメソッドが呼び出されます。

request_pathとcallback_pathは以下のようなコードで生成されます。options[:request_path]やoptions[:callback_path]に値が入っていなければ "#{path_prefix}/#{name}" がrequest_path、"#{path_prefix}/#{name}/callback" が callback_pathになります。path_prefixはデフォで/authになります。これがomniauthの /auth/:provider や /auth/:provider/callbackのルーティングの正体です。

module OmniAuth
  module Strategy
    # snip...
    def request_path
      @request_path ||= options[:request_path].is_a?(String) ? options[:request_path] : "#{path_prefix}/#{name}"
    end

    def callback_path
      @callback_path ||= begin
        path = options[:callback_path] if options[:callback_path].is_a?(String)
        path ||= current_path if options[:callback_path].respond_to?(:call) && options[:callback_path].call(env)
        path ||= custom_path(:request_path)
        path ||= "#{path_prefix}/#{name}/callback"
        path
      end
    end
    # snip...

request_callやcallback_callではそれぞれrequest_phase、callback_phaseメソッドを呼び出します。この{request,callback}_phaseメソッドに各プロバイダ固有の処理を入れます。

    # Performs the steps necessary to run the request phase of a strategy.
    def request_call # rubocop:disable CyclomaticComplexity, MethodLength, PerceivedComplexity
      setup_phase
      log :info, 'Request phase initiated.'
      # store query params from the request url, extracted in the callback_phase
      session['omniauth.params'] = request.GET
      OmniAuth.config.before_request_phase.call(env) if OmniAuth.config.before_request_phase
      if options.form.respond_to?(:call)
        log :info, 'Rendering form from supplied Rack endpoint.'
        options.form.call(env)
      elsif options.form
        log :info, 'Rendering form from underlying application.'
        call_app!
      else
        if request.params['origin']
          env['rack.session']['omniauth.origin'] = request.params['origin']
        elsif env['HTTP_REFERER'] && !env['HTTP_REFERER'].match(/#{request_path}$/)
          env['rack.session']['omniauth.origin'] = env['HTTP_REFERER']
        end
        request_phase
      end
    end

    # Performs the steps necessary to run the callback phase of a strategy.
    def callback_call
      setup_phase
      log :info, 'Callback phase initiated.'
      @env['omniauth.origin'] = session.delete('omniauth.origin')
      @env['omniauth.origin'] = nil if env['omniauth.origin'] == ''
      @env['omniauth.params'] = session.delete('omniauth.params') || {}
      OmniAuth.config.before_callback_phase.call(@env) if OmniAuth.config.before_callback_phase
      callback_phase
    end

例えば、omniauth-oauth2ではrequest_phaseでAuthorization Request用のURLを発行してリダイレクト処理をしています。

      def request_phase
        redirect client.auth_code.authorize_url({:redirect_uri => callback_url}.merge(authorize_params))
      end

callback_phaseはStrategyに既にメソッドが定義されているので、callback側で特別な処理を入れる場合はsuperを呼び出します。

omniauth-oauth2だと以下のようなコードになっており、エラーハンドリングやstateのチェックをしつつ、Authorization CodeからToken Requestによってアクセストークンを取得しています。

      def callback_phase # rubocop:disable AbcSize, CyclomaticComplexity, MethodLength, PerceivedComplexity
        error = request.params["error_reason"] || request.params["error"]
        if error
          fail!(error, CallbackError.new(request.params["error"], request.params["error_description"] || request.params["error_reason"], request.params["error_uri"]))
        elsif !options.provider_ignores_state && (request.params["state"].to_s.empty? || request.params["state"] != session.delete("omniauth.state"))
          fail!(:csrf_detected, CallbackError.new(:csrf_detected, "CSRF detected"))
        else
          self.access_token = build_access_token
          self.access_token = access_token.refresh! if access_token.expired?
          super
        end
      rescue ::OAuth2::Error, CallbackError => e
        fail!(:invalid_credentials, e)
      rescue ::Timeout::Error, ::Errno::ETIMEDOUT => e
        fail!(:timeout, e)
      rescue ::SocketError => e
        fail!(:failed_to_connect, e)
      end

このようにRackミドルウェアを使って、特定のパスへのリクエストをフックしてリダイレクトを噛ませたり、あるいは外部サービスとHTTPリクエストを送ってトークンやユーザ情報をenvに入れてコントローラ側でrequest.envで各データを取得できるようにしています。

このエントリーをはてなブックマークに追加