potatosalad / ruby-jose

JSON Object Signing and Encryption (JOSE) for Ruby
http://www.rubydoc.info/gems/jose
MIT License
63 stars 32 forks source link

Add JWT functionality or jwt integration docs #6

Open jsmestad opened 7 years ago

jsmestad commented 7 years ago

Thanks for putting this gem together @potatosalad. Seems like this is a really complete JOSE implementation minus generating a JWT structure. Is the point to use something like ruby-jwt and to build the JWT header/body structure then pass it to this gem or did I miss something in the docs?

Happy to help 😄

potatosalad commented 7 years ago

Hi @jsmestad,

As far as generating a JWT structure, I'm not sure what ruby-jwt provides over what's available in this gem (after a quick glance at the docs).

now = Time.now.to_i # 1503593573
secret = 'some128bitsecret'

# ruby-jwt example
JWT.encode({ iat: now, exp: now + 3600 }, secret, 'HS256')
# => "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1MDM1OTcxNzMsImlhdCI6MTUwMzU5MzU3M30._BR5iowyyLA1OzM68ZvI2ex8F7kiz-rXR8hSZAjZETI"

# jose example
jwk = JOSE::JWK.from_oct(secret)
jws = JOSE::JWS.from({ 'alg' => 'HS256' })
jwt = JOSE::JWT.from({ 'iat' => now, 'exp' => now + 3600 })
JOSE::JWT.sign(jwk, jws, jwt).compact
# => "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1MDM1OTcxNzMsImlhdCI6MTUwMzU5MzU3M30.wl57Gl5VJl-dvy4TBs2zjsDJb2IuRj-ue9E_E_tGgqw"

Side Note: The protected header and signature are different between the two libraries because jose enforces lexical ordering of the keys when encoding JSON so that results are reproducible/consistent. In this case, ruby-jwt encodes the protected header as {"typ":"JWT","alg":"HS256"} while jose will always encode the protected header as {"alg":"HS256","typ":"JWT"}.

I left out restricting much of anything related to the JWT claims themselves because all of the claims defined in RFC 7519 are optional. By comparison, specifications that actually use JWTs typically have the actual claims requirements and validations spelled out (for example, ID Tokens in OpenID Connect Core 1.0). Companies like Google (and other early adopters of OAuth2), have some really weird claims use cases for JWTs that aren't defined in any public specifications. So my thought was to keep this library relaxed on claims generation/validation and allow other libraries to add their own protocol-specific claims generation/validation.

However, I like what ruby-jwt does with its validation by keeping things generic and customizable.

I have something similar as part of an unreleased Identity and Access Management (IAM) library in Erlang: https://gist.github.com/potatosalad/88e6c10eaad3cbd1d6650b2f9fa32358

You would use the library as follows (in Elixir):

secret = "some128bitsecret"

# Access Token generation
token =
  :iam_claims.new(%{
    claims: %{},
    jwt_id: {:hash, :md5},
    key_id: true
  })
  |> :iam_claims.issue(1503593573)
  |> :iam_claims.expire_after(3600)
  |> :iam_claims.put(:audience, "client_id")
  |> :iam_claims.put(:issuer, "https://self-issued.me")
  |> :iam_claims.put(:subject, "user_id")
  |> :iam_claims.sign("HS256", secret)
# => "eyJhbGciOiJIUzI1NiIsImtpZCI6IkJrWUg0NUZ3ZTNkdzBYbmNXN3IwUXFuR19qbF9VcWp6NlBtMDRUekhhN28iLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJjbGllbnRfaWQiLCJleHAiOjE1MDM1OTcxNzMsImlhdCI6MTUwMzU5MzU3MywiaXNzIjoiaHR0cHM6Ly9zZWxmLWlzc3VlZC5tZSIsImp0aSI6IlMtcFN1b3k0eDJzczFheG5tR1dic1EiLCJzdWIiOiJ1c2VyX2lkIn0.MFVG-aho-mAj2fcDKmwHy7iobHKGsLtrLyxco88m9pA"

# Access Token verification & validation
assert =
  :iam_assert.new(%{
    audience: "client_id",
    issuer: "https://self-issued.me",
    jwt_id: {:hash, :md5},
    now: 1503593573,
    required: %{
      not_before: false
    },
    subject: "user_id"
  })
  |> :iam_assert.require([
    :audience,
    :expiration_time,
    :issued_at,
    :issuer,
    :subject
  ])
  |> :iam_assert.authenticate(token, ["HS256"], secret)
# => %:iam_assert{assertion: "eyJhbGciOiJIUzI1NiIsImtpZCI6IkJrWUg0NUZ3ZTNkdzBYbmNXN3IwUXFuR19qbF9VcWp6NlBtMDRUekhhN28iLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJjbGllbnRfaWQiLCJleHAiOjE1MDM1OTcxNzMsImlhdCI6MTUwMzU5MzU3MywiaXNzIjoiaHR0cHM6Ly9zZWxmLWlzc3VlZC5tZSIsImp0aSI6IlMtcFN1b3k0eDJzczFheG5tR1dic1EiLCJzdWIiOiJ1c2VyX2lkIn0.MFVG-aho-mAj2fcDKmwHy7iobHKGsLtrLyxco88m9pA",
#     audience: "client_id", authorized_party: nil, checks: %{},
#     claims: %{"aud" => "client_id", "exp" => 1503597173, "iat" => 1503593573,
#       "iss" => "https://self-issued.me", "jti" => "S-pSuoy4x2ss1axnmGWbsQ",
#       "sub" => "user_id"}, issuer: "https://self-issued.me", jwt_id: {:hash, :md5},
#     loaded: true, max_age: nil, not_before: nil, now: 1503593573,
#     protected: %{"alg" => "HS256",
#       "kid" => "BkYH45Fwe3dw0XncW7r0QqnG_jl_Uqjz6Pm04TzHa7o", "typ" => "JWT"},
#     public_key: nil,
#     required: %{audience: true, expiration_time: true, issued_at: true,
#       issuer: true, not_before: false, subject: true}, subject: "user_id",
#     validated: true, verified: true, window: 5}

