2
0

More than 1 year has passed since last update.

dexidp/dexが発行したJWTをRubyで検証する

Last updated at Posted at 2019-12-10

はじめに

OpenID Connect Providerが発行するID TokenをRubyのJWTライブラリを利用して検証してみました。よくあるサンプルコードは自前のRSA鍵を利用して検証していたりするので、実際にdexidp/dexが発行したID TokenのJWTを検証するコードをRuby+JWTライブラリで実行してみました。

テストはDexを利用していますが(/.well-known/openid-configurationで情報を提供する)一般的なOpenID Connect Providerであれば同様に動くはずです。

環境

  • Ubuntu 20.04 LTS 64bit版
  • Packages
    • ruby (2.7.0p0)
    • ruby-bundler (2.1.4-1)
  • dexidp/dep (server, https://example.com/dex) + example-app (client)

JWTはDex付属のexample-appを利用して入手しています。Ubuntuパッケージのruby-jwtはバージョンが古く、サンプルがそのままでは実行できませんでしたので、bundlerを利用しています。

References

ライブラリの準備

作業用のディレクトリにGemfileを作成して、最新のJWTライブラリを利用しています。

Gemfile
source 'https://rubygems.org'

gem 'json'
gem 'jwt'
gem 'httpclient'
gem 'openssl'

Gemfileのある作業用ディレクトリで、次のコマンドを実行し、./lib/ruby以下にライブラリファイルをダウンロードします。

$ bundle config set path lib
$ bundle install
Fetching gem metadata from https://rubygems.org/..
Resolving dependencies...
Using bundler 2.1.4
Fetching httpclient 2.8.3
Installing httpclient 2.8.3
Fetching json 2.5.1
Installing json 2.5.1 with native extensions
Fetching jwt 2.2.3
Installing jwt 2.2.3
Fetching openssl 2.2.0
Installing openssl 2.2.0 with native extensions
Bundle complete! 4 Gemfile dependencies, 5 gems now installed.
Bundled gems are installed into `./lib`

Dexが発行したID Tokenの準備

"idtoken.txt"ファイルにID Tokenの内容をコピーしておきます。ピリオドで3つのBase64エンコードされた情報が連結されていますが、Webブラウザに出力されたID Token(JWT)をそのままコピーしています。

idtoken.txt
eyJhbGc ... wOTMifQ.eyJpc3MiO ... EFiZSJ9.r_vHqAr3 ... hjvzhzf9w

ruby-jwtのサンプルコードを試す

次のようなファイルを準備し、bundle execコマンドで実行します。JWKS_URI変数とJWT_TOKEN_FILE変数の内容は適切なURL,ファイル名に変更してください。

verify-idtoken.rb
#!/usr/bin/ruby

require 'bundler/setup'
Bundler.require

## prepare JWT string
JWT_TOKEN_FILE = "./idtoken.txt"
jwt_text = open(JWT_TOKEN_FILE).read()

puts "--- unverified output ---"
claim,algo = JWT.decode(jwt_text, nil, false)
puts claim,algo ## the algo object will be used later.


## find jwks_uri from the unverified claim
oidc_config = JSON.parse(HTTPClient.new.get(claim["iss"] + "/.well-known/openid-configuration").body, symbolize_names: true)
JWKS_URI = oidc_config[:jwks_uri]

puts "--- verified output ---"
## from: https://github.com/jwt/ruby-jwt/tree/v2.2.1#json-web-key-jwk
jwk_loader = ->(options) do
  ## from: https://qiita.com/zakuroishikuro/items/5b08d65d4fef79982f19
  pub_keys = JSON.parse(HTTPClient.new.get(JWKS_URI).body, symbolize_names: true)
  @cached_keys = nil if options[:invalidate] # need to reload the keys
  @cached_keys ||= pub_keys
end
claim,algo = JWT.decode(jwt_text, nil, true, { algorithms: [algo["alg"]], jwks: jwk_loader }) ## algo["alg"] == "RS256"
puts claim,algo

実際には最初のclaim["iss"]が信頼できるサイトかどうか確認する必要がありますが、渡された情報から検証するようにコードを準備しました。

サンプルコードの実行

ライブラリのサンプルを参考にコードを準備してみました。
実行権限を付与するかrubyコマンドの引数にverify-idtoken.rbを指定して実行できます。

$ chmod +x verify-idtoken.rb
$ ./verify-idtoken.rb
実行結果
--- unverified output ---
{"iss"=>"https://example.com/dex", "sub"=>"Cj....cA", "aud"=>"example-app", "exp"=>1576040265, ..., "iat"=>1575953865, "name"=>"Yasuhiro Abe"}
{"alg"=>"RS256", "kid"=>"be215b1....460975ac"}
--- verified output ---
{"iss"=>"https://example.com/dex", "sub"=>"Cj....cA", "aud"=>"example-app", "exp"=>1576040265, ..., "iat"=>1575953865, "name"=>"Yasuhiro Abe"}
{"alg"=>"RS256", "kid"=>"be215b1....460975ac"}

うまくいっているように見えて、実際には大きな問題があります。

サンプルコードの問題点

問題はこのコードは実際には検証をしていない点にあります。JWKS_URI変数に間違った内容を返すページを指定しても動作してしまいます。原因はdecode()の第二引数にnilを指定している点で、期待する動作をさせるにはOpenSSL::PKey::RSAオブジェクトを指定する必要があります。

まず、PEM形式のファイルを準備する必要があり、PEM形式のファイルを生成するJavaのコードが下記のリンク先にあります。

JWKTest.javaの変更点は以下のとおりです。

  1. package文を適切に変更する (私はpackage文は削除して、カレントディレクトリにJWKTest.javaファイルを配置しています)
  2. String modulusB64uに格納する文字列を調査するため、OIDCプロバイダーが提供するhttps://..../.well-known/openid-configurationからjwks-uri:に表示されているURLにアクセスし、keys:のリストから"kid"=>"be215b1....460975ac"に該当する鍵の(n:,e:)の各値の組を入手し、n:に対応する文字列をmodulusB64u変数に格納します。e:の方はexponentB64u変数に格納しますが、"AQAB"で同じなので変更していません。
  3. String jwtには、example-appなどから入手したID Tokenを格納する。

JWKTest.javaのコードを適宜編集したら、次の要領でPEMファイルを作成します。

javaのコードからkeys.pemファイルを入手する
$ javac JWKTest.java
$ java JWKTest > keys.pem

## 念のため内容を確認
$ cat keys.pem
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqh ... plox7wIDAQAB
-----END PUBLIC KEY-----
true

pemファイル(ここでは"keys.pem")を入手したら、rubyのコードを少し修正し、検証してみます。

最初のコードとの差分
--- verify-idtoken.rb   2020-06-12 18:11:51.805369094 +0900
+++ verify-idtoken-pem.rb       2020-06-12 18:17:58.574557211 +0900
@@ -20,6 +20,7 @@
 JWKS_URI = openid_config[:jwks_uri]

 puts "--- verified output ---"
+rsa = OpenSSL::PKey::RSA.new(open("keys.pem").read())
 ## from: https://github.com/jwt/ruby-jwt/tree/v2.2.1#json-web-key-jwk
 jwk_loader = ->(options) do
   ## from: https://qiita.com/zakuroishikuro/items/5b08d65d4fef79982f19
@@ -28,5 +29,5 @@
   @cached_keys = nil if options[:invalidate] # need to reload the keys
   @cached_keys ||= pub_keys
 end
-claim,algo = JWT.decode(jwt_text, nil, true, { algorithms: [algo["alg"]], jwks: jwk_loader }) ## algo["alg"] == "RS256"
+claim,algo = JWT.decode(jwt_text, rsa, true, { algorithms: [algo["alg"]], jwks: jwk_loader }) ## algo["alg"] == "RS256"
 puts claim,algo

これでID Tokenの検証ができましたが、途中で、人間の目とJavaのコードを挟むのはシンプルとはいえないので、Rubyだけで完結する方法を模索します。

jwt.ioによるID Tokenの検証

先に進む前に、ここまでで問題がないか確認します。

https://jwt.io/を利用し、ID Tokenの内容を左側のEncodedにコピーし、先程表示させたPEM形式ファイルの内容を、右側下の"Verify Signature"の公開鍵の欄に入力すると、”signature verified”と表示され、内容が正しいことが分かります。

Public Keyの情報を入力しても、Invalidと表示される場合には、JWKTest.javaファイルの中で、exponentB64u, modulusB64u, jwt の各変数を書き換えているか確認してください。

なおPrivate Keyの情報は空欄で構いません。

必要な情報の入手について

https://example.com/dex/.well-known/openid-configuration にアクセスすると、ここから先のコード中で参照するjwks_uriに対応するURLが表示されます。

頻繁に変化するものでもないと思いますが、コード中ではこのURL文字列をJWKS_URI定数に代入しています。

openid-configurationの抜粋
{
  "issuer": "https://example.com/dex",
...
  "jwks_uri": "https://example.com/dex/keys",
...
}

JWKS_URIで参照しているデータは次のような構造をしています。

JWKS_URIの抜粋
{
  "keys": [
    {
      "use": "sig",
      "kty": "RSA",
      "kid": "e11d6368766d4a31871c04a629bfac7fb1c484fd",
      "alg": "RS256",
      "n": "1PkCjrNZXPnnc4YD4tJwb9rGQxRc1JBn58TSaN1yB1u6Ub3NzhrhoLc65j45RK2b-8hlaN_V4aYGS-V6ao9W70fozZi79h3EAN0FjGV_jLI6yoYeohEa_dfCl28BMfcUf304mEL7tkNKroeDCwd7a6ffqUseg2OFbfSo5aTzG6buFcCaqjaX7Ww2CO1Cg1bRzSsS56tvv_ib7-wrKRJtFAdsflQSVAakE1fKP4Eb7cq3eXHa5tSxUOWcUh-oOz6jnmCHcL6Vs0-i8YA91Al2DlBohdZlA6lissSHVMuSWQ_3sCumG3RBycG9hkFB9BwL6AIw0_D7UTnnyUloRexBtQ",
      "e": "AQAB"
    },
...

OpenSSL::PKeyオブジェクトを利用いた検証

最初は、rubyの中でmodulusとexponentを抽出して、OpenSSL::PKey::RSAオブジェクトを作ろうとしたところ、うまくいかなかったので、これを解決するために、参考にしたのが下記のリンク先で、OpenSSL::PKey::RSA.set_keyを使う方法を利用しています。

先ほど利用いたrubyスクリプトを別名でコピーし、`puts "--- verified output ---"以下全体を次のように書き換えます。

JWKS_URIを使用したJWTの検証
puts "--- verified with generated RSA public key ---"
pub_keys = JSON.parse(HTTPClient.new.get(JWKS_URI).body)
pub_key = nil
pub_keys["keys"].each{|key|
  pub_key = key if key["kid"] == algo["kid"]
}
modulus = Base64.urlsafe_decode64(pub_key["n"])
exponent = Base64.urlsafe_decode64(pub_key["e"])
pkey = OpenSSL::PKey::RSA.new
pkey.set_key(modulus.unpack('B*').first.to_i(2), exponent.unpack('B*').first.to_i(2), nil)

begin
  claim,algo = JWT.decode(jwt_text, pkey, true, { algorithms: [algo["alg"]] }) ## algo["alg"] == "RS256"
  puts claim,algo
rescue 
  puts "error"
end

Javaのコードは16進数文字列を使っていましたが、今回のmodulus, exponentは2進数文字列に変換してから整数にしています。unpack('H*').first.to_i(16)で16進数文字列にしてから整数にしても結果は同じです。

puts pkey でPEM形式の出力をすると、JWTTest.javaとは改行が入っているため見た目は少し違いますが、内容は同一になります。

今回は横着をしてJWKS_URIから直接に鍵情報を取得してRSAと決め打ちをして処理をしていますが、より一般化するのであればJSON::JWKを利用して適切なOpenSSL::PKeyオブジェクトを取得する方法が良さそうです。

JSON::JWKを利用した検証 (RSAとECに対応)

OpenSSL::PKeyオブジェクトを得る方法としては、JSON::JWKを利用する方法が一番簡単そうです。RSAの他に、OpenSSL::PKey::ECにも対応しています。

requrie 'json/jwt'
pub_keys = JSON.parse(HTTPClient.new.get(JWKS_URI).body)
pub_key = nil
pub_keys["keys"].each{|key|
  pub_key = key if key["kid"] == algo["kid"]
}
jwk = JSON::JWK.new pub_key ## jwk is an instance of OpenSSL::PKey::RSA or OpenSSL::PKey::EC
begin
  claim,algo = JWT.decode(jwt_text, jwk.to_key, true, { algorithms: [algo["alg"]] }) ## algo["alg"] == "RS256"
  puts claim,algo
rescue 
  puts "error"
end

JWKTest.java の代りに、rubyのコードでPEM形式の公開鍵を生成する

jwt.ioでID Tokenを検証するために、公開鍵情報が必要になるので、これまで JWTest.java に少し手を入れていました。
これまでのrubyコードで、pkey変数を標準出力に書き出せば良いのですが、検証用のコードを別に作ったので、そのコードを掲載しておきます。

あらかじめJWKS_URLは変更しておいてください
#!/usr/bin/ruby

require 'bundler/setup'
Bundler.require

if ARGV.length == 0
  puts $0 + " 34b98c01b528f95ba2c83ce3898e4d4d02c1de66"
  exit
end

JWKS_URI = "https://example.com/dex/keys"
pub_keys = JSON.parse(HTTPClient.new.get(JWKS_URI).body)
pub_key = nil
pub_keys["keys"].each{ |key|
    pub_key = key if key["kid"] == ARGV[0]
}

exit 1 if pub_key == nil

modulus = Base64.urlsafe_decode64(pub_key["n"])
exponent = Base64.urlsafe_decode64(pub_key["e"])

m = modulus.unpack('B*').first.to_i(2)
e = exponent.unpack('B*').first.to_i(2)

pkey = OpenSSL::PKey::RSA.new

pkey.set_key(m,e,nil)

puts pkey

Gemfileはこんな感じになっています。

Gemfile
source 'https://rubygems.org'
gem 'openssl'
gem 'json'
gem 'jwt'
gem 'httpclient'
gem 'base64'

jwt.ioにID Tokenの情報を入れると、右側の上部にアルゴリズムと鍵を特定するための、kidパラメータが表示されます。

Gemfileとこのスクリプトgem_pem.rbを配置し、このコマンドの引数にkidパラメータの値を指定します。

コマンドライン全体
$ bundle config set path lib
$ bundle install
$ ./gem_pem.rb 34b98c01b528f95ba2c83ce3898e4d4d02c1de66

JWKS_URLはあらかじめgem_pem.rbの中に書き込んでいるので、URLにアクセスして、適当なPEMファイルに変換してくれます。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0