Railsエンジンのappディレクトリ配下のクラスを親アプリでオーバーライドする

やりたいこと

Railsエンジンのappディレクトリ配下に存在するクラス(モデルやコントローラ)のメソッドをオーバーライドしたい。

結論

RailsガイドのRailsエンジンについての記事に全部書いてある。Railsエンジンのapp配下のオーバーライドは、to_prepareを使って、親アプリの初期化が終わったあとに実行する。オーバーライドするクラスはclass_evalでリオープンする。

# config/application.rb
module TestApp
  class Application < Rails::Application
    # ...

    # アプリの初期化が終わったときに呼ばれるフック
    config.to_prepare do
      # もしZeitwerkなら`require_dependency`が非推奨なので`load`を使う
      require_dependency Rails.root.join('lib/monkey_patch/foo_bar_engine.rb')
    end
  end
end
# lib/monkey_patch/foo_bar_engine.rb
module MonkeyPatch
  module FooBarEngine
    def do_something
      # 上書きする
    end
  end
end

FooBarEngine::FooBarsController.class_eval do
  prepend ::MonkeyPatch::FooBarEngine
end

詳細

Railsガイドを読めば問題は解決するのだが、思いつく他の方法で試してみて、なぜだめだったかを見てみる。

オーバーライドに失敗する例: その1

親アプリ側のディレクトリに次のようなコードを書くと、このアクションに対応するエンドポイントにリクエストを送るとき、AbstractController::ActionNotFoundのエラーになる。

# app/controllers/foo_bar_engine/foo_bars_controller.rb
module MonkeyPatch
  module FooBarEngine
    def do_something
      # 上書きする
    end
  end
end

module FooBarEngine
  class FooBarsController
    prepend ::MonkeyPatch::FooBarEngine
  end
end

開発環境では、定数参照時にconst_missingになるとActive Supportがautoload_pathsからパスの規約などに基づいて定数を探す。autoload_pathsは例えば次のように確認できる:

[1] pry(main)> puts ActiveSupport::Dependencies.autoload_paths
/usr/src/app/app/assets
/usr/src/app/app/controllers
/usr/src/app/app/controllers/concerns
/usr/src/app/app/helpers
/usr/src/app/app/jobs
/usr/src/app/app/mailers
/usr/src/app/app/models
/usr/src/app/app/models/concerns
/usr/local/bundle/gems/letter_opener_web-1.4.0/app/assets
/usr/local/bundle/gems/letter_opener_web-1.4.0/app/controllers
/usr/local/bundle/gems/letter_opener_web-1.4.0/app/models
/usr/local/bundle/gems/devise-4.7.3/app/controllers
/usr/local/bundle/gems/devise-4.7.3/app/helpers
/usr/local/bundle/gems/devise-4.7.3/app/mailers
/usr/src/app/spec/mailers/previews
=> nil

ここではletter_opener_webやdeviseなどのRailsエンジンのapp配下もautoload_pathsの後ろのほうに入っている。

FooBarsControllerを読み込んでいないとき、アクション実行時にFooBarEngine::FooBarsControllerという定数を解決することになる。autoload_pathsに従うとRailsエンジンより先に親アプリの定義を見てしまい、中身がほぼ空のコントローラのアクションを呼び出してしまってAbstractController::ActionNotFoundになる。

オーバーライドに失敗する例: その2

config/application.rbの末尾でclass_evalでオーバーライド対象のクラスをリオープンしてオーバーライド用のモジュールをprependすると、uninitialized constant FooBarEngine::FooBarsController (NameError)のエラーになる。

# config/application.rb
module TestApp
  class Application < Rails::Application
    # ...

    # これがないとlibにパスが通らない
    config.eager_load_paths << "#{Rails.root}/lib"
  end
end

require_dependency 'lib/monkey_path/foo_bar_engine.rb'
# lib/monkey_path/foo_bar_engine.rb
module MonkeyPatch
  module FooBarEngine
    def do_something
      # オーバーライドする
    end
  end
end

FooBarEngine::FooBarsController.class_eval # この定数が見つからない
  prepend ::MonkeyPatch::FooBarEngine
end

これは、config/environment.rbでconfig/application.rbを読み込んだ時点ではアプリの初期化が終わっておらず、オートロードの準備もできていないので、Railsエンジン配下の定数を探索できないのが理由。

オーバーライドに成功する例

結論に書いたとおり、config.prepare_toフックでオーバーライドする。prepare_toはアプリの初期化が終わった時点で呼び出されるので、オートロードも可能であり、Railsエンジン配下の定数を探索することもできる。

