2021-01-20

RailsでSNSログインを実装するときに作成する設定ファイルは一体何をやっているのか

RailsでSNSログインを実装する場合、 config/initializers あたりに以下のような設定ファイルを追加すると思います。

Rails.application.config.middleware.use OmniAuth::Builder do
  provider :azure_activedirectory_v2,
  client_id:     ENV["AZURE_AD_CLIENT_ID"],
  client_secret: ENV["AZURE_AD_CLIENT_SECRET"],
  tenant_id:     ENV["AZURE_AD_TENANT_ID"]
end

しかし正直なところ、このコードを見ただけでは「おまじない」感が拭えないばかりか、
例えばネットで rails twitter ログイン とか検索しても、だいたい

  1. gem入れる
  2. ↑の設定ファイルを追加する
  3. /auth/twitter にリンクを貼る

くらいしか書いていなくて、小手先だけの対応をしている感じがしてとても気持ち悪いです。
しかしお恥ずかしい話ですが、正直僕は今まで調べるの面倒で小手先対応していました。ダメなエンジニアですね。とても反省しています。

そんなこんなで小手先の対応を続けていたところ、最近Azure Active Directoryの認証をRailsアプリに組み込んだ時に、どうにもうまく動作させることができずバチが当たってしまったので

  1. ↑の設定ファイルは何をやっているのか?
  2. なぜroutesに定義していないにもかかわらず /auth/:provider にリンク貼ったりするだけで認証画面へ飛ばすことができるのか?

あたりをちゃんと調べたので記事に残しておこうと思います。

設定ファイルは何をやっているのか?

「なぜ設定ファイルを追加することでmiddlewareとして登録され、リクエスト時にSNS認証が有効になるのか?」
ということが知りたいので、どうやってそれが実現されていくのかを、起点となる設定ファイルから順番にRails内部へ侵入し見ていく。

まず、そもそも Rails.application.config.middleware.use こいつがなんなのかだが、ここのメソッドを呼んでいる。

def use(klass, *args, &block)
  middlewares.push(build_middleware(klass, args, block))
end

やっていることはとても簡単で、 Middleware クラスのインスタンスを作成して、アクセサを使って middlewares にpushしているだけ。
Railsコンソールなどで、 Rails.application.config.middleware をのぞいて見ると ActionDispatch::MiddlewareStack のインスタンスが取得でき、 middlewares にはRailsガイドでもおなじみのRack Middlewareがずらずらと並んでいることが確認できます。

今回の設定ファイルでは

  • klassは OmniAuth::Builder
  • argsは空の配列
  • block_given? はtrueを返す

という感じになっている

登録されたmiddlewareはその後、Rails engineのappの中のbuildが呼ばれ

def app
  @app || @app_build_lock.synchronize {
    @app ||= begin
      stack = default_middleware_stack
      config.middleware = build_middleware.merge_into(stack)
      config.middleware.build(endpoint) # <- ここ
    end
  }
end

そのbuildはActionDispatch::MiddlewareStackのbuildなのでここが呼ばれ、ようやく先ほど登録した Middleware のインスタンスの build呼ばれる

def build(app = nil, &block)
  instrumenting = ActiveSupport::Notifications.notifier.listening?(InstrumentationProxy::EVENT_NAME)
  middlewares.freeze.reverse.inject(app || block) do |a, e|
    if instrumenting
      e.build_instrumented(a)
    else
      e.build(a) # <- ここ
    end
  end
end

Middleware クラスのインスタンスの buildklass のインスタンス作成を行っているので、いよいよ OmniAuth::Builder のインスタンスの作成まで辿り着く

def build(app)
  klass.new(app, *args, &block) # <- klassはOmniAuth::Builderのこと
end

OmniAuth::BuilderRack::Builder継承しているので、 Rack::Builderinitialize が最初に実行される
Rack::Builderinitialize では instance_eval を使って、受け取ったブロックを実行している。
このブロックこそ、我々が config/initializers に追加した設定ファイルの provider メソッドを含む do〜end の部分である。

def initialize(default_app = nil, &block)
  @use, @map, @run, @warmup, @freeze_app = [], nil, default_app, nil, false
  instance_eval(&block) if block_given?
end

さて、上述したとおり instance_eval を呼び出されているレシーバは、ここでは OmniAuth::Builder のインスタンスなので、
ブロックにある provider メソッドはここに定義されているメソッドが呼び出されているということになる。
klass:azure_activedirectory_v2args は環境変数のハッシュ、 blocknil で呼び出されている。

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)}")
    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

klassClass ではないので、ここ↓Omniauth のストラテジークラスが取得される

middleware = OmniAuth::Strategies.const_get("#{OmniAuth::Utils.camelize(klass.to_s)}")

ここで取得されるストラテジークラスのためにgemを入れているのである!
(ここではこのgemのクラスが取得される)

そして最後にここ↓ でRackの use メソッドにより Rack Middleware として登録されるので、この時点からリクエスト時にSNS認証が有効になる

use middleware, *args, &block

なるほど〜

次回へ続く

ちょっと長くなりそうなので、
なぜroutesに定義していないにもかかわらず /auth/:provider にリンク貼ったりするだけで認証画面へ飛ばすことができるのか?
次回に続きます