We are trying to use ruby-jwt to encode/decode HS256 signed JWTs using kid
s to indicate the record in a keystore that represents the key to use, but it seems like we need to do 2 decodes for verification. One decode not using a secret or indicating to ruby-jwt that we want to verify the JWT just so we can scrape the kid
out of it, then a second decode that passes in the secret that the kid
represents, and the flag to verify the JWT. It's a minor thing and b64 is cheap, but is doing 2 decodes really the approach that needs to be taken for HMAC JWTs with kid
in them? Is this a limitation of the library, or a gap in knowledge on how to work with kids
with HMAC?
keys = {'31b88f20-8edd-49fe-a839-57b8f681888c' => 'mysecret'}
payload = {user_id: 99, kid: keys.first.first, exp: 5.years.from_now.to_i}
token = JWT.encode(payload, keys.first.second, 'HS256')
# Get the kid from the jwt so we can look up what secret to use to verify it by the kid
decoded_jwt = JWT.decode(token, nil, false)
kid = decoded_jwt[0]['kid']
secret = keys[kid]
# decode the jwt again with the secret, and verify it
decoded_jwt = JWT.decode(token, secret, true)
# => [{"user_id"=>99, "kid"=>"31b88f20-8edd-49fe-a839-57b8f681888c", "exp"=>1865946624}, {"alg"=>"HS256"}]
It works but trying to see if there is a better approach. Thanks!
You can avoid the double decoding by using JSON Web Key Set (JWKS).
To implement this process using JWKS and HMAC you can do so as follows.
Encoding:
require 'jwt'
secret = 'mysecret'
kid = '31b88f20-8edd-49fe-a839-57b8f681888c'
algorithm = 'HS256'
# Used strings for comparison at the end
# Used Time so it did not rely on Rails
payload = {"user_id" => 99, "exp" => Time.now.to_i + 10000}
# Encoding pass kid as a header field
token = JWT.encode(payload, secret, algorithm , kid: kid)
#=> "eyJraWQiOiIzMWI4OGYyMC04ZWRkLTQ5ZmUtYTgzOS01N2I4ZjY4MTg4OGMiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo5OSwiZXhwIjoxNzA4MTE3ODIzfQ.UArtkNyPx0ri08i6D2t7sLtZ_dlqoDczP7r4ZVPtTGM"
Export Keys:
Since HMAC is a symmetric key the Key Type (kty
) needs to be set to 'oct' See RFC7518 at 6.1, and the Key Value (k
) is set to the secret See RFC7518 at 6.4.1
#Create a JWK by importing the Hash
jwk = JWT::JWK.new({ kty: 'oct', k: secret, kid: kid, alg: algorithm })
# JSON Web Key Set for advertising your signing keys
jwks_hash = JWT::JWK::Set.new(jwk).export
#=> {:keys=>[{:kty=>"oct", :kid=>"31b88f20-8edd-49fe-a839-57b8f681888c", :alg=>"HS256"}]}
# OR export private keys (Not recommended refer to: https://www.rfc-editor.org/rfc/rfc7517.html#section-9.2)
jwks_hash = JWT::JWK::Set.new(jwk).export(include_private: true)
#=> {:keys=>[{:kty=>"oct", :k=>"mysecret", :kid=>"31b88f20-8edd-49fe-a839-57b8f681888c", :alg=>"HS256"}]}
Decoding:
# If you did not export the private secret, if you did this step can be skipped
jwks_hash[:keys].each {|k| k.merge!(k: secret)}
# Create a JWKS
jwks = JWT::JWK::Set.new(jwks_hash)
#Decoding
decoded = JWT.decode(token, nil, true, jwks: jwks)
#=> [{"user_id"=>1708119080, "exp"=>1708119627}, {"kid"=>"31b88f20-8edd-49fe-a839-57b8f681888c", "alg"=>"HS256"}]
decoded[0] == payload
#=> true