Rails × React でSPA認証をFirebase AuthenticationのTwitter認証で実装してみた

## 始めに

ポートフォリオを作成する際にログイン認証を実装すると思いますが、実装方法が色々あり何を選ぼうか悩むことはありませんか？

今回私はFirebase Authenticationを使用してTwitterのログイン認証を実装してみました！

そこでなぜFirebase Authenticationを選んでどんなふうに実装していったのかざっくりまとめ見ました！




## 1.Firebase Authentificationとは?

Firebase Authenticationはユーザー認証機能を提供し、ユーザ情報をクラウドで保存してくれる、Google運営のサービス。

Firebase Authenticationを使用することでtwitterやfacebook、メールアドレス・パスワード、Googleアカウントなど様々な方法で認証機能の実現を可能にできます。


## 2.なぜFirebase Authenticationを選んだのか？

 Firebase Authentificationを選んだ理由を、2点挙げさせていただきます！

**①ログイン機能を実装する方法で、外部サービスを選ぶのが最善の策だと考えたから。**

**②他の外部サービスとの比較から、Firebase Authentificationを選ぶのがいいと思ったから。**


### ①**ログイン機能を実装する方法で、外部サービスを選ぶのが最善の策だと考えたから。**
※**ここでは捉え方がそれぞれではあると思うので一意見の感想として捉えていただきたいです。**

今回はRails APIモードでSPA認証を行う上で、外部サービスの認証機能を使用するのが最善の策という考えに至りました。

