🦁

Rails HotwireでFirebaseの認証トークンを使う方法

2024/02/12に公開

はじめに

Rails 7.0の登場とともに、HotwireがRailsの標準機能として導入されました。Hotwireは、少ないJavaScriptでSPA(シングルページアプリケーション)風のモダンなWebアプリケーションを構築する新しいアプローチです。シンプルなUIのアプリケーションに限ると、元々のRails開発効率の高さも合わさって、かなり効率よく開発できる可能性があると思っています。
しかし、Hotwireに関する具体的な実装記事はまだ多くなくノウハウの共有が不足しているようにも感じます。そこで今回は、Hotwireを使用してBearer認証(Firebase Authenticationを例に)を行う方法を共有します。

Hotwireとは

公式サイトによると、Hotwireは、Turbo、Stimulus、Stradaの3つのライブラリ群から構成されています。このうち、Stradaはネイティブアプリケーション開発向けのライブラリで尚且つ現時点ではベータ版です。Webアプリケーションに使うのは、Turbo + Stimulusになります。

Turbo

Turboは、リンクやフォームリクエストをインターセプトしてFetchリクエストに置き換え、HTMLのレスポンスでDOM要素を動的に置き換えることでページの一部分だけを更新します。これにより、ページ全体を再読み込みすることなく、高速でスムーズなユーザー体験を提供するライブラリになります。Turboは、3つのコンポーネントがあり、それぞれざっくり以下のようなときに使います。

  • Turbo Drive: <body>タグ内のコンテンツを更新する。
  • Turbo Frame: 指定したタグのコンテンツを更新する。
  • Turbo Streams: 複数のフレームを入れ替えたり、追加・削除する。

これらの機能により、JavaScriptの初期化を必要とせず、またJavaScriptの状態を保持しながら、読み込みを高速化することが可能になります。

Stimulus

Stimulusは、既存のHTMLに軽くJavaScriptを追加することを目的としたモデストなフレームワークです。HTMLを中心に据え、カスタムデータ属性を使用してコントローラークラスとHTMLを接続します。これにより、HTML要素に対する動作を宣言的に定義でき、コントローラーやアクションをHTMLと直接関連付けることが可能になります。
個人的には、ReactのフックやVueのメソッドのようなものだと思っています。

認証について

Railsでの認証システム構築には、Deviseなどのライブラリがよく使用されますが、Reactなどのフロントエンドフレームワークを使用する際には、Firebase AuthenticationやAuth0, Cognitoなどのサービスを利用することが一般的かと思います。
よく使われるBearer認証は、フロント側で発行したこれらのサービスのトークンをリクエストヘッダーにAuthorization: Bearer {token}の形で追加して、バックエンド側でトークンを検証することで認証を行います。

Railsのセットアップ

まずはRailsアプリケーションのセットアップから始めます。Railsプロジェクトを新規作成します。

rails new myapp
cd myapp

今回、フロント側のjavaScriptは、importmapを使うので、importmapとTurboをインストールします。

bin/rails importmap:install
bin/rails turbo:install
./bin/rails stimulus:install

さらに、Firebase SDKを使うためにconfig/importmap.rbにfirebase/appfirebase/authを追加します。
なお、env.jsは環境変数を設定ファイルとして追加しています。

config/importmap.rb
pin 'application'
pin '@hotwired/turbo-rails', to: 'turbo.min.js', preload: true
pin '@hotwired/stimulus', to: 'stimulus.min.js'
pin '@hotwired/stimulus-loading', to: 'stimulus-loading.js'
pin_all_from 'app/javascript/controllers', under: 'controllers'
pin 'firebase/app', to: 'https://www.gstatic.com/firebasejs/10.7.2/firebase-app.js'
pin 'firebase/auth', to: 'https://www.gstatic.com/firebasejs/10.7.2/firebase-auth.js'
pin 'env', to: 'env.js'

次に、確認用にトップページとマイページを用意します。
今回は、簡単のためにトップページにアクセスし、ログインフォームからログインしマイページに遷移するものとして進めます。

bin/rails g controller top index
bin/rails g controller mypage index

Firebaseのセットアップ

Firebaseコンソールで新しいプロジェクトを作成し、Authenticationを有効にします。Authenticationで、メール/パスワードプロバイダを有効します。

プロジェクト設定のマイアプリから、ウエブアプリを追加します。
次のようなSDKの初期化について表示されるので、firebaseConfigの情報をapp/javascript/env.jsにコピーして参照できるようにしておきます。

app/javascript/env.js
const firebaseConfig = {
  apiKey: 'xxxxx_xxxxxxxxxxx',
  authDomain: 'xxxxxxxxxx.xxxxxx.com',
  projectId: 'xxxxxxxxxxxxx',
  storageBucket: 'xxxxxxxxxxxx.xxxxxx.com',
  messagingSenderId: '000000000000',
  appId: '1:0000000:web:xxxxxxxxxxxxxxxxx',
  measurementId: 'G-X00000000',
}
export { firebaseConfig }

ログインフォームの作成

