【Ruby】jwt gemの使い方とJWTのエンコード・デコード方法の具体例

Ruby

前回、JWTの概要と構成要素(ヘッダ/ペイロード/署名)を理解するでJWTの概要について紹介しました。
今回はruby-jwt(jwt gem)を利用してペイロードをJWTにエンコード、JWTをペイロードにデコードする方法について紹介します。利用するruby-jwtのバージョンは2.3.0です。

JWTの関連用語やJWTの基礎知識がすでにある前提で説明をするため、前提知識のない方はJWTの概要と構成要素(ヘッダ/ペイロード/署名)を理解するをご覧になってください。

ruby-jwtのエンコードメソッドについて

ruby-jwtのエンコードメソッドであるJWT.encodeの引数の意味は以下の通りです。

  • 第1引数: ペイロード
  • 第2引数: アルゴリズムのパスワード
  • 第3引数: 署名のアルゴリズム

たとえば第3引数にRS256(SHA-256アルゴリズムを利用したRSA)を指定した場合、第2引数にはRSA暗号の秘密鍵をセットします。
第3引数にnone(署名やMAC値を利用しない)を指定した場合、noneではパスワードが不要なため第2引数にはnilをセットします。

なおアルゴリズムの動的な選択は脆弱性につながるため、第3引数はハードコードで記述することが推奨されています。1

ruby-jwtのデコードメソッドについて

ruby-jwtのデコードメソッドであるJWT.decodeの引数の意味は以下の通りです。

  • 第1引数: デコード対象のトークン(JWT)
  • 第2引数: パスワード
  • 第3引数: バリデーションの有無
  • 第4引数: オプション

たとえばRS256アルゴリズムを利用したJWTをデコードする場合、第2引数にはRSA暗号の公開鍵(あるいは秘密鍵)を指定し、第4引数には{ algorithm: 'RS256' }でアルゴリズムを明記します。
none(署名やMAC値を利用しない)で作成したJWTをデコードする場合、第2引数はnil、第3引数はfalseにします。第4引数は不要です。

ruby-jwtを利用したJWTのエンコード・デコードの具体例

今回は署名なし・ありの2パターンについて紹介します。なお、ruby-jwtがすでにインストールされている前提で説明をします。

署名なしJWTのエンコード・デコードの例

$ rails c

### ペイロードの作成
> payload = { iss: "example_app", sub: "1", exp: (DateTime.current + 14.days).to_i }

### パスワードなしでペイロードをエンコード
> token = JWT.encode(payload, nil, 'none')
=> "eyJhbGciOiJub25lIn0.eyJpc3MiOiJleGFtcGxlX2FwcCIsInN1YiI6IjEiLCJleHAiOjE2NDcwNjcyNzd9."

### パスワード・バリデーションなしでJWTをデコード
> decoded_token = JWT.decode(token, nil, false)
=> [{"iss"=>"example_app", "sub"=>"1", "exp"=>1647067277}, {"alg"=>"none"}]

参考: JWTの要素をBase64URLデコードした結果について

JWTはBase64URLエンコードした「ヘッダ」「ペイロード」「署名」をピリオド(.)で連結させた文字列です。ですので、JWTをピリオド(.)で分割し、各要素をBase64URLデコードすることでヘッダとペイロードの内容が取得できます。

具体例は以下の通りです。

$ rails c

### トークン(JWT)の作成
> payload = { iss: "example_app", sub: "1", exp: (DateTime.current + 14.days).to_i }
> token = JWT.encode(payload, nil, 'none')

### JWTをピリオドで分割
> jwt_header, jwt_payload = token.split('.')

### ヘッダには署名なしを意味する『none』がセットされている
> JSON.parse(Base64.urlsafe_decode64(jwt_header))
=> {"alg"=>"none"}

### ペイロードが取得できる
> JSON.parse(Base64.urlsafe_decode64(jwt_payload))
=> {"iss"=>"example_app", "sub"=>"1", "exp"=>1647067277}

署名ありJWTのエンコード・デコードの例

ruby-jwtではバージョン2.3.0現在、noneHMACRSASSAECDSAの署名アルゴリズムをサポートしています。1
今回は例としてRS256(SHA-256アルゴリズムを利用したRSA)で署名したJWTのエンコード・デコード方法について紹介します。

RS256で利用するRSA暗号の秘密鍵と公開鍵はOpenSSL::PKey::RSAを利用して作成します。
OpenSSL::PKey::RSAを利用した秘密鍵と公開鍵の作成方法の詳細解説は【Ruby】OpenSSL::PKey::RSAを利用した秘密鍵・公開鍵の生成方法で紹介しています。

$ rails c

### /dev/randomを利用して乱数を初期化する
> OpenSSL::Random.seed(File.read("/dev/random", 16))

### RSAオブジェクト(RSA秘密鍵)の生成
> rsa_private = OpenSSL::PKey::RSA.generate(2048)

### 公開鍵の作成
> rsa_public = rsa_private.public_key

### ペイロードの作成
> payload = { iss: "example_app", sub: "1", exp: (DateTime.current + 14.days).to_i }