authorization code grantに沿ったDoorkeeperのコードリーディング

さまざまな都合により、OAuth 2のプロバイダになるためのDoorkeeperというgemのコードを読むことがしばしばある生活を送っている。

似た名前のモジュールやクラスが多く、読むたびに混乱しているので、authorization code grantでアクセストークンを取得するときの登場するクラス/モジュールと流れをあらためて自分なりに整理した。基本的に自分用であって、網羅的ではない。

前提

2020-11-28現在での最新版であるDoorkeeper 5.5.0.rc1を読む。authorization code grantが正常に通るときのパスだけを見る。

RailsのAPIモードは無効とし、Doorkeeperの設定resource_owner_authenticatorで渡すブロックでは特定のリソースオーナーの認証に常に成功しているとする。本来は認証を実際に実行し、失敗すれば再認証させるべき。

以降の文章では、Doorkeeperが提供する名前空間のうちDoorkeeperDと省略する。

Doorkeeper用エンドポイントの登録

DoorkeeperはRails Engineであり、ルーティングを拡張するためのuse_doorkeeperというメソッドが提供されている。このメソッドでルーティングを拡張するまでの流れは次のとおり。

主に登場するクラス/モジュール

名前 概要
D::Engine Rails EngineとしてRailtieのinitializerを設定する
D::Rails::AbstractRouter Doorkeeper用ルーティング拡張クラスのためのインタフェースを表す
D::Rails::Routes 親アプリのルーティングにDoorkeeper用のエンドポイントを追加するメソッドを持つ

ルーティングの設定フロー

  • D::Engine"doorkeeper.routes"としてinitializerを登録する
    • 親アプリの初期化時にD::Rails::Routes.install!を実行する
      • ActionDispatch::Routing::Mapperuse_doorkeeperをincludeすることでルーティングの設定でuse_doorkeeperできるようにする
  • Doorkeeper利用時に親アプリのconfig/routes.rbでuse_doorkeeperする
    • D::Rails::Routes#generate_routes!を実行する
    • Railsのscopeを呼び出して、その中で D::Rails::AbstractRouter#map_routeによってDoorkeeperのエンドポイントを定義する
      • D::Rails::Routesのprivateメソッドで個々のルーティングが定義されており、それらのメソッドをsendで呼び出している

authorization code grantの認可リクエスト

authorization code grantでは、あるクライアントとして認可リクエストを送り認可コードを得る必要がある。

主に登場するクラス/モジュール

D::RequestD::OAuthそれぞれの配下に似たような名前のモジュールやクラスがあって混乱する。

コントローラ関連

名前 概要
D::AuthorizationsController /oauth/authorizeへのリクエストがルーティングされるコントローラ
D::Helpers::Controller Doorkeeperの設定をもとにした値などを取得するためのメソッドが集められたモジュール

認可リクエスト

名前 概要
D::OAuth::PreAuthorization 認可リクエストのパラメータのラッパークラス。バリデーションを実行したりscope文字列をパースする
D::Validations D::OAuth::PreAuthorizationD::OAuth::BaseRequestでのバリデーションの仕組みを提供するモジュール
D::Models::AccessTokenMixin アクセストークンに関するロジックを提供するモジュール。ORマッパーへの依存を減らすために、アクセストークンのモデルからは切り離されている
D::OAuth::Hooks::Context 認可前後のフック関数に渡すコンテキストを表すクラス
D::Server 認可サーバとして必要なリクエスト、パラメータ、現在のリソースオーナーやクライアントへアクセスするためのメソッドを提供するクラス。コントローラをコンテキストとして渡して使う
D::Request response_typeを渡して、対応する認可/トークンリクエストを処理するストラテジクラスを返すためのメソッドを提供するクラス
D::Request::Strategy リクエストをもとに認可するストラテジクラスの基底クラス。#authorizeというメソッドを提供する
D::Request::Code D::Request::Strategyを継承するauthorization code grantのストラテジ。#requestではD::OAuth::CodeRequestはインスタンスを返す。D::Request::Strategy#authorizeを呼ぶと、そのインスタンスに#authorizeを委譲する
D::OAuth::CodeRequest 認可コードをD::OAuth::Authorization::Codeのインスタンスとして生成して、認可エンドポイントのレスポンスを作成する
D::OAuth::Authorization::Code 認可コードのラッパークラス。認可コードを発行しグラントを記録するテーブルへ保存する#issue_token!を提供する
D::OAuth::CodeResponse 認可エンドポイントのレスポンスのラッパークラス。コールバックまたはネイティブアプリ向けの方法で認可コードをクライアントに渡すために必要なデータを提供する
D::GrantFlow D::GrantFlow::RegistryにOAuthのグラントの種類とDoorkeeperのストラテジークラスの対応を登録するモジュール
D::GrantFlow::Flow D::GrantFlowで登録する対応を表すクラス