トップページにログインフォームを作成し、ログインフォーム用に、Firebaseから取得した認証トークンを使用して認証を行うためのコードをstimulusを使って作成します。

まず、認証処理をauth_controller.jsに実装します。
このコントローラでは、認証状態のチェックとログイン、ログアウトの処理を記述しています。
initializeメソッドはstimulusのコントローラーのライフサイクルコールバックで、コントローラがインスタンス化されるときに呼び出されます。ここに、firebase/authのonAuthStateChangedを追加して認証状態の変化を監視しています。ログイン状態の時は、idToken(JWTトークン)を取得して保持するようにしています。
さらに、"turbo:before-fetch-request"イベントを利用して、ログイン状態のときFetchリクエストの前にAuthorizationヘッダーにidTokenを追加するようにしています。
これによって、TurboのFetchリクエストのたびにバックエンドにidTokenが渡るようになり、バックエンド側でそれを検証することで認証処理を行うことが可能になります。

app/javascript/controllers/auth_controller.js
import { Controller } from "@hotwired/stimulus"
import { initializeApp, getApp } from "firebase/app"
import { getAuth, onAuthStateChanged, signInWithEmailAndPassword, signOut } from "firebase/auth"
import { firebaseConfig } from "env"

let idToken = null;
let initialized = false;

// Connects to data-controller="auth"
export default class extends Controller {
  static targets = [ "email", "password" ]

  initialize() {
    if(initialized) return;

    const app = initializeApp(firebaseConfig);
    const auth = getAuth(app);

    onAuthStateChanged(auth, async (user) => {
      if(user) {
        idToken = await user.getIdToken();
      }
    });

    document.addEventListener("turbo:before-fetch-request", (event) => {
      if(idToken == null) return;

      event.preventDefault();
      event.detail.fetchOptions.headers["Authorization"] =  "Bearer " + idToken;
      event.detail.resume();
    });
    initialized = true;
  }

  async loginWithEmailAndPassword() {
    try{
      const app = getApp();
      const auth = getAuth(app);
      const email = this.emailTarget.value ;
      const password = this.passwordTarget.value ;
      await signInWithEmailAndPassword(auth, email, password)
    } catch (error) {
      console.log("login error", error)
    }
  }

  async logout() {
    try{
      const app = getApp();
      const auth = getAuth(app);
      await signOut(auth)
    } catch (error) {
      console.log("logout error", error)
    }
  }
}

次に、stimulusのコントローラーは、htmlのカスタムデータ属性を使うことでバインドします。ここでは、application.html.erbのbodyタグ内で呼び出すようにしています。

app/views/layout/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <title>RailsHotwireFirebaseAuthSample</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <div data-controller="auth" >
      <%= yield %>
    </div>
  </body>
</html>

トップページのログインフォームは、次のように作成しました。
form_withのdataプロパティでコントローラーのアクションを実行させています。
action: "submit->auth#loginWithEmailAndPassword:prevent" の部分がauth_controllerのloginWithEmailAndPasswordメソッドを実行する処理になります。
後ろの:preventは、コントローラのメソッドを実行前にpreventDefaultを呼ぶオプションです。詳しくは、https://stimulus.hotwired.dev/reference/actions#options

app/views/top/index.erb
<div>Top Page</div>
<%= form_with data: { controller: "auth", action: "submit->auth#loginWithEmailAndPassword:prevent" } do |f| %>
  <div>
    <%= f.text_field :email, placeholder: "email", data: { auth_target: "email"} %>
  </div>
  <div>
    <%= f.password_field :password, placeholder: "password", data: { auth_target: "password"} %>
  </div>
  <div>
    <%= f.submit 'Login' %>
  </div>
<% end %>
<br />
<button data-action="click->auth#logout">
  Logout
</button>
<br />
<%= link_to "MyPage", "/mypage" %>

Railsでのトークン検証

Rails側でFirebaseから発行されたトークンを検証するには、firebase-admin-sdk gemを使用しました。
Firebaseのサービスアカウントキーを使用するためコンソールの プロジェクトの設定 > サービスアカウント タグから生成します。

ここでは、lib内でサービスアカウントキーで初期化して使います。

lib/firebase_admin/client.rb
require 'firebase-admin-sdk'

module FirebaseAdmin
  class Client
    class << self
      # ひとまず直接ファイルを指定しているが、環境変数で読む方がよい
      FIREBASE_SERVICE_ACCOUNT_FILE = Rails.root.join('service_account.json')

      # Verify id token
      #
      # @param id_token [String] Firebase id token
      # @return [hash] decoded token
      def verify_id_token!(id_token)
        app.auth.verify_id_token(id_token)
      end

      private

      def app
        @app ||= Firebase::Admin::App.new(
          credentials: Firebase::Admin::Credentials.from_file(FIREBASE_SERVICE_ACCOUNT_FILE)
        )
      end
    end
  end
end

作成したクライアントを使って、ApplicationController内にauthenticateメソッドを定義します。

ApplicationController.rb
class ApplicationController < ActionController::Base
  def authenticate
    id_token = request.headers['Authorization']&.split&.last
    decorded_token = FirebaseAdmin::Client.verify_id_token!(id_token)
    @uid = decorded_token['user_id']
  end
