🔸

Ruby で行う JWT(IDトークン)の検証

2024/02/21に公開

こんにちは。 iys5 です。
今回は Ruby を用いた JWT(IDトークン)の検証方法について書こうと思います。

使用するGem

https://github.com/jwt/ruby-jwt

gem install jwt

こちらの Gem は、JWT を扱う他の Gem と比較してもスター数が多く、各クレームのチェックも一通り対応しています。

バージョンは 2.8.0 を使用しています。
JWE 形式の JWT は対象外です。

署名検証

基本的には以下の様に JWT.decode を呼ぶだけです。署名検証とデコードをまとめて行ってくれます。

id_token = 'eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJnb29nbGUifQ.O0QuwE_3GnISfA65tc4RD5bAOSi_HI8OzA_o-Ys2kpHYW-6tQxjdZhc-RQBxkiY1NRxqRzsiH7Q3Rz2SSGKZPaTmvO1w8zxCBh2Qb8MV784ch_YFdIgjmylR-1Db5G_QEqsG2PPVdgRg3JfRtw3kQdbzp7HH8C6XMDHoCdlUtSwc1a-bRu269_TDl5UMJUJilynqRNJjjlzKyHqHkLStDxbgdpxUgX1Z8gDmgV4bvQBvVxwO37Dvbxt9CD2FR7iU4fSD4lJvILBn7XwrbkepEk2XFHAqwaYH5pXRicqDxvrUPrC9DAbUAn9ZzQ88vyfeSvpXi9uF2KJ6F7qjP3xLTQ'
key = OpenSSL::PKey::RSA.new("-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzbNVPxSS1I17sJoW2eqE\nIRCZlHoVLSIUwbRPjPz3HCpZ9zHBKnjNo0+MHV+BAoiJB5NYzBJ8sg6vccBEDd9c\nHQfhp12F4XaA2/uZVj2Yrxp0lbmXFmoAuAYmNHxrLEblvhFbk5BlXWuPPa36eeJb\nw6U9tQVrNp1vRhEmxf8ETFXkNCxioHAf9Sg1ICO82KNZOkbe8mlW0g9REd/45Slg\nNzn4ghzfTS5zgHp5QTkr9IFDf1f82wmtJbQ3+ZyPwZ2L4j4ZdNii7ktF6zoVX0aj\nUAeBqWFbf5qs6SJAtDQQMxbHs90pJPLyeZPH5QCZ3iJjeySiiYQKWzV0t8Q6CKe/\njwIDAQAB\n-----END PUBLIC KEY-----\n")

JWT.decode(
    id_token,          # IDトークン
    key,               # 鍵
    true,              # 署名検証を行うかどうか
    algorithm: 'RS256' # 署名検証アルゴリズム
)
# => [
#      {"iss"=>"google"}, # payload
#      {"alg"=>"RS256"}   # header
#    ]

検証が成功した場合、デコード後の payload と header が戻り値で得られます。
なお署名検証をスキップし、デコードのみを行いたい場合は以下のようにします。

JWT.decode(
    id_token,
    nil,
    false,
    algorithm: 'none'
)

JWK Setを使用する

公開鍵には上の例で示した pem 形式(-----BEGIN PUBLIC KEY----- のような文字列で始まるもの)の他にも JWK (JSON Web Key) と呼ばれる JSON 形式のものもあります。
IDトークンを発行するプロバイダは、IDトークンの受取手が検証を行う際に必要な JWK の集合(JWK Set)をURLで公開している場合があり、例えば Google は以下の JWK Set URL を公開しています。

https://www.googleapis.com/oauth2/v3/certs

このような URL から得られる JWK Set を使用して検証することも可能です。

JWK Set を Hash で用意し、JWT::JWK::Set インスタンスを作成します。
JWT.decode の第2引数を nil とし、オプショナル引数 jwks にインスタンスを渡します。

id_token = 'eyJraWQiOiJrZXlfaWQxIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJnb29nbGUifQ.5JoYc8r0Dt4HhJXRBXBqIdre3OXmXLHCyVEz5EuDrQnIL9DYaDIf2SDC0c30QnU2WPjjLMotE2eYt7rhb1PXJGYo2PVs5WZfF50x_gdka2ODbT7bhL7fxrcQbrVPMkiV1nBcezRJ02PUcS8BFFiAOBztpcmHbfVoD82vtreiQRn6gfhohLYNvqDg4AUZziKeEswzsxr5ruliy8FBkUsXF8ln4wea8dOGZ7IAeRfFdw6CpJ6LKNAx7PS2Hu26UFptTJHfKDeI6-MRMXBXVskHoD8ygEBczGTWSPoyUOV0y3ZoQxznKV6IwV1e03dhrQZc7kM4MMPx1nok_tQ_TZZK0g'
# header: {"kid"=>"key_id1", "alg"=>"RS256"}
# payload: {"iss"=>"google"}

