Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ID token validation #62

Merged
merged 2 commits into from
Sep 13, 2018
Merged

Add ID token validation #62

merged 2 commits into from
Sep 13, 2018

Conversation

joshcanhelp
Copy link
Contributor

This PR adds ID token validation to the basic OmniAuth-Auth0 strategy. The main strategy was not altered in any way functionally except to add this validation (minor docs additions and minor formatting). The main work for this PR is in the new OmniAuth::Auth0::JWTValidator class.

This uses the Ruby JWT library and validates the following:

  • Signature
  • Expiration (30 sec leeway)
  • Issuer
  • Audience
  • Not Before (30 sec leeway)

@joshcanhelp joshcanhelp added this to the v2-Next milestone Aug 27, 2018
@joshcanhelp joshcanhelp force-pushed the add-id-token-validation branch from de8b38e to c000825 Compare August 27, 2018 21:27
@joshcanhelp joshcanhelp requested a review from machuga September 3, 2018 19:08
attr_accessor :issuer, :jwks

# Initializer
# @param options class
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Options "class"? What does that mean here? Should it say object?

end

# Docs: https://github.com/jwt/ruby-jwt#add-custom-header-fields
decode_options = {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 space indentation - should be 2

# @return hash - The parsed head of the JWT passed, empty hash if not.
def token_head(jwt)
jwt_parts = jwt.split('.')
return {} if blank?(jwt_parts) || blank?(jwt_parts[0])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you return {} from here, can the rest of the library execute? Or will this get caught in the else clause of decode and raise the JWT::VerificationError?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would you name this line if you had to give it a name? What are we checking for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you return {} from here, can the rest of the library execute?

The part of this lib that uses it will fail on looking for an algorithm, which seems rational to me for an empty head.

What would you name this line if you had to give it a name?

Not sure what you're asking here.

What are we checking for?

Whether or not was have something to parse.

# @return string - The @domain property as a URL.
def domain_url
domain_url = URI(@domain)
domain_url = URI("https://#{@domain}") if domain_url.scheme.nil?
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line and the one above seem strange. A conditional may make more sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you rephrase? This allows a URI to be loaded without http:// or https:// and will convert it.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure! So here you're assigning to domain_url twice (you're also shadowing the method name itself which is confusing) and I get why, it just feels odd. However, I think moving this up to the constructor would make sense anyway. For instance:

def initialize(options)
        @issuer = "#{domain_url}/"
        @client_id = options.client_id
        @client_secret = options.client_secret
        @jwks = nil

        temp_domain = URI(options.domain)
        @domain = temp_domain.scheme? ? temp_domain : URI("https://#{options.domain}")
      end

Then this method can simply return @domain.to_s

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@machuga - Good comment. I ended up removing this method and the @domain property since it was only used for @issuer.

def jwks_key(key, kid)
@jwks ||= fetch_jwks
return nil if blank?(@jwks[:keys])
@jwks[:keys].each do |jwk|
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to shorten this down to:

def jwks_key(key, kid)
  @jwks ||= fetch_jwks
  return nil if blank?(@jwks[:keys])
  @jwks[:keys].find { |key| jwk[:kid] == kid }
end

However, you're memoizing @jwks here in a method that isn't named jwks or directly related and that makes me a bit uncomfortable. Maybe it should fetch_jwks should be the location where this is memoized? Maybe the flow could be a bit different?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree RE: where this is memoized so I moved that to fetch_jwks. But I think you're misunderstanding what this is doing. The JWKS is like:

{
  keys [
    {
      "kid" : "kid_1",
      "key" : "thing_we_want"
    }, {
      "kid" : "kid_2",
      "key" : "thing_we_want"
    }
  ]
}

... in the general case so we need to iterate through the keys, check the kid for each, and return the value if we have a matching kid. Otherwise return nil. I simplified it a bit based on your feedback but what you're proposing won't do what we need here.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm mostly just being picky about the Ruby idioms in this situation!

# Get a JWKS from the issuer
# @param path string - JWKS path from issuer.
# @return void
def fetch_jwks(path = '.well-known/jwks.json')
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this intended to be a public method?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was but I think it should be private in this case.

@esarafianou
Copy link

esarafianou commented Sep 6, 2018

LGTM from a security perspective

@joshcanhelp joshcanhelp force-pushed the add-id-token-validation branch from 6fc441f to e4e0e46 Compare September 11, 2018 17:02
Copy link

@machuga machuga left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Last few requests and then I think we'll be good!


# Get a JWKS from the issuer
# @return void
def fetch_jwks
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what I was trying to point out earlier about this method name was that if you keep it as jwks you can then us this memoized form around the app, like above in jwks_key:

      def jwks_key(key, kid)
        return nil if blank?(jwks[:keys])
        jwks[:keys].each { |jwk| return jwk[key] if jwk[:kid] == kid }
      end

That way you're never concerned with when/if the fetching is done.

def jwks_key(key, kid)
fetch_jwks
return nil if blank?(@jwks[:keys])
@jwks[:keys].each { |jwk| return jwk[key] if jwk[:kid] == kid }
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this method may look a bit more clear as:

def jwks_key(key, kid)
  return nil if blank?(jwks[:keys])
  matching_jwk = jwks[:keys].find { |jwk| jwk[:kid] == kid }
  matching_jwk[key] if matching_jwk
end

This way uses a clear method name/block to indicate "I'm looking for a certain key, then going to perform an operation with what I found"

credentials do
hash = { 'token' => access_token.token }
hash = {}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A more idiomatic way of handling the hash assignment in this block may be:

credentials do
	# Not sure of the name `hash` here...what is its purpose?
	hash = {
		'token' => access_token.token,
		'expires' => true
	}

	if access_token.params
		hash.merge!({
			'id_token' => access_token.params['id_token'],
			'token_type' => access_token.params['token_type'],
			'refresh_token' => access_token.refresh_token,
		})
	end

	# Make sure the ID token can be verified and decoded.
	auth0_jwt = OmniAuth::Auth0::JWTValidator.new(options)
	fail!(:invalid_id_token) unless auth0_jwt.decode(hash['id_token']).length

	hash
end

@joshcanhelp joshcanhelp force-pushed the add-id-token-validation branch from e4e0e46 to bf84621 Compare September 13, 2018 17:41
@joshcanhelp joshcanhelp merged commit 0824e55 into master Sep 13, 2018
@joshcanhelp joshcanhelp deleted the add-id-token-validation branch September 13, 2018 19:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants