サイトアイコンmaita tomoya dev io

#56 Rails セッション管理と Firebase 認証の二重管理で発生する不整合問題を徹底解説

Rails セッション管理と Firebase 認証の二重管理で発生する不整合問題を徹底解説

Rails アプリケーションで Firebase Authentication を使用している際に、「ログインしているのに認証エラーになる」「SMS認証が不安定」といった問題に遭遇したことはありませんか?

これらの問題の多くは、Rails側のセッション管理Firebase側の認証管理が独立して動作することで発生する「二重管理問題」が原因です。

本記事では、この複雑な問題を初心者エンジニアにも分かりやすく解説し、なぜこの問題が発生するのか、そしてどのように対処すべきかを詳しく説明します。

目次

  1. Rails セッション管理の基本概念
  2. Firebase 認証の仕組み
  3. 二重管理による問題の発生
  4. 具体的な不整合発生ケース
  5. 対処法と改善案
  6. まとめ

Rails セッション管理の基本概念

セッションとは何か

Web アプリケーションにおける「セッション」とは、サーバー側がユーザー固有の情報を一時的に保存するための仕組みです。

# Rails でのセッション使用例
class ApplicationController < ActionController::Base
  def login_user(user_id)
    session[:user_id] = user_id  # ユーザーIDをセッションに保存
  end
  
  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
    # セッションからユーザーIDを取得してユーザーを特定
  end
  
  def logged_in?
    current_user.present?
  end
end

ブラウザとサーバー間のデータフロー

ブラウザ側                          Rails サーバー側
┌─────────────────┐              ┌─────────────────────┐
│ Cookie:          │              │ セッションストア:      │
│ session_id=abc123│ <────────────│ abc123 => {          │
└─────────────────┘              │   user_id: 12345     │
                                 │ }                    │
                                 └─────────────────────┘

重要なポイント:

  • ブラウザ側:セッションIDのみをCookieに保存
  • サーバー側:実際のデータ(ユーザー情報等)を保存
  • 通信:毎リクエスト時にCookieでセッションIDを送信

Rails セッション管理の特徴

項目内容
データ保存場所サーバー側(メモリ/DB/Redis等)
ブラウザ保存内容セッションIDのみ(Cookie)
データ更新サーバー側のコードで明示的に実行
有効期限サーバー側で設定・管理
セキュリティサーバー側制御のため比較的安全

Firebase 認証の仕組み

Firebase Authentication の特徴

Firebase Authentication は、クライアント側(ブラウザ)で直接動作する認証システムです。

// Firebase 認証の基本的な使用例
import { getAuth, onAuthStateChanged } from 'firebase/auth'

const auth = getAuth()

// 認証状態の監視
onAuthStateChanged(auth, (user) => {
  if (user) {
    console.log('ユーザーがログイン中:', user.uid)
    // JWTトークンの取得
    user.getIdToken().then(token => {
      console.log('IDトークン:', token)
    })
  } else {
    console.log('ユーザーはログアウト状態')
  }
})

Firebase 認証のデータ保存場所

項目内容
データ保存場所ブラウザ側(localStorage等)
認証情報JWTトークン形式
データ更新Firebase SDK が自動実行
有効期限自動リフレッシュ(通常1時間)
セキュリティクライアント側のため追加対策が必要

Firebase 認証が失効するタイミング

Firebase 認証は以下のような場合に失効します:

自動的な失効

  • トークン期限切れ(1時間後)
  • ネットワーク接続問題
  • ブラウザタブを長時間放置
  • モバイルアプリのバックグラウンド処理

ユーザーアクション

  • ブラウザの「サイトデータを削除」
  • プライベートモードの使用
  • 複数デバイスでの同時ログイン時の競合

システム側要因

  • Firebase API側のメンテナンス
  • パスワード変更などのセキュリティ操作
  • アカウント無効化

二重管理による問題の発生

Rails + Firebase 構成での認証フロー

多くの Rails + Firebase 構成では、以下のような二重管理が行われています:

