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

# はじめに

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

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

# 環境

* Ubuntu 24.04 LTS 64bit版
* ruby (3.3.5)
* ruby-bundler (2.5.16)
* dexidp/dep (server, https://example.com/dex) + example-app (client)

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

# References

* https://github.com/jwt/ruby-jwt/tree/v2.2.1#json-web-key-jwk
* https://qiita.com/zakuroishikuro/items/5b08d65d4fef79982f19
* https://stackoverflow.com/questions/38998702/verify-a-signature-in-jwt-io
* https://stackoverflow.com/questions/46121275/ruby-rsa-from-exponent-and-modulus-strings

## ライブラリの準備

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

```ruby:Gemfile
source 'https://rubygems.org'

gem 'json'
gem 'jwt'
gem 'httpclient'
gem 'openssl'
gem 'mutex_m'
```

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

```bash
$ bundle config set path lib
$ bundle install
Fetching gem metadata from https://rubygems.org/...
Resolving dependencies...
Fetching httpclient 2.8.3
Fetching base64 0.2.0
Installing base64 0.2.0
Fetching json 2.9.1
Installing httpclient 2.8.3
Installing json 2.9.1 with native extensions
Fetching mutex_m 0.3.0
Installing mutex_m 0.3.0
Fetching openssl 3.3.0
Installing openssl 3.3.0 with native extensions
Fetching jwt 2.10.1
Installing jwt 2.10.1
Bundle complete! 5 Gemfile dependencies, 7 gems now installed.
Bundled gems are installed into `./lib`
```

## Dexが発行したID Tokenの準備

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

```json:idtoken.txt
eyJhbGc ... wOTMifQ.eyJpc3MiO ... EFiZSJ9.r_vHqAr3 ... hjvzhzf9w
```

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

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

```ruby: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().strip() ## strip() is essential to avoid future verification error.

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"]が信頼できるサイトか確認する必要があります。

## サンプルコードの実行

作成したコードを実行してみます。

```bash:idtoken.txtにID Tokenの内容を保存する
$ cat > idtoken.txt
eyJhbGci....

$ bundle exec ruby ./verify-idtoken.rb
```

正常に処理されれば次のように検証された内容が返されます。

```text:実行結果(正常系)
--- 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"}
```

``idtoken.txt``の内容が古いものだと次のように``kid``に対応する公開鍵がないとエラーになります。

```text:古いtokenの検証結果(異常系)
.../lib/ruby/3.3.0/gems/jwt-2.10.1/lib/jwt/jwk/key_finder.rb:25:in `key_for': Could not find public key for kid 69ba8d782bacd99772120aa88153269876ca40f2 (JWT::DecodeError)
        from .../lib/ruby/3.3.0/gems/jwt-2.10.1/lib/jwt/decode.rb:63:in `set_key'
        from .../lib/ruby/3.3.0/gems/jwt-2.10.1/lib/jwt/decode.rb:34:in `decode_segments'
        from .../lib/ruby/3.3.0/gems/jwt-2.10.1/lib/jwt.rb:51:in `block in decode'
        from .../lib/ruby/3.3.0/gems/jwt-2.10.1/lib/jwt/deprecations.rb:9:in `context'
        from .../lib/ruby/3.3.0/gems/jwt-2.10.1/lib/jwt.rb:50:in `decode'
        from ./verify-idtoken.rb:27:in `<main>'
```