### 秘密鍵を使ってペイロードをエンコード
> token = JWT.encode(payload, rsa_private, 'RS256')
=> "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJleGFtcGxlX2FwcCIsInN1YiI6IjEiLCJleHAiOjE2NDcwNjc3MjV9.L_uif2HT1h6kpDJ43YYwE7fW7GC4TWTRYIHb60LEc3-cYYGXNi1zaKrAYYE0eKg50r8xj0I-dnNwIlG3d30UrQKEhGWnzJf7Xdo8D2spVAT_JDGrBKISPzwuJZ0I8R4Lf2Tm96llpHTXu0Yydu-etkUQlh75a9rR8iEQM0RyJTyn3TWnvVTZq41M7wxvV5Uzu3u_CMbzSq3BE8R1so60jH5PhOxZuM2Mohvfzudt740y-Fu7IZQhZ7zSx_r_qpK0DJSO95Wnvn1LtcsQApC5tRzoiTtiV5uQeKr6pbTyRE687AbiPnZwFGlXA4xGI9yPaFu7C_lk4TJB5kUoDg"

### 公開鍵を使ってペイロードをデコード
> JWT.decode(token, rsa_public, true, { algorithm: 'RS256' })
=> [{"iss"=>"example_app", "sub"=>"1", "exp"=>1647067725}, {"alg"=>"RS256"}]

### デコードは秘密鍵でもできる
> JWT.decode(token, rsa_private, true, { algorithm: 'RS256' })
=> [{"iss"=>"example_app", "sub"=>"1", "exp"=>1647067725}, {"alg"=>"RS256"}]

参考: デコードの第3引数のバリデーション『true』の役割について

JWT.decodeの第3引数であるバリデーションをtrueにすることで、JWTの署名部分(ピリオド区切りにした際の3番目の要素)が不適切な値だった場合にJWT::VerificationErrorの例外が発生します。

第3引数のtrueにした場合とfalseにした場合の挙動の違いは以下の通りです。

### 署名部分を改ざんしたトークン
> token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJleGFtcGxlX2FwcCIsInN1YiI6IjEiLCJleHAiOjE2NDcwNjc3MjV9.aaaaaaaaaaaa"

### falseの場合はヘッダとペイロードが正しければデコードできる
> JWT.decode(token, rsa_public, false, { algorithm: 'RS256' })
=> [{"iss"=>"example_app", "sub"=>"1", "exp"=>1647067725}, {"alg"=>"RS256"}]

### trueにすることで署名部分が改ざんされたことが例外によって検知できる
> JWT.decode(token, rsa_public, true, { algorithm: 'RS256' })
# JWT::VerificationError: Signature verification raised
# from /usr/local/bundle/gems/jwt-2.3.0/lib/jwt/signature.rb:32:in `verify'

参考: JWTの要素をBase64URLデコードした結果について

JWTをピリオド(.)で分割し、Base64URLデコードした結果は以下の通りです。JWTの署名部分はバイナリデータとなっています。

$ rails c

### トークン(JWT)の作成
> OpenSSL::Random.seed(File.read("/dev/random", 16))
> rsa_private = OpenSSL::PKey::RSA.generate(2048)
> payload = { iss: "example_app", sub: "1", exp: (DateTime.current + 14.days).to_i }
> token = JWT.encode(payload, rsa_private, 'RS256')

### ピリオドを区切り文字としてJWTを3つの要素に分割。
> jwt_header, jwt_payload, jwt_signature = token.split('.')

### ヘッダには署名アルゴリズム『RS256』がセットされている
> JSON.parse(Base64.urlsafe_decode64(jwt_header))
=> {"alg"=>"RS256"}

### ペイロードが取得できる
> JSON.parse(Base64.urlsafe_decode64(jwt_payload))
=> {"iss"=>"example_app", "sub"=>"1", "exp"=>1647067277}

### 署名はバイナリデータで保存されている
> Base64.urlsafe_decode64(jwt_signature)
=> "/\xFB\xA2\x7Fa\xD3\xD6\x1E\xA4\xA42x\xDD\x860\x13\xB7\xD6\xEC`\xB8Md\xD1`\x81\xDB\xEBB\xC4s\x7F\x9Ca\x81\x976-sh\xAA\xC0a\x814x\xA89\xD2\xBF1\x8FB>vsp\"Q\xB7w}\x14\xAD\x02\x84\x84e\xA7\xCC\x97\xFB]\xDA<\x0Fk)T\x04\xFF$1\xAB\x04\xA2\x12?<.%\x9D\b\xF1\x1E\v\x7Fd\xE6\xF7\xA9e\xA4t\xD7\xBBF2v\xEF\x9E\xB6E\x10\x96\x1E\xF9k\xDA\xD1\xF2!\x103Dr%<\xA7\xDD5\xA7\xBDT\xD9\xAB\x8DL\xEF\foW\x953\xBB{\xBF\b\xC6\xF3J\xAD\xC1\x13\xC4u\xB2\x8E\xB4\x8C~O\x84\xECY\xB8\xCD\x8C\xA2\e\xDF\xCE\xE7m\xEF\x8D2\xF8[\xBB!\x94!g\xBC\xD2\xC7\xFA\xFF\xAA\x92\xB4\f\x94\x8E\xF7\x95\xA7\xBE}K\xB5\xCB\x10\x02\x90\xB9\xB5\x1C\xE8\x89;bW\x9B\x90x\xAA\xFA\xA5\xB4\xF2DN\xBC\xEC\x06\xE2>vp\x14iW\x03\x8CF#\xDC\x8Fh[\xBB\v\xF9d\xE12A\xE6E(\x0E"

さいごに

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!

参考資料