class ApplicationController < ActionController::Base
  before_action :refresh_firebase_token!
  
  def current_user
    # Rails セッションからユーザーを特定
    @current_user ||= User.find_by(firebase_uid: session[:firebase_uid])
  end
  
  private
  
  def refresh_firebase_token!
    # 定期的に Firebase トークンを更新
    refresh_token = current_user&.firebase_refresh_token
    return unless refresh_token
    
    # Firebase API を呼び出し
    response = firebase_api_call(refresh_token)
    
    # Rails セッションを更新
    session[:firebase_uid] = response['user_id']  # ← 問題の原因
  end
end
// フロントエンド側(Firebase 認証)
class AuthManager {
  async getCurrentUser() {
    // Firebase 側の認証状態を取得
    const user = firebase.auth().currentUser
    return user
  }
  
  async checkAuthState() {
    const user = await this.getCurrentUser()
    if (!user) {
      // Firebase 側で認証失効 → Rails 側も強制同期
      this.forceLogout()
    }
  }
  
  forceLogout() {
    // Rails セッションを削除
    fetch('/users/sign_out', { method: 'DELETE' })
      .then(() => window.location.href = '/login')
  }
}

二重管理の問題構造

問題の核心:Rails側とFirebase側が独立して動作している

Firebase側:リアルタイムで認証状態が変化
Rails側:定期的にトークンを更新してセッション管理
↓
同期タイミングがずれて不整合発生

具体的な不整合発生ケース

ケース1: SMS認証中のセッション不整合

このケースは特に複雑で、SMS認証プロセス中に発生します:

// SMS認証の流れ
async function sendSMSCode(phoneNumber) {
  // 1. Firebase で SMS認証開始
  const confirmationResult = await signInWithPhoneNumber(auth, phoneNumber, recaptchaVerifier)
  // 新しい Firebase UID "new_user_12345" が生成される
}

async function verifySMSCode(code) {
  // 3. SMS認証完了
  const result = await confirmationResult.confirm(code)
  // Firebase 側は "new_user_12345" で認証完了
  
  // 4. Rails 側にデータを送信
  const response = await fetch('/users/verify_sms', {
    method: 'POST',
    body: JSON.stringify({
      firebase_uid: result.user.uid,  // "new_user_12345"
      id_token: result._tokenResponse.idToken
    })
  })
}
class ApplicationController < ActionController::Base
  before_action :refresh_firebase_token!  # 毎リクエストで実行
  
  def refresh_firebase_token!
    # 2. SMS認証中でも実行される
    refresh_token = current_user&.firebase_refresh_token  # 既存ユーザーの古いトークン
    response = firebase_api_call(refresh_token)
    
    # 古い情報でセッションを上書き
    session[:firebase_uid] = response['user_id']  # "old_user_67890"
  end
end

問題発生の時系列

時刻 10:00:01 - SMS認証開始
  Firebase: 新しいUID "new_user_12345" を生成中
  Rails: session[:firebase_uid] は "old_user_67890"

時刻 10:00:02 - refresh_firebase_token! が実行される
  Rails: 古いトークンで Firebase API を呼び出し
  Rails: session[:firebase_uid] = "old_user_67890" で上書き

時刻 10:00:03 - SMS認証完了
  Firebase: "new_user_12345" で完了
  Rails: session[:firebase_uid] は "old_user_67890" のまま
  → 不整合発生!

ケース2: トークン期限切れによる不整合

// Firebase 側でトークンが期限切れ
firebase.auth().onAuthStateChanged((user) => {
  if (!user) {
    // Firebase 認証が失効
    console.log("Firebase認証が失効しました")
  }
})

// この時点での状態
// Firebase: null (認証失効)
// Rails: session[:firebase_uid] = "user_67890" (まだ生きている)
# Rails 側ではまだユーザーが取得できる
def current_user
  @current_user ||= User.find_by(firebase_uid: session[:firebase_uid])
  # まだユーザーが見つかる(Rails 側は生きている)
end

def signed_in?
  current_user.present?  # true を返す
end

結果:Rails側では「ログイン中」、Firebase側では「ログアウト状態」

ケース3: 並行処理による競合状態

