Rubyでjwtを使ったjwt検証でpayloadを取得するまでの流れをざっくり確認してみた
2020-12-27
Firebase Authenticationのトークンを検証してuidを取り出すためにjwtの検証が必要で、rubyにはFirebaseから検証のSDKが提供されていないらしく、ruby-jwt を使って検証コードが必要になり、その際に処理の流れを確認したので、そのメモ。
間違いあれば@koyamaaaa にメッセージ等ください。
環境
ruby 2.7.1
rails (6.0.3)
jwt (2.2.2)
検証の流れ
・クライアントからFirebaseで生成されたトークンが送られてくる
・トークンに含まれるkidを使って公開鍵を取得する
kidは、Fireabseでkeyvalue形式で保管されている特定のvalueを取得するためのkey、対応するvalueが公開鍵。
・取得した公開鍵が正しいかどうかをjwtを使って検証
・検証して問題なければ取得したpayloadを返却
補足:公開鍵でも安全性と有効性は確認しないといけない
公開鍵だからといって問題のない公開鍵とは限らないため、公開鍵が正しいかどうかを確認する必要があるらしい。詳しくはこちら
補足:OpenSSL::X509::Certificate.new(obj)
OpenSSL::X509::Certificate.new(obj)はobjが文字列なら、見合った形式の証明書オブジェクトを生成してくれるらしい。不正や読み込みエラーの場合は「OpenSSL::X509::CertificateError」が返るらしい。
objがStringである場合には、それをPEM形式もしくはDER形式の証明書データの文字列であると見なしてその内容から証明書オブジェクトを生成します。 objがIOである場合には、そのファイルの中身から証明書オブジェクトを生成します。
jwtのデコードの流れをざっくり確認
どんな処理をしているのかわからないとpayload取得の仕組みがわからず書けないので、こちらを参考に確認してみた。
JWT.decode(@token, nil, true, options)を呼ぶ
まずはJWT.decodeでパラメータを渡しているぽい。また、decodeにブロック引数を渡しているのがわかる。
payload, _ = JWT.decode(@token, nil, true, options) do |header|
cert = fetch_certificates[header['kid']]
if cert.present?
OpenSSL::X509::Certificate.new(cert).public_key
else
nil
end
end
JWT.decodeの中身
JWT.decodeの中身はこれ。Decodeオブジェクトを作成してdecode_segmentsを呼んでいる。
def decode(jwt, key = nil, verify = true, options = {}, &keyfinder)
Decode.new(jwt, key, verify, DEFAULT_OPTIONS.merge(options), &keyfinder).decode_segments
end
Decode.newの中身
Decode.new の中身はこんな感じ
def initialize(jwt, key, verify, options, &keyfinder)
raise(JWT::DecodeError, 'Nil JSON web token') unless jwt
@jwt = jwt
@key = key
@options = options
@segments = jwt.split('.')
@verify = verify
@signature = ''
@keyfinder = keyfinder
end
decode_segmentsの中身
decode_segmentsの中身はこれ。各検証の結果、問題なければpayloadとheaderを返してくれる。
def decode_segments
validate_segment_count!
if @verify
decode_crypto
verify_signature
verify_claims
end
raise(JWT::DecodeError, 'Not enough or too many segments') unless header && payload
[payload, header]
end
validate_segment_count!はオブジェクトの値がただしいかどうかの確認をしているぽい感じだった。@verifyの分岐はオブジェクト作成時にtrueを渡しているので、中に入ることになる。decode_cryptoは@segmentsに入っている配列の二つ目をBase64でデコードしている。
def decode_crypto
@signature = JWT::Base64.url_decode(@segments[2] || '')
end
@segments
@segmentsはinitializeの中にあったこれ
@segments = jwt.split('.')
jwt.splitのjwtはJWT.decode(@token, …)の@token が入っている。@tokenはクライアントから送られてくるヘッダーに含まれた認証トークンのこと、つまり今回検証したい jwtのトークン。jwtのトークンはこんなやつ
eyJhbGciOiJSUzI1NiIsImtpZCI6IjNjYmM4ZjIyMDJmNjZkMWIxZTEwMTY1OTFhZTIxNTZiZTM5NWM2ZDciLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vc3BvdGxpbmUtNDRhNjUiLCJhdWQiOiJzcG90bGluZS00NGE2NSIsImF1dGhfdGltZSI6MTYwODcyODE2OCwidXNlcl9pZCI6IkVVY0Uyek1MdGxNYWFkZ1lRUllvcUc4d1BGRjIiLCJzdWIiOiJFVWNFMnpNTHRsTWFhZGdZUVJZb3FHOHdQRkYyIiwiaWF0IjoxNjA4NzI4MTY4LCJleHAiOjE2MDg3MzE3NjgsImVtYWlsIjoieWt3bTcuMi43QGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJ5a3dtNy4yLjdAZ21haWwuY29tIl19LCJzaWduX2luX3Byb3ZpZGVyIjoicGFzc3dvcmQifX0.ehZ2UlA9nCdwOtpmPsNZMFYUtfMaHOSSOqK7NFgOI-W4zfgueaCfDbiaUKARRYAsqN0lG9awcbFeL1ZMZG56LZaI_coBsaOB8YNXiKXQvZBR0KpOayz-eUzqlmOwJEYaLmCn69GyIqN06LaOddGij1uWQlG_LcVCye-hjaHvFq93tIuLSGbVEd-dUu6us02bc3ZAc51Sbcc9-LbImoI8dq2d4hMGEbeGmBgM6CNU9TziLg0QsX_QjZmAOJ9PBrJ2de1SK5zxNQrkB_nxheCVtqF-SoA4blhzuhnLpJeU4k0EpG3HuIMDKBujB4aLYiH5OqxWT1qtaIxj2UpCdKD4cQ
// ピリオドで区切られているのを分けると
eyJhbGciOiJSUzI1NiIsImtpZCI6IjNjYmM4ZjIyMDJmNjZkMWIxZTEwMTY1OTFhZTIxNTZiZTM5NWM2ZDciLCJ0eXAiOiJKV1QifQ
.
eyJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vc3BvdGxpbmUtNDRhNjUiLCJhdWQiOiJzcG90bGluZS00NGE2NSIsImF1dGhfdGltZSI6MTYwODcyODE2OCwidXNlcl9pZCI6IkVVY0Uyek1MdGxNYWFkZ1lRUllvcUc4d1BGRjIiLCJzdWIiOiJFVWNFMnpNTHRsTWFhZGdZUVJZb3FHOHdQRkYyIiwiaWF0IjoxNjA4NzI4MTY4LCJleHAiOjE2MDg3MzE3NjgsImVtYWlsIjoieWt3bTcuMi43QGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJ5a3dtNy4yLjdAZ21haWwuY29tIl19LCJzaWduX2luX3Byb3ZpZGVyIjoicGFzc3dvcmQifX0
.
ehZ2UlA9nCdwOtpmPsNZMFYUtfMaHOSSOqK7NFgOI-W4zfgueaCfDbiaUKARRYAsqN0lG9awcbFeL1ZMZG56LZaI_coBsaOB8YNXiKXQvZBR0KpOayz-eUzqlmOwJEYaLmCn69GyIqN06LaOddGij1uWQlG_LcVCye-hjaHvFq93tIuLSGbVEd-dUu6us02bc3ZAc51Sbcc9-LbImoI8dq2d4hMGEbeGmBgM6CNU9TziLg0QsX_QjZmAOJ9PBrJ2de1SK5zxNQrkB_nxheCVtqF-SoA4blhzuhnLpJeU4k0EpG3HuIMDKBujB4aLYiH5OqxWT1qtaIxj2UpCdKD4cQ
JSONWebToken(JWTの紹介とYahoo!JAPANにおけるJWTの活用
上から順に「ヘッダー」「ペイロード」「署名」となっているとのこと。@tokenをsplitした配列が@segmentsに入っているので、@segments[2]に入っている署名を取得してそれをデコードしている処理。
verify_signature
最初の部分は例外処理なのでスルーで、次の find_key(&keyfinder)が大切ぽい。
def verify_signature
raise(JWT::IncorrectAlgorithm, 'An algorithm must be specified') if allowed_algorithms.empty?
raise(JWT::IncorrectAlgorithm, 'Expected a different algorithm') unless options_includes_algo_in_header?
@key = find_key(&@keyfinder) if @keyfinder
@key = ::JWT::JWK::KeyFinder.new(jwks: @options[:jwks]).key_for(header['kid']) if @options[:jwks]
Signature.verify(header['alg'], @key, signing_input, @signature)
end
@keyfinder
@keyfinderはJWT.decode(…)で渡したブロック引数が入っている。
# 渡したブロック引数はここの部分
cert = fetch_certificates[header['kid']]
if cert.present?
OpenSSL::X509::Certificate.new(cert).public_key
else
nil
end
find_keyが実行されるとkey_finderが渡されてyieldされる。
def find_key(&keyfinder)
key = (keyfinder.arity == 2 ? yield(header, payload) : yield(header))
raise JWT::DecodeError, 'No verification key available' unless key
key
end
arityメソッド
arity はメソッドが受け付ける引数を返すメソッドらしい。ここでは、ブロック引数が受け取る引数のことを指す(多分)ので、引数は|header|のみ、つまり一つになる。なのでyield(header)が実行されてブロック引数が実行されて
cert = fetch_certificates[header['kid']]
if cert.present?
OpenSSL::X509::Certificate.new(cert).public_key
else
nil
end
public_keyが取得され、find_key内のkeyに渡される。ちなみに、header[‘kid’]とは冒頭あたりでも書いた、Firebaseの公開鍵を取得するkeyのこと。これを使ってここからFirebaseの公開鍵を取得している。詳細はこちら。
そしてそのpublic_keyが正しいかを下記で検証しているぽい。
Signature.verify(header['alg'], @key, signing_input, @signature)
Signature.verifyは問題なければtrueが返ってくる。
まとめ
これ以上は追わないけれど、この後にpeyloadの中身を検証するverifiy_claimsを実行 → hearder関数を実行 → payload関数を実行 → 問題なければheaderとpayloadが返却され、利用できるようになる。例えばuidはpayload[‘user_id’]で取れた。ざっくり検証までの流れはこんな感じ。
OSSのコード読むの難しい。
参考
Firebase Authentication の ID トークンを Ruby で検証する