satococoa's blog

主にサーバーサイド、Web 系エンジニアのブログです。Go, Ruby, React, GCP, ...etc.

Firebase Authentication の ID トークンを Ruby で検証する

ID トークンを確認する | Firebase にあるように、Fireabse Authentication によって発行された ID トークンを正しく検証することにより、そのユーザの user_id を確認することができます。

Firebase Admin SDK が提供されていればそれを使うことで簡単に検証できるのですが、Ruby 版は提供されていないので Rails から使いたい場合などは自分で検証処理を書くことになります。

検証すべき内容は ID トークンを確認する | Firebase に書いてあるのでそれに沿って書いていきます。

要: JWT gem

# @see https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja
#
# Usage:
#    validator = FirebaseAuth::TokenValidator.new(token)
#    payload = validator.validate!
#
class FirebaseAuth::TokenValidator
  class InvalidTokenError < StandardError; end

  ALG = 'RS256'
  CERTS_URI = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'
  CERTS_CACHE_KEY = 'firebase_auth_certificates'
  PROJECT_ID = 'YOUR_PROJECT_ID'
  ISSUER_URI_BASE = 'https://securetoken.google.com/'

  def initialize(token)
    @token = token
  end

  #
  # Validates firebase authentication token
  #
  # @raise [InvalidTokenError] validation error
  # @return [Hash] valid payload
  #
  def validate!
    options = {
      algorithm: ALG,
      iss: ISSUER_URI_BASE + PROJECT_ID,
      verify_iss: true,
      aud: PROJECT_ID,
      verify_aud: true,
      verify_iat: true,
    }
    payload, _ = JWT.decode(@token, nil, true, options) do |header|
      cert = fetch_certificates[header['kid']]
      if cert.present?
        OpenSSL::X509::Certificate.new(cert).public_key
      else
        nil
      end
    end

    # JWT.decode でチェックされない項目のチェック
    raise InvalidTokenError.new('Invalid auth_time') unless Time.zone.at(payload['auth_time']).past?
    raise InvalidTokenError.new('Invalid sub') if payload['sub'].empty?

    payload
  rescue JWT::DecodeError => e
    Rails.logger.error e.message
    Rails.logger.error e.backtrace.join("\n")

    raise InvalidTokenError.new(e.message)
  end

  private

  # 証明書は毎回取得せずにキャッシュする (要: Rails.cache)
  def fetch_certificates
    cached = Rails.cache.read(CERTS_CACHE_KEY)
    return cached if cached.present?

    res = Net::HTTP.get_response(URI(CERTS_URI))
    raise 'Fetch certificates error' unless res.is_a?(Net::HTTPSuccess)

    body = JSON.parse(res.body)
    expires_at = Time.zone.parse(res.header['expires'])
    Rails.cache.write(CERTS_CACHE_KEY, body, expires_in: expires_at - Time.current)

    body
  end
end