タケユー・ウェブ日報

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