承諾画面の表示

GET /oauth/authorizeを呼び出すときの流れ。

まず、リソースオーナーのデータを取得する。

  • D::AuthorizationsController#newへルーティングする
  • before_action :authenticate_resource_owner!を実行する
  • D::Helpers::Controller#current_resource_ownerを実行する
  • 親アプリのconfig/initializers/doorkeeper.rbなどでD.configureで設定する authenticate_resource_ownerのブロックを呼び出し、返り値を@current_resource_ownerへ入れる

次に、認可エンドポイントへのリクエストを検証する。

  • D::AuthorizationsController#new#pre_authを呼び出す
    • Doorkeeperの設定、認可リクエストのパラメータ、@current_resource_ownerをもとにD::OAuth::PreAuthorizationのインスタンスを作る
  • pre_auth#authorizable?を実行する
    • D::Validations#validateを実行する
      • あらかじめD::OAuth::PreAuthorizationの序盤で定義しているvalidate :client_id, ...などはこのモジュールのメソッドであり、バリデーションを登録している
      • 登録されたバリデーションを順番に実行する
        • バリデーションメソッドはvalidate_#{属性名}sendする
        • これらもD::OAuth::PreAuthorizationにあらかじめ定義してある
        • それぞれのバリデーションはOAuth 2に基づいたもの

リクエストが妥当であれば、クライアントへ承諾画面を返す。

  • authorizable?であればrender_successを実行する
    • D::Helpers::Controller#skip_authorizationを実行する
      • 認可スコープの承諾画面を表示するか否かを決める
      • Doorkeeperの設定のskip_authorizationのブロックを実行する
    • #matching_tokenを実行する
      • D::Models::AccessTokenMixin#matching_token_forですでに対象のクライアントとリソースオーナーの組み合わせで作成済みのアクセストークンを探す
    • #skip_authorization#matching_tokenのどちらかがtrueなら、すぐにauthorize_responseで作成する認可済みのレスポンスを返す
    • そうでなければ:newをレンダリングする
      • app/views/doorkeeper/authorizations/new.html.erbをレンダリングする
      • リクエストしているスコープを表示し、承諾もしくは拒否を求める画面

認可コードの発行

承諾画面で承諾をサブミットし、POST /oauth/authorizeを呼び出すときの流れ。

認可コードを生成する。

  • D::AuthorizationsController#createにルーティングする
  • #authorization_responseを呼び出す
    • pre_authをもとにD::OAuth::Hooks::Contextのインスタンスを作る
    • フックbefore_successful_authorizationを実行する
    • #strategyを呼び出す
      • #serverを呼び出す
        • D::Helpers::Controller#serverを呼び出す
        • コントローラ自身をcontextとして渡してインスタンスを作る
      • Server#authorization_requestを呼び出す
        • 引数に pre_auth.response_typeを渡す。いまは"code"
        • D::Request.authorization_strategyから"code"に対応する認可strategyクラスを取得する
          • D::GrantFlowで各グラントのstrategyクラスなどは設定済み
          • Doorkeeperの設定にあるgrantから対応するものをD::GrantFlow::Flowとして取り出す
          • request_type_strategyを呼び出してD::Request::Codeを返す
          • D::Request::Codeにserverを渡してストラテジーオブジェクトを作る
      • strategyとしてD::Request::Codeのオブジェクトが得られた
    • strategyであるD::Request::Code#authorizeを実行する
      • D::Request::Strategy#authorize#requestD::OAuth::CodeRequest#authorizeと委譲される
      • pre_authresource_ownerを引数に取ってD::OAuth::CodeRequestのインスタンスを作る
      • D::OAuth::CodeRequest#authorizeD::OAuth::Authorization::Codeのインスタンスを作り#issue_token!を呼び出す
        • 認可コードを生成して、既定のActive Recordモデルを通じてテーブルに保存する
      • D::OAuth::CodeResponseのインスタンスをpre_authD::OAuth::Authorization::Codeのインスタンスを渡して作り、returnする

