Help us understand the problem. What is going on with this article?

RubyでFirebaseのidトークンを認証に使ってみる

More than 1 year has passed since last update.

本記事はFirebase Advent Calendar 201611日目の記事です。

さていきなりですが、アプリケーションからFirebaseにログインすると、そのユーザー用のIDトークンが発行されますよね。
このトークンは内部的にFirebaseの各種リソースにアクセスするときに利用されるだけでなく、公式のSDKを利用することでサーバーサイドでの認証に使うことが可能です。

手続きとしてはシンプルで、各SDKが用意してある検証用メソッドにトークンを渡すと、検証が成功した際にはトークンをデコードした情報が受け取れるようになっています。
ここからuidや認証に使用したプロバイダ情報を取得することができて便利。

しかし、Rubyのような公式SDKがない言語で認証を行おうとすると、トークンの検証を自分でする必要が出てきて面倒なのですが、最近それに向き合う必要性がでてきました。

で、実際にどう向き合ったかというと

node.jsのメソッドをRubyに移植した

ということになります。自分でもびっくりするくらい知性がゼロですね。
では、その結果をどうぞ。

firebase_config.yml
  project_info:
    project_number: <your-project-number>
    firebase_url: "https://<your-database-name>.firebaseio.com"
    project_id: "<your-project-id>"
    secret: "<your-secret>"
firebase_utils/auth.rb
require 'jwt'
require 'yaml'

module FirebaseUtils
  CONFIG = YAML.load_file("firebase_config.yml")

  module Auth
    ALGORITHM = 'RS256'
    ISSUER_BASE_URL = 'https://securetoken.google.com/'
    CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'
    class << self
      def verify_id_token(token)
        raise 'id token must be a String' unless token.is_a?(String)

        full_decoded_token = _decode_token(token)

        err_msg = _validate_jwt(full_decoded_token)
        raise err_msg if err_msg

        public_key = _fetch_public_keys[full_decoded_token[:header]['kid']]
        unless public_key
          raise 'Firebase ID token has "kid" claim which does not correspond to ' +
            'a known public key. Most likely the ID token is expired, so get a fresh token from your client ' +
            'app and try again.'
        end

        certificate = OpenSSL::X509::Certificate.new(public_key)
        decoded_token = _decode_token(token, certificate.public_key, true, { algorithm: ALGORITHM, verify_iat: true })

        {
          'uid' => decoded_token[:payload]['sub'],
          'decoded_token' => decoded_token
        }
      end

      private
      def _decode_token(token, key=nil, verify=false, options={})
        begin
          decoded_token = JWT.decode(token, key, verify, options)
        rescue JWT::ExpiredSignature => e
          raise 'Firebase ID token has expired. Get a fresh token from your client app and try again.'
        rescue => e
          raise "Firebase ID token has invalid signature. #{e.message}"
        end

        {
          payload: decoded_token[0],
          header: decoded_token[1]
        }
      end

      def _fetch_public_keys
        uri = URI.parse(CLIENT_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)

        if (data['error']) then
          msg = 'Error fetching public keys for Google certs: ' + data['error']
          msg += " (#{res['error_description']})" if (data['error_description'])

          raise msg
        end

        data
      end

      def _validate_jwt(json)
        project_id = FirebaseUtils::CONFIG[:project_info][:project_id]
        payload = json[:payload]
        header = json[:header]

        return 'Firebase ID token has no "kid" claim.' unless header['kid']
        return "Firebase ID token has incorrect algorithm. Expected \"#{ALGORITHM}\" but got \"#{header['alg']}\"." unless header['alg'] == ALGORITHM
        return "Firebase ID token has incorrect \"aud\" (audience) claim. Expected \"#{project_id}\" but got \"#{payload['aud']}\"." unless payload['aud'] == project_id

        issuer = ISSUER_BASE_URL + project_id
        return "Firebase ID token has incorrect \"iss\" (issuer) claim. Expected \"#{issuer}\" but got \"#{payload['iss']}\"."  unless payload['iss'] == issuer

        return 'Firebase ID token has no "sub" (subject) claim.' unless payload['sub'].is_a?(String)
        return 'Firebase ID token has an empty string "sub" (subject) claim.'  if payload['sub'].empty?
        return 'Firebase ID token has "sub" (subject) claim longer than 128 characters.' if payload['sub'].size > 128

        nil
      end
    end
  end
end

firebase_config.ymlには、マネジメントコンソールから取得できる情報を入れておきましょう。

使い方
begin
  decoded_token = FirebaseUtils::Auth.verify_id_token(passed_token)
  # do stuff
rescue => e
  # error handling
end

これでRubyでも認証できるようになりました!
コピペのおかげでエラーメッセージが最高にネイティブで最高ですね!

これでアドベントカレンダー13日目は終了です!明日は@hatahataさんの記事です!
楽しみですね!

解説

はい。さすがにこれはよくないですね。解説します。
Firebaseのトークンを検証するには、大雑把にわけて下記3つのフェーズを経ることで実現できます。

  • JWTをデコード
  • トークン情報のフォーマットチェック
  • トークンが正しい秘密鍵から作られているか確認

では、それぞれの処理部を見ていきましょう。

JWTをデコード

まずはここからです。ご存じの方も多いと思いますがJWTとはJSON Web Tokenの略称で、簡単にいうと改ざんがすごくむずかしいJSONです。認証サーバーが発行するトークンによく使われます(とこちらの記事で勉強しました!ありがとうございます)。
FirebaseのトークンもこのJWTが採用されているので、トークン自体はJWTを扱えるようになれば簡単にデコードできるようになります。

