Edited at
FirebaseDay 11

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を参考に実装してみた、という背景が有ります。

しかし、コードを移植していくと何をやってるかなんとなくわかり、あらためてドキュメントを読んだら大分内容が頭に入ってくるようになったので記事にしてみました。

少しでも同じような方の助けになれば幸いです。

また、言葉の使い方とか変だったら編集リクエストをなげてもらえるとうれしいです!