認可コードをコールバックURIに付与するかネイティブアプリ用画面のURIのパラメータとして返す。

  • 認可コードを返すためにredirect_or_render authorization_responseする
    • D::OAuth::CodeResponseのインスタンスであるauthorization_responseredirectable?なら、そのURIへリダイレクトする
    • 認可コードのときは常にtrueなので、oobであればoob用のURIに、それ以外は設定済みのURIに、認可コードをパラメータとして付けてリダイレクトする
      • oobのときD::OAuth::Authorization::Code#oob_redirectをもとにリダイレクトし、app/views/doorkeeper/authorizations/show.html.erbをレンダリングする

トークンエンドポイント

主に登場するクラス/モジュール

認可エンドポイントで登場したものは省略。

名前 概要
D::TokensController /oauth/token へのリクエストがルーティングされるコントローラ
D::Request::AuthorizationCode D::Request::Strategyを継承するauthorization code grantのストラテジ。#authorizeを提供する。Strategyでの#authorize#requestへの委譲時にD::OAuth::AuthorizationCodeRequestを生成する。そのときに#grantの呼び出しを通じて認可コードの検証を実行する
D::OAuth::BaseRequest トークンエンドポイントへのリクエストの基底クラス。#authorizeでトークンレスポンスの生成と前後のフックの実行を提供する
D::OAuth::AuthorizationCodeRequest authorization code grantでのトークンエンドポイントへのリクエストを表すクラス。PKCEのchallengeの検証も担う。フックD::BaseRequest#before_successful_responseをオーバーライドしてアクセストークンを作成している
D::Models::AccessGrantMixin アクセスグラントに関するロジックを提供するモジュール。ORマッパーへの依存を減らすために、アクセスグラントのモデルからは切り離されている
D::OAuth::TokenResponse トークンエンドポイントのレスポンスのラッパークラス。ステータスコードやレスポンスのJSONを取得できる

アクセストークン取得の流れ

POST /oauth/tokenを呼び出して、アクセストークンを含むJSONをレスポンスとして得る。

  • D::TokensController#createにルーティングする
  • #authorize_responseを呼び出す
    • 認可エンドポイントと同じ流れでserverを取得しD::Server#token_requestを呼び出す
      • 引数としてparams[:grant_type]を渡すが、ここでは"authorization_code"
      • D::Request.token_strategyであらかじめ登録済みのstrategyからgrant_type_strategyとしてD::Request::AuthorizationCodeを取得してreturnする
      • そのクラスのインスタンスを得る
    • D::Request::AuthorizationCode#authorizeを呼び出す
      • D::Request::Strategy#authorize#requestD::OAuth::AuthorizationCodeRequest#authorizeD::OAuth::BaseRequest#authorizeと委譲される
      • D::Request::AuthorizationCode#requestgrant呼び出し時に認可コードをもとにoauth_access_grantsのレコードを探している
        • D::Models::AccessGrantMixins.by_tokenで実行している
      • D::OAuth::BaseRequestD::OAuth::ValidationsをincludeしているのでD::OAuth::PreAuthorizationと同じく宣言済みのバリデーションをvalid?で実行できる
      • valid?ならトークンレスポンスを返す
        • D::OAuth::AuthorizationCodeRequest#before_successful_responseで認可コードをrevokeしてアクセストークンを生成する
        • D::OAuth::TokenResponseにアクセストークンを渡してインスタンスを作る
      • トークンレスポンスのオブジェクトをreturnする
  • D::TokensController#createでトークンレスポンスから#body#statusでトークンレスポンスのJSONのステータスを取得してrenderする

所感

DoorkeeperはOAuthの各グラントに対応し、またORマッパー非依存になるような設計で作られていて、さまざまな要件のもとでOAuth 2サーバを作りたいという希望にかなうライブラリとなっている。そのぶん、やっていることが複雑であったりもするし、細かいカスタマイズを施したくなる場面もたびたびある。また、認可という場合によってはクリティカルな機能に関わるライブラリでもある。そういう点で、ただのブラックボックスとして扱うよりは、できるだけ内部を知っておいたほうがいいだろうと思う。どのライブラリにも言えることではあるが、アプリケーション開発の延長として、ライブラリの新バージョンリリース時などのタイミングでこまめにコードを読むことを継続していく。

OpenID Connectについて知るときに読んだもの

OpenID Connect (OIDC)がどういうものか、どう使うのかについて知るために読んだものについてまとめておく。前提として、OAuth 2を利用したアプリケーションの開発経験はあるとします。

