タケユー・ウェブ日報

Ruby on Rails や Flutter といったWeb・モバイルアプリ技術を武器にお客様のビジネス立ち上げを支援する、タケユー・ウェブ株式会社の技術ブログです。

rspec で Firebase ID Token を stub

class SessionsController < ApplicationController
  def create
    id_token = params.required(:id_token)
    user = User.from_firebase(id_token)
    # (snip)
  end
end

class User < ApplicationRecord

  CIRTIFICATE_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'
  EXP_LEEWAY = 30.seconds

  def self.from_firebase(id_token)
    firebase_project_id = Rails.application.credentials.dig(:firebase, :project_id)
    valid_iss = "https://securetoken.google.com/#{firebase_project_id}"

    # https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja
    a, decoded_token_header = JWT.decode(id_token, nil, false)

    certificate = Rails.cache.fetch('firebase_securetoken_cirtificate', expires: 1.hour) do
      uri = URI.parse(CIRTIFICATE_URL)
      JSON.parse(Net::HTTP.get_response(uri).body).fetch(decoded_token_header["kid"])
    end

    public_key = OpenSSL::X509::Certificate.new(certificate).public_key
    decoded_token_payload, _ = JWT.decode(
      id_token,
      public_key,
      true,
      exp_leeway: EXP_LEEWAY,   # 有効期限の検証をするが、ゆるめに。 EXP_LEEWAY 秒は大目に見る。
      verify_iat: true,         # 発行時の検証をする
      aud: firebase_project_id,
      verify_aud: true,         # 対象の検証をする
      iss: valid_iss,
      verify_iss: true,         # 発行元の検証をする
      verify_sub: true,         # 件名の存在を検証する
      algorithm: decoded_token_header["alg"]
    )

    Rails.logger.debug("decoded_token_payload: #{decoded_token_payload.inspect}")

    # decoded_token_payload = {
    #   "name"=>"Takeuchi Yuichi",
    #   "picture"=>"https://lh3.googleusercontent.com/a-/AAuE7mAZU7Rh7lIFStzfWGe3tC24qDIX4UIoEWR8426flA",
    #   "iss"=>"https://securetoken.google.com/rails-firebase-sample",
    #   "aud"=>"rails-firebase-sample",
    #   "auth_time"=>1580712233,
    #   "user_id"=>"Qgk3sd1HgoPLVbSy8uXAWnRmWmx1",
    #   "sub"=>"Qgk3sd1HgoPLVbSy8uXAWnRmWmx1",
    #   "iat"=>1580712233,
    #   "exp"=>1580715833,
    #   "email"=>"takeyuweb@gmail.com",
    #   "email_verified"=>true,
    #   "firebase"=>{
    #     "identities"=>{
    #       "google.com"=>["100008179958237311525"],
    #       "email"=>["takeyuweb@gmail.com"]
    #     },
    #     "sign_in_provider"=>"google.com"
    #   }
    # }

    where(uid: decoded_token_payload.fetch("sub")).first_or_create
  end
end
require 'rails_helper'

RSpec.describe "/session", type: :request do
  describe 'create session' do
    context "with valid id_token" do
      it "returns Created" do
        stub_id_token do |id_token|
          post "/session", params: { id_token: id_token }
          expect(response).to have_http_status(:created)
        end
      end
    end

    context "with invalid id_token" do
      it "returns Unauthorized " do
        stub_id_token("exp" => 0) do |id_token|
          post "/session", params: { id_token: id_token }
          expect(response).to have_http_status(:unauthorized)
        end
      end
  end
end
# spec/support/firebase.rb

module FirebaseIdTokenGenerator
  def stub_id_token(override = {}, &block)
    unless defined?(@pkey)
      @pkey, @cert = generate_key_pair
      @kid = 'thekeyid'

      certificates = {
        'dummy' => generate_key_pair[1].to_pem,
        @kid => @cert.to_pem
      }
      WebMock.stub_request(:get, User::CIRTIFICATE_URL).to_return(status: 200, body: certificates.to_json)
    end

    block.call(get_id_token(generate_payload(override)))
  end

  def get_id_token(payload)
    JWT.encode(payload, @pkey, 'RS256', { kid: @kid, typ: 'JWT' })
  end

  def generate_payload(override = {})
    # https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja
    firebase_project_id = Rails.application.credentials.dig(:firebase, :project_id)
    {
      "name"=>"Takeuchi Yuichi",
      "picture"=>"https://test.host/picture.jpeg",
      "iss" => "https://securetoken.google.com/#{firebase_project_id}",
      "aud" => firebase_project_id,
      "auth_time"=> Time.current.to_i,
      "user_id"=>"theuserid",
      "sub"=>"theuserid",
      "iat"=> 1.hour.ago.to_i,
      "exp"=> 1.hour.from_now.to_i,
      "email"=>"yuichi.takeuchi@takeyuweb.co.jp",
      "email_verified"=>true,
      "firebase"=>{
        "identities"=>{
          "google.com"=>["000000000000000000000"],
          "email"=>["yuichi.takeuchi@takeyuweb.co.jp"]
        },
        "sign_in_provider"=>"google.com"
      }
    }.deep_merge(override)
  end

  def generate_key_pair
    ca_passphrase = SecureRandom.alphanumeric
    digest = OpenSSL::Digest::SHA1.new

    issu = OpenSSL::X509::Name.new
    issu.add_entry('C' , 'JP')
    issu.add_entry('ST', 'Saitama')
    issu.add_entry('DC', 'Omiya-ku')
    issu.add_entry('O' , 'TakeyuWeb, Inc.')
    issu.add_entry('CN', 'MexiCasita Test CA')

    issu_rsa = OpenSSL::PKey::RSA.generate(2048)

    issu_cer = OpenSSL::X509::Certificate.new
    issu_cer.not_before = Time.current
    issu_cer.not_after  = 10.years.from_now
    issu_cer.public_key = issu_rsa.public_key
    issu_cer.serial  = 1
    issu_cer.issuer  = issu
    issu_cer.subject = issu
    ex = OpenSSL::X509::Extension.new('basicConstraints', OpenSSL::ASN1.Sequence([OpenSSL::ASN1::Boolean(true)]))
    issu_cer.add_extension(ex)
    issu_cer.sign(issu_rsa, digest)

    return [issu_rsa, issu_cer]
  end
end

RSpec.configure do |config|
  config.include FirebaseIdTokenGenerator

  config.after(:all, type: :request) do
    remove_instance_variable(:@pkey) if defined?(@pkey)
  end
end