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で各データを取得できるようにしています。