end

MypageControllerのbefore_actionでauthenticateメソッドを実行します。

MypageController.rb
class MypageController < ApplicationController
  before_action :authenticate

  def index; end
end

ログインの確認

アカウント作成は、事前にFirebaseのコンソールで行っています。

トップページから作成したアカウントのemail, passwordでログインします。画面遷移させていないの見た目は変わりません。

mypageのリンクをクリックし、mypageに遷移させます。

見た目には、ページが遷移しただけですが、ブラウザのデベロッパーツールを確認するとmypageへのリクエストのtypeがfetchとなっており、ヘッダーにAuthorizationとトークンがセットされていることがわかります。

これで、認証ができたと思いきや、mypage上でブラウザのリロードするとエラーになります。

これは、Turboリクエスト以外ではトークンがセットされないためです。このままだと、ページのリロードや他のサイトからのリンクを踏んだ場合もエラーになってしまいます。
対策として、一度ページを表示して認証情報の確認、取得を行い、Turboリクエストで再度リクエストを行うことにします。
なお、Firebase Authorizationのデフォルト設定では、ブラウザアプリケーションの認証状態はセッションとして維持されますが、設定を変えた場合は他のIDaasを使う場合はlocalStorageなどに保持するなどの対応が必要な場合があります。

認証チェックと再リクエスト対応

ブラウザのリロードやリンクに対応するために、Getリクエストでbearerトークンなしのときに"Checking authentication...”と表示させるようにします。

まず、auth_check.erbというviewを作成します。

app/views/shared/auth_check.erb
<div>Checking authentication...</div>

次に、application_controller.rbのauthenticateメソッドを編集して、Getリクエストでトークンが取得できない場合にauth_check.erbviewを表示させるようにします。
認証の要不要を制御するためのインスタンス変数@require_authを追加しておきます。

app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  def authenticate
    @require_auth = true   # <- 追加

    id_token = request.headers['Authorization']&.split&.last

    if request.method_symbol == :get && id_token.nil? # <- 追加
      render 'shared/check_auth', status: 200         # <- 追加
      return                                          # <- 追加
    end

    decorded_token = FirebaseAdmin::Client.verify_id_token!(id_token)
    @uid = decorded_token['user_id']
  end
end

@require_authと@uidの値を、auth_controller.jsで使えるようにapplication.html.erbの変更します。
@uidは、サーバー側で認証されていないと空になります。view側でサーバー認証の有無を確認できるようにするための対応になります。

app/views/layout/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    ...
  </head>

  <body>
    <div data-controller="auth" <%= "data-auth-require_auth-value=#{@require_auth || false} data-auth-uid-value=#{@uid}" %>>
      <%= yield %>
    </div>
  </body>
</html>

auth_controller.jsを編集して、onAuthStateChangedのコールバック内で状態に合わせた処理を行います。
制御用の定数として、valuesにrequire_authuidを追加します。ついでに、ログイン後に/mypageに自動で遷移するようにしておきます。

app/javascript/controllers/auth_controller.js
export default class extends Controller {
  static values = {
    require_auth: Boolean,
    uid: String
  }

   ...

  initialize() {
    ...

    onAuthStateChanged(auth, async (user) => {
      if(user) {
        idToken = await user.getIdToken();
      }

      // 認証必要ないページ
      // 認証必要なページで認証情報が成功している場合は後の処理を抜ける
      if(!this.requireAuthValue || user && this.uidValue == user.uid) return;

      // 認証チェック表示、再リクエスト
      if(user && this.uidValue == "") {
        Turbo.visit( location.pathname, { action: "replace" });
        return;
      }

      // 認証必要なページで認証失敗している
      // ログインページにリダイレクト
      Turbo.visit("/", { action: "replace" });
    });

    ...
  }
  async loginWithEmailAndPassword() {
    try{
      const app = getApp();
      const auth = getAuth(app);
      const email = this.emailTarget.value ;
      const password = this.passwordTarget.value ;
      await signInWithEmailAndPassword(auth, email, password)
      Turbo.visit("/mypage", { action: "replace" }); // <- 追加
    } catch (error) {
      console.log("login error", error)
    }
  }
   ...
}

補足 次のように処理されます。

  1. 通常リクエストを送る
  2. 認証チェックが表示される
  3. 認証状態取得する
  4. onAuthStateChangedのコールバックが走る
  5. 認証チェック表示のときは、同ページをTurboリクエストする
  6. 正常なレスポンスが返るのでページが置き換えられる

確認

ログイン後にmypageに遷移して、認証チェックを行った後にMypageが表示されことを確認をしました。

一度、”Check Authentication..”と表示され、その後ページが表示されるようになりました。よりSPAっぽくなった気がします。

コード全体

コード全体はこちらになります。
https://github.com/kykt35/rails-hotwire-firebase-auth-sample

まとめ

Rails HotwireとFirebase Authenticationを組み合わせた、Webアプリケーションのベースを構築することができました。

Discussion