jwks_hash = { 
    keys: [
        { kid: 'key_id1', kty: 'RSA', n: '8caIDnepIxbUj3pK7M5Xx3eXA9sQXUz4XwPdZA2BiOouTHLu_k8aFg5cVyymzhCUZuTV0faIAnkyBgF2B74rU9PKnOJB82Q5zkjRBXErcFnBnQBwqrAgBmp-WQhZlR4R7X5b2-fwRwOuYdER4-sZMLMLIU3qMQz05XWhZ1rLX4bkwhf4i75uOgmunhLIKjIqPU5BnVE1Ziqn00srAJdpUU-ClHVpUHIvELuQqKn_WyEPukd9m7j434KlDC9gFLzoujOs7yuMmi1Sn_OlTR8ijL7efV4ckVp2qHxy-p3FMEHFFNWo6iDga8o8UkNxDsfp5t1Y40mfOli19NCd5dQESw', e: 'AQAB' },
        { kid: 'key_id2', kty: 'RSA', n: '7JgXZHPZ78r2ld1zbCqXbPofoc8KUIEZM4Lsbca7ogq0Cq3tqxdofNiNSoKNAtqWeOWb0koDd1CJlb-N81LyEmd3q1RrR6k_KQg41B_uHKhFlowH8NLgGaXRq9D-fO6L1mf5bQZmFhRnxn8_5iZ_eBoTy68POWKoXsJncxMYtRuoCq1c6OuAebvobeOXOV1Zj4JlJgWHNmVIE3Vvu6NPpIhhsXf2SrYhy7iAbBLhLWQ6Kh6bVMSsGf4f2tk3W5rNO0hbL2tng-Xt5WJT5VpRGsNdQ7IV2YNG70InE37jcU-K1jm6YlnoFTl-vsDWrMx2SwHa4gikvcWYMyq9ze2Psw', e: 'AQAB' },
    ] 
}
jwks = JWT::JWK::Set.new(jwks_hash)
JWT.decode(id_token, nil, true, algorithm: 'RS256', jwks: jwks)

JWK Setには複数の鍵が含まれていますが、kid の値がIDトークンのヘッダに含まれるものと等しいものが使用されます。
上記の例では、id_token のヘッダには kid: 'key_id1' が含まれているため、JWK Set の一つ目の公開鍵が使用されます。

クレームのチェック

署名検証時にオプショナル引数を加えることで、payload に含まれる各クレームのチェックを同時に行えます。

例えば、 iss(Issuer: JWTの発行者)や aud(Audience: JWTの受取手)といったクレームが含まれている場合、 JWT.decode 実行時にそれらが有効な値かどうかをチェックし、無効な値の場合はエラーを発生させることができます。チェックする場合はオプショナル引数を以下のように追加します。

JWT.decode(
    id_token, 
    key, 
    true, 
    algorithm: 'RS256', 
    iss: 'google',      # 有効な発行者
    verify_iss: true    # 発行者をチェックするかどうか
    aud: 'client',      # 有効な受取手
    verify_aud: true    # 受取手をチェックするかどうか
)

時刻からJWTが有効かどうかを判断する exp(Expiration Time: JWTの有効期限)と nbf(Not Before: JWTが有効になる日時)に関してはオプショナル引数なしで常にチェックされます。チェックが不要な場合のみオプショナル引数を以下のように追加します。

JWT.decode(
    id_token, 
    key, 
    true, 
    algorithm: 'RS256', 
    verify_expiration: false, # 有効期限をチェックしない
    verify_not_before: false  # 有効になる日時をチェックしない
)

他にも以下のクレームのチェックについても設定できます。詳細は Gem の README を参照ください。

  • jti(JWT ID: JWTを一意に表す識別子)
  • iat(Issued At: JWTが発行された日時)
  • sub(Subject: 認証対象となるユーザーの識別子)

エラーハンドリング

署名検証やクレームのチェックが失敗した場合、何らかのエラーがメッセージ付きで raise されます。
例えば、iss(Issuer: JWTの発行者)が不正な値だった場合は以下のようなエラーが発生します。

JWT.decode(
    id_token, 
    key, 
    true, 
    algorithm: 'RS256', 
    iss: 'apple',
    verify_iss: true
)
# => JWT::InvalidIssuerError: Invalid issuer. Expected ["apple"], received google

JWT::InvalidIssuerError が raise され、「iss には 'apple' を想定していたが 'google' を受け取った」という旨のメッセージが付与されています。

実装を見てみると、JWT.decode で発生するエラーはいずれも JWT::DecodeError を継承しているようです。

https://github.com/jwt/ruby-jwt/blob/v2.8.0/lib/jwt/error.rb

そのため、基本的には JWT::DecodeError を rescue するのみでハンドリングできそうです。

begin
  JWT.decode(...)
rescue JWT::DecodeError => e
  logger.warn(e.message)
end

まとめ

Ruby で JWT の検証を行う方法として jwt/ruby-jwt Gem を使用する方法を紹介しました。

今回は主に JWT の検証機能について紹介しましたが、こちらの Gem では JWT の生成や、様々な署名アルゴリズムの使用も可能なので、より詳しい内容は README を参照してみてください。

Social PLUS Tech Blog

Discussion