// SMS認証完了時の処理
async function completeSMSAuth(user, credential) {
  // Firebase 側で電話番号リンク
  const result = await linkWithCredential(user, credential)
  
  // 新しいトークン情報を Rails に送信
  submitToRails({
    firebase_uid: result.user.uid,
    refresh_token: result._tokenResponse.refreshToken,
    id_token: result._tokenResponse.idToken
  })
}
# 同時に before_action で refresh_firebase_token! が実行
def refresh_firebase_token!
  # 古いトークンを使用して Firebase API を呼び出し
  old_refresh_token = current_user&.firebase_refresh_token
  response = firebase_api_call(old_refresh_token)
  
  # 古い情報でセッションを上書き
  session[:firebase_uid] = response['user_id']
end

結果:SMS認証の新しい情報が古い情報で上書きされる

対処法と改善案

短期的な対処法

1. 認証プロセス中のトークン更新を制御

class ApplicationController < ActionController::Base
  before_action :refresh_firebase_token!
  
  private
  
  def refresh_firebase_token!
    # SMS認証関連のパス中は実行しない
    auth_paths = ['/users/phone_number', '/users/verify_sms', '/users/sign_up_with_provider']
    return if auth_paths.any? { |path| request.path.include?(path) }
    
    # 既存のトークン更新処理
    refresh_token = current_user&.firebase_refresh_token
    return unless refresh_token
    
    response = firebase_api_call(refresh_token)
    session[:firebase_uid] = response['user_id']
  end
end

2. Firebase 認証状態の監視追加

class AuthStateMonitor {
  constructor(auth) {
    this.auth = auth
    this.setupAuthStateMonitoring()
  }
  
  setupAuthStateMonitoring() {
    this.auth.onAuthStateChanged((user) => {
      if (!user && this.isInAuthFlow()) {
        console.warn('認証フロー中に Firebase 認証が失効')
        this.handleAuthLoss()
      }
    })
  }
  
  isInAuthFlow() {
    const authPaths = ['/users/phone_number', '/users/verify_sms']
    return authPaths.some(path => window.location.pathname.includes(path))
  }
  
  async handleAuthLoss() {
    // 自動復旧を試行
    try {
      await this.attemptAutoRecover()
    } catch (error) {
      // 復旧失敗時はユーザーに通知
      this.notifyUser('認証エラーが発生しました。再度ログインしてください。')
    }
  }
}

3. 認証中フラグによる排他制御

class SMSAuthManager {
  constructor() {
    this.authInProgress = false
  }
  
  async sendSMSCode(phoneNumber) {
    if (this.authInProgress) {
      console.log('SMS認証が進行中です')
      return
    }
    
    this.authInProgress = true
    
    try {
      const confirmationResult = await signInWithPhoneNumber(auth, phoneNumber, recaptchaVerifier)
      window.confirmationResult = confirmationResult
    } catch (error) {
      console.error('SMS送信エラー:', error)
    } finally {
      // 送信完了後はフラグをリセット(認証完了時ではない)
      // this.authInProgress = false
    }
  }
  
  async verifySMSCode(code) {
    if (!this.authInProgress) {
      console.error('SMS認証が開始されていません')
      return
    }
    
    try {
      const result = await window.confirmationResult.confirm(code)
      await this.submitToRails(result)
    } catch (error) {
      console.error('SMS認証エラー:', error)
    } finally {
      this.authInProgress = false  // 認証完了時にフラグをリセット
    }
  }
}

中長期的な改善案

1. 統一認証ミドルウェアの導入

class UnifiedAuthMiddleware
  def initialize(app)
    @app = app
  end
  
  def call(env)
    request = ActionDispatch::Request.new(env)
    
    # Firebase トークンの検証
    if (token = extract_firebase_token(request))
      user = authenticate_with_firebase_token(token)
      if user
        # Rails セッションと Firebase 認証を同期
        sync_auth_state(request.session, user)
      end
    end
    
    @app.call(env)
  end
  
  private
  
  def sync_auth_state(session, user)
    session[:firebase_uid] = user.firebase_uid
    session[:last_auth_sync] = Time.current
  end
end