それがこのメソッドです。

decode_token
def _decode_token(token, key=nil, verify=false, options={})
  begin
    decoded_token = JWT.decode(token, key, verify, options)
  rescue JWT::ExpiredSignature => e
    raise 'Firebase ID token has expired. Get a fresh token from your client app and try again.'
  rescue => e
    raise "Firebase ID token has invalid signature. #{e.message}"
  end

  {
    payload: decoded_token[0],
    header: decoded_token[1]
  }
end

RubyにはいくつかのJWTを扱うgemがありますが、今回はruby-jwtを使用しています。
このメソッドではruby-jwtの機能:JWT.decodeを少し使いやすくするためにラップしてるだけで、特に複雑なことはしておりません。エラーハンドリングが雑なのはご愛嬌。
ちなみに、JWT.decodeの結果はこんな感じで返ってきます。

[
  {
    "iss" => "https://securetoken.google.com/xxxx",
    "aud" => "xxxx",
    "auth_time" => 1475483334,
    "user_id" => "hogehogehogehoge",
    "sub" => "fugafugafuga",
    "iat" => 1476255544,
    "exp" => 1476259144,
    "firebase" => {
      "identities" => {
        "twitter.com" => ["99999999"]
      },
      "sign_in_provider"=>"twitter.com"
    }
  },
  {"alg"=>"RS256", "kid"=>"hogefugahoge"}
]

JWTは改ざんがあったらそもそもデコードできずに落ちるので、このデータが受け取れている時点で改ざんなしのJWTだったということがわかります。
では、次に行きましょう。

トークン情報のフォーマットチェック

続いて、トークンの中身がFirebaseの認証トークンとして正しいフォーマットになっているかをチェックします。それがこのメソッドになります。

_validate_jwt
def _validate_jwt(json)
  project_id = FirebaseUtils::CONFIG[:project_info][:project_id]
  payload = json[:payload]
  header = json[:header]

  return 'Firebase ID token has no "kid" claim.' unless header['kid']
  return "Firebase ID token has incorrect algorithm. Expected \"#{ALGORITHM}\" but got \"#{header['alg']}\"." unless header['alg'] == ALGORITHM
  return "Firebase ID token has incorrect \"aud\" (audience) claim. Expected \"#{project_id}\" but got \"#{payload['aud']}\"." unless payload['aud'] == project_id

  issuer = ISSUER_BASE_URL + project_id
  return "Firebase ID token has incorrect \"iss\" (issuer) claim. Expected \"#{issuer}\" but got \"#{payload['iss']}\"."  unless payload['iss'] == issuer

  return 'Firebase ID token has no "sub" (subject) claim.' unless payload['sub'].is_a?(String)
  return 'Firebase ID token has an empty string "sub" (subject) claim.'  if payload['sub'].empty?
  return 'Firebase ID token has "sub" (subject) claim longer than 128 characters.' if payload['sub'].size > 128

  nil
end

短い処理ではありますが、いろんなものをチェックしてますので箇条書きで説明します。

  • キーIDが存在していること
  • 署名の作成アルゴリズムがRS256であること
  • aud(audience)が自分のFirebaseのプロジェクトIDとなっていること
  • 署名の発行者がhttps://securetoken.google.com/<aud>となっていること
  • sub(Subject=Firebase uid)が1文字以上128文字以下の文字列であること

これらを満たしていれば、少なくともFirebaseの発行したJWTのフォーマットとして正しいとされます。
uidって128文字制限があるんですね。しらなかった。

トークンが正しい鍵から作られているか確認する

あとは、Googleが用意した鍵からトークンが生成されているかどうかを確認します。
まずは、公開鍵をGoogleから取得してきます。

fetch_certificates
def _fetch_public_keys
  uri = URI.parse(CLIENT_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)

  if (data['error']) then
    msg = 'Error fetching public keys for Google certs: ' + data['error']
    msg += " (#{res['error_description']})" if (data['error_description'])

    raise msg
  end

  data
end

ここですね。通信が正しく行えていれば複数の公開鍵が返ってきますので、その中からkidをキーにもつものを使用しましょう。
また、レスポンスのcache-controlを利用して公開鍵の情報をキャッシュしておくとなお良いです(今回は本質じゃないので省きました)。

公開鍵がとれてきたら、次は証明書を作ります。Googleの公開鍵はX.509にて作成されていますので、それに従って作成します。

x509 = OpenSSL::X509::Certificate.new(public_key)

ここまでできたら、あとは証明書を使ってトークンがデコードできれば認証完了です!

decoded_token = decode_token(token, x509.public_key, true, { algorithm: ALGORITHM, verify_iat: true })

お粗末さまでした。

最後に

実はこの「SDKがない言語でも認証をさせる」という要件はFirebase側も認識しているようで、先程のドキュメントにあるverify_id_tokens_using_a_third-party_jwt_libraryの項に実装の仕方を書いてくれています。
ただ、恥ずかしながら自分は認証周りについてとことん疎く、これを読んでも実装のイメージが全然湧かず詰んだ。。。となり、Node.jsを参考に実装してみた、という背景が有ります。
しかし、コードを移植していくと何をやってるかなんとなくわかり、あらためてドキュメントを読んだら大分内容が頭に入ってくるようになったので記事にしてみました。

少しでも同じような方の助けになれば幸いです。
また、言葉の使い方とか変だったら編集リクエストをなげてもらえるとうれしいです!

otakky
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした