本記事はFirebase Advent Calendar 201611日目の記事です。
さていきなりですが、アプリケーションからFirebaseにログインすると、そのユーザー用のIDトークンが発行されますよね。
このトークンは内部的にFirebaseの各種リソースにアクセスするときに利用されるだけでなく、公式のSDKを利用することでサーバーサイドでの認証に使うことが可能です。
手続きとしてはシンプルで、各SDKが用意してある検証用メソッドにトークンを渡すと、検証が成功した際にはトークンをデコードした情報が受け取れるようになっています。
ここからuidや認証に使用したプロバイダ情報を取得することができて便利。
しかし、Rubyのような公式SDKがない言語で認証を行おうとすると、トークンの検証を自分でする必要が出てきて面倒なのですが、最近それに向き合う必要性がでてきました。
で、実際にどう向き合ったかというと
node.jsのメソッドをRubyに移植した
ということになります。自分でもびっくりするくらい知性がゼロですね。
では、その結果をどうぞ。
project_info:
project_number: <your-project-number>
firebase_url: "https://<your-database-name>.firebaseio.com"
project_id: "<your-project-id>"
secret: "<your-secret>"
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を扱えるようになれば簡単にデコードできるようになります。
それがこのメソッドです。
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の認証トークンとして正しいフォーマットになっているかをチェックします。それがこのメソッドになります。
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から取得してきます。
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を参考に実装してみた、という背景が有ります。
しかし、コードを移植していくと何をやってるかなんとなくわかり、あらためてドキュメントを読んだら大分内容が頭に入ってくるようになったので記事にしてみました。
少しでも同じような方の助けになれば幸いです。
また、言葉の使い方とか変だったら編集リクエストをなげてもらえるとうれしいです!