2. 認証状態の一元管理

class UnifiedAuthManager {
  constructor() {
    this.authState = 'loading'
    this.listeners = []
    this.initializeAuth()
  }
  
  async initializeAuth() {
    // Firebase 認証の初期化
    this.firebaseAuth = getAuth()
    
    // Rails セッションの確認
    const railsAuth = await this.checkRailsAuth()
    
    // 認証状態の統一
    await this.synchronizeAuthState(this.firebaseAuth.currentUser, railsAuth)
    
    // 認証状態の監視開始
    this.firebaseAuth.onAuthStateChanged(this.handleAuthStateChange.bind(this))
  }
  
  async handleAuthStateChange(firebaseUser) {
    // Firebase 認証状態が変更された際の処理
    if (firebaseUser) {
      this.authState = 'authenticated'
      await this.syncWithRails(firebaseUser)
    } else {
      this.authState = 'unauthenticated'
      await this.clearRailsAuth()
    }
    
    // 状態変更をリスナーに通知
    this.notifyListeners()
  }
  
  async syncWithRails(firebaseUser) {
    const idToken = await firebaseUser.getIdToken(true)
    
    await fetch('/api/auth/sync', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${idToken}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        firebase_uid: firebaseUser.uid
      })
    })
  }
}

根本的な解決案:認証アーキテクチャの統一

最も理想的な解決策は、認証アーキテクチャを統一することです:

アプローチ1: Firebase 認証を主軸とした設計

class ApplicationController < ActionController::Base
  before_action :authenticate_with_firebase_token!
  
  private
  
  def authenticate_with_firebase_token!
    token = request.headers['Authorization']&.sub(/^Bearer /, '')
    return redirect_to login_path unless token
    
    begin
      # Firebase トークンを直接検証
      decoded_token = FirebaseTokenVerifier.verify(token)
      @current_user = User.find_by(firebase_uid: decoded_token['user_id'])
    rescue => e
      # トークンが無効な場合
      redirect_to login_path
    end
  end
  
  # セッションを使用しない
  def current_user
    @current_user
  end
end

アプローチ2: Rails セッションを主軸とした設計

// Firebase を認証のみに使用し、セッション管理は Rails に委譲
class RailsCentricAuth {
  async authenticateWithFirebase(email, password) {
    // Firebase で認証のみ実行
    const userCredential = await signInWithEmailAndPassword(auth, email, password)
    const idToken = await userCredential.user.getIdToken()
    
    // Rails にトークンを送信してセッション開始
    const response = await fetch('/auth/firebase_login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id_token: idToken })
    })
    
    if (response.ok) {
      // Firebase 側はログアウト(Rails セッションのみ使用)
      await signOut(auth)
      window.location.href = '/dashboard'
    }
  }
}

まとめ

Rails アプリケーションで Firebase Authentication を使用する際の二重管理問題について解説しました。

重要なポイント

  1. Rails セッションFirebase 認証は異なる仕組みで動作する
  2. 同期タイミングのずれが不整合の主な原因
  3. 短期的対処法で症状は軽減できるが、根本解決には設計変更が必要
  4. 認証アーキテクチャの統一が最も確実な解決策

初心者エンジニアへのアドバイス

  • 認証システムは複雑になりがちなので、シンプルな設計を心がける
  • ログ出力を充実させて問題の早期発見に努める
  • 段階的な改善で既存システムに影響を与えないよう注意する
  • 公式ドキュメントを活用して最新のベストプラクティスを学ぶ

実務でよくある対処パターン

多くの現場では、まず短期的対処法を適用して症状を軽減し、その後時間をかけて根本的な改善を行うアプローチが取られます。

  1. 緊急対応:認証プロセス中のトークン更新制御
  2. 安定化:Firebase 認証状態の監視追加
  3. 改善:統一認証ミドルウェアの導入
  4. 最適化:認証アーキテクチャの統一

この記事で説明した内容を参考に、あなたのプロジェクトに最適な認証システムを構築してください。認証は Web アプリケーションの根幹部分なので、安全性使いやすさの両立を目指して設計することが重要です。

参考リンク