Rails 7.0 の api_only = true で devise を使うと DisabledSessionError で落ちるのをなんとかする。

概要

Rails 6.1 から 7.0 で ActionDispatch::Request::Session::DisabledSessionError追加 されており、セッションが有効化されてない場合に使おうとした際に例外が投げられるようになった。これ自体は歓迎すべき変更だと思われる。

しかし api_only = true で運用すると最小構成で Rails アプリが作られるため、該当するミドルウェアが読み込まれない。

devise が内部で暗黙的にセッションを使っている ため、セッションが無効なままここに到達すると warden が env["rack.session"] を触って 例外が発生して死んでしまう。

対策を考える。

  • Rails を止める。
    • 身も蓋もない。
  • Rails 7.0 を止める。
    • まだしばらく 6.1 でもいいんじゃない、っていうのはある。
  • api_only = true をやめる。
    • 身も蓋もない (2回目)
  • ミドルウェアを挟んでセッションを有効にする。
    • 今まで使ってなかったし最小限でやりたいのに余計なものを有効にするのは…という気持ちが強い。
      • しかしながら api_only = true でもセッションを使う必要があって有効にしてた現場は割とありそう。
      • そういうところでは、そもそもこの問題は発生してないはず。
  • ハックする。
    • 一時凌ぎでいいし、これでいきましょう。

ハックの実装

困ってる人はいるようで、 heartcombo/devise/issues/5443 が立っており、議論の中で実装例を書いている人がいる。参考にしつつ実装してみることにする。

とりあえず request.env['rack.session'] に何か空の Hash でも放り込んでおけば良いので、こんな感じのダミーになりそうな class を切る。

class FakeSession < Hash
  def enabled?
    false
  end

  def destroy
  end
end

で、 request.env['rack.session'] ||= FakeSession.new が実行されるようなメソッドを切って before_action に放り込んでやればいい。

実際の実装は以下のような感じになると思う。

app/controllers/concerns/devise_hack_fake_session.rb を切って

module DeviseHackFakeSession
  extend ActiveSupport::Concern

  class FakeSession < Hash
    def enabled?
      false
    end

    def destroy
    end
  end

  included do
    before_action :set_fake_session

    private

    def set_fake_session
      if Rails.configuration.respond_to?(:api_only) && Rails.configuration.api_only
        request.env['rack.session'] ||= ::DeviseHackFakeSession::FakeSession.new
      end
    end
  end
end

で、 app/controllers/application_controller.rb で読む。

class ApplicationController < ActionController::API
  include DeviseHackFakeSession
  # 略
end

上の様な対応を入れた プルリク も出てはいるのだが、これはあくまで一時凌ぎであって、この回避策はどう考えても雑であり devise 側が取り込むかというと極めて微妙な気がしている。

ちゃんとやるなら…

  • devise 側で warden.session_serializer を触らないようにする。
  • warden 側にセッションを使わないオプションを生やして使うようにする。

みたいな方針が健全かなと思う。

ざっと見ただけだが、 devise 側から warden.session_serializer を触ってるコードは

あたりなので、こちらを精査してパッチを充てた方がいいかもしれない。

api_only = true で devise ってどういう状況やねん

devise_token_auth を使う場合とか、 devise-jwt を使う場合とかが考えられるかな…。

実際 devise-jwt 側でも issue が立っている。

他に DisabledSessionError の追加によって出てくる問題とか。

なんと ActiveRecord ではマルチ DB 環境下において暗黙にセッションに依存しているところがあったようだ。

マルチDBでリードレプリカを有効にしたAPIモードのRails7において書き込み系リクエストが失敗する事象の対処方法と原因 に詳しい。

こんなの手元の開発環境で気付ける訳がない。もしステージング環境をしっかり作ってなかったら本番まで持っていって初めて死ぬ。

まぁしかし、どちらかというと今まで実は機能してなかったことに気付けたという意味ではやっぱり追加されてよかった例外なのではないでしょうか。

Rails 7.0 から DisabledSessionError が追加された経緯を軽く追ってみる。

https://github.com/rails/rails/pull/42231

このプルリクが出たのが 2021/5/15 で、マージされたのが 2021/5/18

同じプルリクで 2 種類の DisabledSessionError が足されており若干ややこしいが

  • ActionController::RequestForgeryProtection::DisabledSessionError
    • actionpack/lib/action_controller/metal/request_forgery_protection.rb
      • 後に削除される。
  • ActionDispatch::Request::Session::DisabledSessionError
    • actionpack/lib/action_dispatch/request/session.rb

で、この時点では

config.action_controller.silence_disabled_session_errors = true

にすれば、いきなり例外で落ちるのではなくエラーメッセージを表示するだけで回避できそうに一瞬見えたけど、よく見ると違ってて ActionController::RequestForgeryProtection::DisabledSessionError 向けのオプションだった模様。

そして色々と問題が起きたのか ActionController::RequestForgeryProtection::DisabledSessionError の方は 2021/10/11 のプルリク でごっそりと落とされ、ひっそりと残っていたオプションも 2021/12/7 のプルリク で落ちた。

Rails 7.0 のリリース日を見ると

  • alpha1 と alpha2 が 2021/9/16
  • rc1 が 2021/12/7 で、 rc2 と rc3 が 2021/12/15
  • 7.0.0 が 2021/12/16

なので、最終的に ActionDispatch::Request::Session::DisabledSessionError だけが残ってリリースされた模様。

締め

Rails に何を求めるのかという話なのだが、現代の Web アプリ開発的には単純に JSON さえ返してくれれば良くて、ブラウザの面倒まで見ないで欲しいというか、正直余計なことはしないで欲しい訳だけど、歴史的経緯からするとそうもいかない。

とはいえブラウザだけじゃなくてスマホアプリとかもある訳で、フロントエンドの面倒を見るという責務は分離されていた方が正直開発しやすい。

じゃあ Rails じゃなくて良くない?ってのは正論でもあるけど、現実の世の中では正論で殴ると殴られた方も殴った方も不幸になる訳です。難しいですね。