OAuth 2

OIDCはOAuth 2を拡張した認証プロトコルとなっている。前提に書いたとおりOAuth 2を使ったアプリケーション(認可サーバ、クライアント両方)の開発に携わった経験はなんどかあったものの、念のため復習した。

OAuth 2 in Action

OAuth 2をさらうために、網羅度が高そうな"OAuth 2 in Action"を読んだ。邦訳は『OAuth徹底入門 セキュアな認可システムを適用するための原則と実践』。

OAuth 2の各グラントタイプを、認可サーバ、クライアント、リソースサーバーそれぞれの役割と認可のフロー、それらを実現するコードで説明している。コードは基本的にJavaScriptで書かれていて、GitHubにもリポジトリ(oauthinaction/oauth-in-action-code)がある。さらに、認可サーバ、クライアント、リソースサーバそれぞれについて、よくある脆弱性とその対策を説明している。

記述がよくも悪くも冗長なので、コードやシーケンス図を読んで、わからないところがあれば本文で補完していくような読みかたがいいかもしれない。

OpenID Connect

OAuth 2 in Action

上で紹介した"OAuth 2 in Action"の後半で、OIDCについて1章割いて説明している*1。OAuthでは誰が認可したのかはクライアントからわかりえないので認証ではない、という話に始まり、他の章と同様に実装を交えてOIDCを説明している。

OAuth、OAuth認証、OpenID Connectの違いを整理して理解できる本

もう少し理解を整理するためにAuth屋さんが頒布している次の本も読んだ。

authya.booth.pm

この本のいいところは、いわゆる「OAuth認証」から始めて、OIDCがなぜ出てくるかまで次のような流れで説明しているところだと思う。

  • OAuthでは、クライアントからはユーザを認証(つまり使っているのが誰なのか判断することが)できない
    • アクセストークンはクライアントから見るとopaqueだから情報がない
  • プロフィールAPIがリソースサーバにあれば認証に近いことができる(これが「OAuth認証」)が、トークン置き換え攻撃やクライアントの脆弱性で乗っ取りが発生しうる
  • Facebookなどdebug_token APIを導入して、「OAuth認証」をよりセキュアにしているサービスもある
  • これらを踏まえて、OAuthを土台としつつセキュアな認証機能を標準化するのがOIDC

RFCなど

IDトークンの検証方法は『OAuth、OAuth認証、OpenID Connectの違いを〜』にも書いてあるが、OIDCのRFCで説明しているのでそちらも確認した。

Final: OpenID Connect Core 1.0 incorporating errata set 1 - 3.1.3.7. ID Token Validation

また、OAuth 2/OIDCの周辺技術に関して、いくつか文書を読んだ。

OIDCではBearer TokenをプロフィールAPIを含むAPIへのアクセス時のトークン送信で使い、JWTをidentity providerからIDトークンとして取得するので、それぞれ注意点やベストプラクティスをあらためて把握するためにRFCを読んだ。"OAuth Security Current Best Practice"は"OAuth 2 in Action"の脆弱性に関する部分と少し被るが、こちらも生まれうる脆弱性とその対策について広く説明している。

OAuth 2/OIDCのRFCは長大なので、ひとまずライブラリの実装と標準の仕様を比較するときなどに読む方針にしている。

OpenID Connectを実装したライブラリ

コードを読むと理解が深まりやすいので、なにか現実のライブラリを読むのはいいことだと思う。注意点は、あくまでもRFCなどが説明する仕様が正であって、実装はなんらかの差異や不足があると認識しておいたほうがよいこと。それも考慮してコードを読むとコントリビューションチャンスも生まれそう。

今回はdoorkeeper-openid_connectを使ってIDトークンの払い出しやDiscovery APIなど一通り実装し、relying partyとして認証サーバを叩いたりコードを読んで理解を深めた。

所感

はじめはなぜOIDCでIDトークンを検証したら認証したことになるのか理解しきれていなかったが、従来の「OAuth認証」で使うプロフィールAPIやdebug_token APIでやっていたようなことを、UserInfo APIと署名付きのIDトークンでやっていると考えると腑に落ちた。そういう意味では、『OAuth、OAuth認証、OpenID Connectの違いを〜』はそのあたりの観点を提供してくれたので大変助かった。とはいえ、それなりにいろいろ読んだものの、細かいパラメータや様々な拡張仕様など、わからないことはまだまだあるので、必要に応じて身につけていきたい。

*1:Chapter 13. User Authentication with OAuth 2.0