follmy

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.rb

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

jwt.rb

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.rb

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のコード読むの難しい。

参考

ID トークンを検証する

Firebase Authentication の ID トークンを Ruby で検証する

class OpenSSL::X509::Certificate

公開鍵暗号と電子署名の基礎知識

JWT・Cookie それぞれの認証方式のメリデメ比較


プロフィール

koyamaaa2です。

プライバシーポリシー