You can play around with the resulting JWT here.

The flow of stages for "authentication" of the JWT is different depending on whether the JWT was signed or encrypted:

Is something like that what you're looking for? If so, do you think it should be part of this library or does it belong in a separate library for the more common use cases of JWTs?

Opinions and contributions are definitely welcome :smiley:

jsmestad commented 7 years ago

Ah that makes sense. The missing claims is what threw me off and the fact that JOSE::JWS.verify_strict returns what seems to be just a saw JSON string. Any reason it's not run through JSON.parse? (or maybe I am doing it wrong)


    def verify!
      ::JOSE::JWS.verify_strict(public_jwk, ["ES512"], @jwt)
    end

    def body
      JSON.parse(verify![1])
    rescue JSON::ParserError
      {}
    end

I also have no clue what the JWT struct and Map are supposed to be used for. I dug into the source and docs but still cannot quite figure it out...

potatosalad commented 7 years ago

So, a JWS can actually sign non-JSON data, which is why I don't do JSON parsing with JOSE::JWS. A JWT, however, is by definition JSON data, so if you use JOSE::JWT for signing and verifying you'll get an object back.

jwk = JOSE::JWK.from_oct('some128bitsecret')
jws = JOSE::JWS.from({ 'alg' => 'HS256' })
plaintext = 'test'
jwt = JOSE::JWT.from({ 'test' => true })

# plain text example
signed_plaintext = JOSE::JWS.sign(jwk, plaintext, jws).compact
# => "eyJhbGciOiJIUzI1NiJ9.dGVzdA.Q6Csqi1yGWpbZZ9ETjq_clf0TEmo2p4RhDy1J6xQyIM"
JOSE::JWS.verify_strict(jwk, ['HS256'], signed_plaintext)
# => [true,
#     "test",
#     #<struct JOSE::JWS
#      alg=#<struct JOSE::JWS::ALG_HMAC hmac=OpenSSL::Digest::SHA256>,
#      b64=nil,
#      fields=JOSE::Map[]>]

# JWT example
signed_jwt = JOSE::JWT.sign(jwk, jws, jwt).compact
# => "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0ZXN0Ijp0cnVlfQ.wdE-oAmXMPFyhWD0zXYLhR6nCj4ku00WlJnNjsVC0Ck"
JOSE::JWT.verify_strict(jwk, ['HS256'], signed_jwt)
# => => [true,
#        #<struct JOSE::JWT fields=JOSE::Map["test" => true]>,
#        #<struct JOSE::JWS
#         alg=#<struct JOSE::JWS::ALG_HMAC hmac=OpenSSL::Digest::SHA256>,
#         b64=nil,
#         fields=JOSE::Map["typ" => "JWT"]>]

An instance of JOSE::JWT isn't terribly useful on its own, except for the fact that instance methods can be used on it (so jwt.sign(jwk, jws) instead of JOSE::JWT.sign(jwk, jws, jwt)). It's designed to mirror JOSE::JWK, JOSE::JWS, and JOSE::JWE, however, which hold more processed/optimized versions of the keys or cryptography algorithms that will be used. Converting them back to a map returns the actual JSON fields. It's a little goofy that JWT's only instance variable is fields, though. I originally wrote the Erlang version of this library first, so some of the API decisions were made just to keep some consistency/familiarity between the two libraries.

JOSE::Map, on the other hand, is based on Hamster::Hash, with the goal of keeping all data immutable. You can always convert back to a hash with jwt.to_map.to_hash (or JOSE::JWT.to_map(jwt).to_hash). All of the separate standards (JWE, JWK, JWS, and JWT) behave the same (so you can also do jws.to_map.to_hash or JOSE::JWS.to_map(jws).to_hash).

potatosalad commented 7 years ago

Also, you can see some of the difference between whether a JSON object is expected with the peek methods in JOSE::JWS.peek_protected (returns a String) and JOSE::JWT.peek_protected (returns a Map).

The documentation could definitely be improved, though. I tried to imply the use-case based on the names of the arguments (see how JOSE::JWS.sign accepts plain_text while JOSE::JWT.sign accepts a jwt), but there should probably be some explanatory text at the top of the documentation for each module/class.

Also, the ordering of arguments has bothered me for a while now because I was all over the place when I wrote the library:

Perhaps having something like JOSE::Signer and JOSE::Claims with a friendlier interface would help reduce confusion for folks just starting with the library.

jsmestad commented 7 years ago

Ah thats awesome. Yeah I can see the erlang/elixir influence everywhere.

If I could say one thing on the project, it would be to add to a GH wiki and just demonstrate how to do things like what ruby-jwt explains. I chose this library as its far more complete, but their seems to be 5 ways to do everything and little indication on which is ideal.

I'd be happy to get started if you feel its useful to the project overall.

potatosalad commented 7 years ago

I'd be happy to get started if you feel its useful to the project overall.

Heck yeah, that would be super helpful. You probably have a much better idea/perspective on what's lacking from the initial documentation for newcomers. Even if you put some placeholders in the wiki for areas like "show how you encrypt and decrypt a JWT here" I can go in a flesh those out.