SPA認証ではセッションベースではなくトークンを用いた認証を用いるのが一般的であり、サーバサイドにユーザ情報を保存しないため、クライアント側にユーザー情報が含まれているトークンを保存することになります。セッションベースとトークンベースの認証について[この記事](https://coders-shelf.com/react-auth-problem/)を参考にしました！

しかし、クライアントサイド側で情報を保存（Local Storageに保存するなど）することはクロスサイトスクリプティング攻撃などの問題が生じる恐れがあります。

そこで外部サービスを使用し、トークン情報を保管する方法を選びました。

Firebase Authenificationは、ログインが成功すればJWT仕様のIDトークンを返却されるためクライアントサイド側で保存することなくSPA認証を実現できます。

# **②他の外部サービスとの比較から、今回はFirebase Authentificationを選ぶのがいいと思ったから**

外部の認証機能を採用するにあたり、Auth0とFirebaseAuthで悩みました。そこでFirebase Authentificationを選んだポイントや外部サービスにはない特徴を挙げさせていただきます！


### ①電話認証以外は基本的に無料

個人的にできるものなら節約したかったのと電話認証を使う予定がなかったのでFirebase Authentificationを選びました！


### ②匿名認証ができる

　今回は匿名認証は使用しませんでしたが、匿名認証に興味がある人向けに記載させていただきました！匿名認証とは端的に言えば、個人情報を入力せずにサービスを使用することができる仕組みのこと。

### ③利用する上でのハードルが低い

 Firebaseを利用する際に必要なものがGoogleアカウントのみであり、フォーム入力や支払いに関する登録もせず利用できます。


# 3.Firebase Authentificationを使用して実装してみる

それではFirebase Authentificationを使用して、Twitter認証の流れを追っていきたいと思います！

今回はRails × React でFirebase AuthenticationのTwitter認証の流れを見ていきます！

### 認証機能の流れ

ざっくり認証機能の流れをまとめました！

1. **ログインボタンを押すと、Twitterのログイン画面に遷移する。（今回はポップアップモードを採用）**
1. **認証に成功したらユーザーの情報（IdTokenを含む）が返却される。**
1. **返却されたIdTokenをフロント側からバックエンドに送信**
1. **送信されたFirebase Id TokenをRails側で検証。送られてきたuser_idから新規登録か既存のユーザーかを特定する。**

上記の流れをわかりやすく図にした記事があったので参考にさせていただきました！
[Rails API×Firebase authの場合、Railsは何をすべきなのかを考えた【設計編】](https://zenn.dev/awakei/articles/775c030922bf9c6ab56e)


以降各項目について説明させていただきます！

### React（フロント側）の実装

今回はFirebaseの初期設定であるプロジェクトの作成などは省略させていただきます。
（下記の記事を参照しました）

[【完全版】React 18.2のFirebase v9.10 Authentication(認証)を基礎からマスター](https://reffect.co.jp/react/react18-firebase9-auth)

### ログイン状態を保持

- `onAuthStateChanged` 関数を使用しサインインしているユーザーを取得し、ログイン・ログアウト時の処理を設定。user情報を監視しています。
- もしuser情報がなければログアウトと認識し、user情報があればログインの状態を保持し続けます。
- user情報はどのコンポーネントでも使用する可能性を考慮し、`useContext`を使用しuserの情報をグローバルな状態で管理します。

```jsx:ログイン状態を保持する実装
import { createContext, useState, useEffect } from 'react'
import { onAuthStateChanged } from 'firebase/auth'
import { auth } from './firebase'

// 
export const AuthContext = createContext('')

export const App = () => {
  // 
  const [user, setUser] = useState({})
 
  const handleLogin = (user) => {
    setUser(user)
  }

  const handleLogout = () => {
    setUser({})
  }

  useEffect(() => {
    const unsubscribed = onAuthStateChanged(auth, (user) => {
      if (user) {
        handleLogin(user)
      } else {
        handleLogout()
      }
    })
    //クリーンアップ処理
    return ()=> {unsubscribed();}
  }, [])

  return (
      <AuthContext.Provider value={[user]}>
        　<Component/>
      </AuthContext.Provider>
  )
}
```


### ユーザ登録画面でツイッターボタンを作成

- 今回はMUIを使用し、ツイッターログインボタンを作成します。
- ログインボタンを押すとポップアップウィンドウでサインインする`signInWithPopup`を呼び出す。
- サインインしたuserの情報からIdTokenを取り出しバックエンドに送ります。

```jsx
import { Button } from '@mui/material'
import axios from 'axios'

export const Login = () => {
  
  //twitterログインの処理
  const handleTwitterLogin = () => {
    signInWithPopup(auth, provider)
      .then(async (result) => {
        const user = await result.user
        const token = await user.getIdToken(true)
        const config = { headers: { authorization: `Bearer ${token}` } }

        axios.post("バックエンド側のpath",config)
      })
      .catch((error) => {
        //失敗時に表示したい項目を記載
      })
  }

  return (
          <Button onClick={handleTwitterLogin}>
            Twitterログイン
          </Button>
  )
}
```


### 2.Rails（バックエンド側）の実装

- フロント側から送られてきたIdTokenを検証します。
- Firebase Authntification SDKを使用すると、簡単にJWT使用で送られてくるIdTokenを検証できるが、Rubyはサポート対象外でした….。そのため、サードパーティのJWTライブラリを使用してIDトークンを確認する必要がある。
実際にRubyで実装されている記事がありましたので参考にさせていただきました！
[RubyでFirebaseのidトークンを認証に使ってみる](https://qiita.com/otakky/items/b7582202f5cde8f2dd21)
[Rails + Next.js + Firebase Authentication で認証付きアプリを作成する](https://qiita.com/kmgk0215/items/a174c6eef0a940ad03ea)

今回は`ruby-jwt`ライブラリを使用します。
```jsx
gem 'jwt'
```

- 次に、IDトークンのヘッダー、ペイロード、および署名を確認します。

### IDトークンのヘッダーが次の制約に準拠していること

- アルゴリズムが"RS256"であること

- Google公開鍵から送られてくる中から、kidをキーに持つものと一致していること。
kidとは署名に使用するキーを識別する値

### IDトークンのペイロードが次の制約に準拠していること

- tokenの有効期限が切れていないこと

- tokenの発行時が過去であること

- audが自分のproject_idであること

- 署名の発行者が`"https://securetoken.google.com/<aud>"`である必要がある

- uidが空ではない文字列であること

### 複数の公開鍵からkidをキーに持つものを取得してJWTライブラリを使用して署名を確認

 参照記事
[Firebase ドキュメント](https://firebase.google.com/docs/auth/admin/verify-id-tokens)

これらを踏まえてRails側で送られてきたidTokenの検証コードを記載していきます。

- 検証コードは使い回すことを考慮して、concern配下にファイルを作成します。
- 送られてきたトークンを検証していきます。
- まずはIdトークンが文字列かどうか確認後、一回目は検証を行わずjwt形式のトークンをデコードして署名に必要なkidを取得する。
- トークンの情報が先ほど挙げさせていただいたヘッダー、ペイロード、および署名の確認事項から正しいフォーマットであるかチェックを行う。

```ruby:検証なしでトークンをデコードし、トークンが正しいフォーマットかチェック

      def authenticate_token
        authenticate_with_http_token do |token, options|
          return { data: verify_id_token(token) }
        rescue 
          ##エラー時の処理を記載
        end
      end
 
      private

      def verify_id_token(token)
        raise 'エラーメッセージ' unless token.is_a?(String)
        full_decoded_token = decode_jwt(token, false)
        errors = validate(full_decoded_token)
        raise #エラー時の処理

        public_key = fetch_public_keys[full_decoded_token[:header]['kid']]
        unless public_key
        #トークンが正しい鍵から作られていない場合のエラー文を記載
        end
       #公開鍵から証明書を作成
        certificate = OpenSSL::X509::Certificate.new(public_key)
       #証明書を利用しトークンを再度デコード
        decoded_token = decode_jwt(token, true, { algorithm: ALGORITHM, verify_iat: true }, certificate.public_key)
        { uid: decoded_token[:payload]['sub'], decoded_token: decoded_token }
      end

      def decode_jwt(token, verify, options = {}, key = nil)
        begin
          decoded_token = JWT.decode(token, key, verify, options)
        rescue JWT::ExpiredSignature
         #有効期限が切れている時のエラー
        rescue JWT::InvalidIatError 
         #発行時が過去ではない場合のエラー
        end
 
        { payload: decoded_token[0], header: decoded_token[1] }
      end

      def validate(json)
       #トークンの情報が正しいフォーマットであるかチェックを行う
      end

```

- payloadとheaderの中身はこんな感じです。
```ruby:payload
{
 "name"=>"",
 "picture"=>"",
 "iss"=>"",
 "aud"=>"",
 "auth_time"=>,
 "user_id"=>"",
 "sub"=>"",
 "iat"=>,
 "exp"=>,
 "firebase"=>{}
 }
```
```ruby:header
{"alg"=>"", "kid"=>"", "typ"=>""}
```


- 次にトークンが正しい鍵から作られているかを確認する
- 公開鍵をGoogleから取得後、複数の公開鍵からkidをキーに持つものを使用して証明書を作成
- 作成後証明書を使用してトークンを再度デコードする

```ruby:複数の公開鍵からkidをキーに持つものを使用して作成された証明書でトークンを再度デコードする

      ALGORITHM = 'RS256'.freeze
      ISS_URL = 'https://securetoken.google.com/'.freeze
      CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'.freeze


      def authenticate_token
        authenticate_with_http_token do |token, options|
          return { data: verify_id_token(token) }
        rescue 
          ##エラー時の処理を記載
        end
      end
 
      private

      def verify_id_token(token)
        raise 'エラーメッセージ' unless token.is_a?(String)
        full_decoded_token = decode_jwt(token, false)
        errors = validate(full_decoded_token)
        raise #エラー時の処理       

        public_key = fetch_public_keys[full_decoded_token[:header]['kid']]
        unless public_key
        #トークンが正しい鍵から作られていない場合のエラー文を記載
        end
       #公開鍵から証明書を作成
        certificate = OpenSSL::X509::Certificate.new(public_key)
       #証明書を利用しトークンを再度デコード
        decoded_token = decode_jwt(token, true, { algorithm: ALGORITHM, verify_iat: true }, certificate.public_key)
        { uid: decoded_token[:payload]['sub'], decoded_token: decoded_token }
      end



      def decode_jwt(token, verify, options = {}, key = nil)
        begin
          decoded_token = JWT.decode(token, key, verify, options)
        rescue JWT::ExpiredSignature
         #有効期限が切れている時のエラー
        rescue JWT::InvalidIatError 
         #発行時が過去ではない場合のエラー
      end
        { payload: decoded_token[0], header: decoded_token[1] }
      end

     #CERT_URLから証明書リストを取得
      def fetch_public_keys
        uri = URI.parse(CERT_URL)
        https = Net::HTTP.new(uri.host, uri.port)
        https.use_ssl = true

        res = https.start { https.get(uri.request_uri) }
        data = JSON.parse(res.body)

        return data unless data['error']
       ##エラー時のメッセージなどを記載
      end

     
```

- idトークン認証が成功し復号できれば、uidからUserの新規登録か既存であるかを確認する。

```ruby:uidからUserの新規登録または既存であるか確認
module Api
  module V1
    class UsersController < ApplicationController
      include FirebaseAuthConcern
      before_action :set_auth, only: %i[create]

      def create
        create_user(@auth)
      end

      private

      def create_user(auth)
        render json: auth, status: :unauthorized and return unless auth[:data]
        uid = auth[:data][:uid]
        render json: { message: '登録済みです' } and return if User.find_by(uid: uid)

        user = User.new(uid: uid)
        if user.save
          render json: { message: '登録しましました' }
        else
          render json: user.errors.messages, status: :unprocessable_entity
        end
      end

      def set_auth
        @auth = authenticate_token
      end
    end
  end
end
```

# 4.最後に

最後までご覧いただきありがとうございました！この記事が誰かの役に立てれば幸いです！

# 4.参考文献

[Next.js編 - Rails + Next.js + Firebase V9 Authentication で認証付きのCRUDアプリを作る](https://lei900.github.io/22/11/nextjs-firebase-auth/)
[Rails編 - Rails + Next.js + Firebase V9 Authentication で認証付きのCRUDアプリを作る](https://lei900.github.io/22/11/rails-firebase-auth/)
[Firebase ドキュメント](https://firebase.google.com/docs/auth/admin/verify-id-tokens)
[ruby-jwt](https://github.com/jwt/ruby-jwt)
[React(SPA)での認証についてまとめ](https://coders-shelf.com/react-auth-problem/)
[認証用トークン保存先の第4選択肢としての「Auth0」](https://logmi.jp/tech/articles/324349)
[RubyでFirebaseのidトークンを認証に使ってみる](https://qiita.com/otakky/items/b7582202f5cde8f2dd21)
[Rails + Next.js + Firebase Authentication で認証付きアプリを作成する](https://qiita.com/kmgk0215/items/a174c6eef0a940ad03ea)