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
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ Gemfile.lock
.env
log/
tmp/

## Environment normalization:
/.bundle
/vendor/bundle
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[Full Changelog](https://github.com/auth0/omniauth-auth0/compare/v1.4.1...v2.0.0)

Updated library to handle OIDC conformant clients and OAuth2 features in Auth0.
This affects how the `credentials` and `info` attributes are populated since the payload of /oauth/token and /userinfo are differnt when using OAuth2/OIDC features.
This affects how the `credentials` and `info` attributes are populated since the payload of /oauth/token and /userinfo are different when using OAuth2/OIDC features.

The `credentials` hash will always have an `access_token` and might have a `refresh_token` (if it's allowed in your API settings in Auth0 dashboard and requested using `offline_access` scope) and an `id_token` (scope `openid` is needed for Auth0 to return it).

Expand Down
3 changes: 2 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
source 'http://rubygems.org'
source 'https://rubygems.org'

gemspec

gem 'gem-release'
gem 'rake'
gem 'jwt'

group :development do
gem 'dotenv'
Expand Down
112 changes: 112 additions & 0 deletions lib/omniauth/auth0/jwt_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
require 'base64'
require 'uri'
require 'json'
require 'omniauth'

module OmniAuth
module Auth0
class JWTValidator

attr_accessor :issuer

# Initializer
# @param options object
# options.domain - Application domain.
# options.client_id - Application Client ID.
# options.client_secret - Application Client Secret.
def initialize(options)
temp_domain = URI(options.domain)
temp_domain = URI("https://#{options.domain}") unless temp_domain.scheme
@issuer = "#{temp_domain.to_s}/"

@client_id = options.client_id
@client_secret = options.client_secret
end

# Decode a JWT.
# @param jwt string - JWT to decode.
# @return hash - The decoded token, if there were no exceptions.
# @see https://github.com/jwt/ruby-jwt
def decode(jwt)
head = token_head(jwt)

# Make sure the algorithm is supported and get the decode key.
if head[:alg] == 'RS256'
jwks_x5c = jwks_key(:x5c, head[:kid])
raise JWT::VerificationError, :jwks_missing_x5c if jwks_x5c.nil?
decode_key = jwks_public_cert(jwks_x5c.first)
elsif head[:alg] == 'HS256'
decode_key = @client_secret
else
raise JWT::VerificationError, :id_token_alg_unsupported
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

algorithm: head[:alg],
leeway: 30,
verify_expiration: true,
verify_iss: true,
iss: @issuer,
verify_aud: true,
aud: @client_id,
verify_not_before: true
}

# Docs: https://github.com/jwt/ruby-jwt#algorithms-and-usage
JWT.decode(jwt, decode_key, true, decode_options)
end

# Get the decoded head segment from a JWT.
# @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.

json_parse(Base64.decode64(jwt_parts[0]))
end

# Get the JWKS from the issuer and return a public key.
# @param x5c string - X.509 certificate chain from a JWKS.
# @return key - The X.509 certificate public key.
def jwks_public_cert(x5c)
x5c = Base64.decode64(x5c)

# https://docs.ruby-lang.org/en/2.4.0/OpenSSL/X509/Certificate.html
OpenSSL::X509::Certificate.new(x5c).public_key
end

# Return a specific key from a JWKS object.
# @param key string - Key to find in the JWKS.
# @param kid string - Key ID to identify the right JWK.
# @return nil|string
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

private

# Get a JWKS from the issuer
# @return void
def jwks
jwks_uri = URI(@issuer + '.well-known/jwks.json')
@jwks ||= json_parse(Net::HTTP.get(jwks_uri))
end

# Rails Active Support blank method.
# @param obj object - Object to check for blankness.
# @return boolean
def blank?(obj)
obj.respond_to?(:empty?) ? obj.empty? : !obj
end

# Parse JSON with symbolized names.
# @param json string - JSON to parse.
# @return hash
def json_parse(json)
JSON.parse(json, {:symbolize_names => true})
end
end
end
end
50 changes: 40 additions & 10 deletions lib/omniauth/strategies/auth0.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
require 'base64'
require 'uri'
require 'omniauth-oauth2'
require 'omniauth/auth0/jwt_validator'

module OmniAuth
module Strategies
Expand All @@ -9,11 +10,12 @@ class Auth0 < OmniAuth::Strategies::OAuth2
option :name, 'auth0'

args [
:client_id,
:client_secret,
:domain
]
:client_id,
:client_secret,
:domain
]

# Setup client URLs used during authentication
def client
options.client_options.site = domain_url
options.client_options.authorize_url = '/authorize'
Expand All @@ -22,25 +24,41 @@ def client
super
end

# Use the "sub" key of the userinfo returned
# as the uid (globally unique string identifier).
uid { raw_info['sub'] }

# Build the API credentials hash with returned auth data.
credentials do
hash = { 'token' => access_token.token }
hash['expires'] = true
credentials = {
'token' => access_token.token,
'expires' => true
}

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

# 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(credentials['id_token']).length

credentials
end

# Store all raw information for use in the session.
extra do
{
raw_info: raw_info
}
end

# Build a hash of information about the user
# with keys taken from the Auth Hash Schema.
info do
{
name: raw_info['name'] || raw_info['sub'],
Expand All @@ -50,6 +68,7 @@ def client
}
end

# Define the parameters used for the /authorize endpoint
def authorize_params
params = super
params['auth0Client'] = client_info
Expand All @@ -59,43 +78,54 @@ def authorize_params
params
end

# Declarative override for the request phase of authentication
def request_phase
if no_client_id?
# Do we have a client_id for this Application?
fail!(:missing_client_id)
elsif no_client_secret?
# Do we have a client_secret for this Application?
fail!(:missing_client_secret)
elsif no_domain?
# Do we have a domain for this Application?
fail!(:missing_domain)
else
# All checks pass, run the Oauth2 request_phase method.
super
end
end

private

# Parse the raw user info.
def raw_info
userinfo_url = options.client_options.userinfo_url
@raw_info ||= access_token.get(userinfo_url).parsed
end

# Check if the options include a client_id
def no_client_id?
['', nil].include?(options.client_id)
end

# Check if the options include a client_secret
def no_client_secret?
['', nil].include?(options.client_secret)
end

# Check if the options include a domain
def no_domain?
['', nil].include?(options.domain)
end

# Normalize a domain to a URL.
def domain_url
domain_url = URI(options.domain)
domain_url = URI("https://#{domain_url}") if domain_url.scheme.nil?
domain_url.to_s
end

# Build the auth0Client URL parameter for metrics.
def client_info
client_info = JSON.dump(
name: 'omniauth-auth0',
Expand Down
4 changes: 2 additions & 2 deletions omniauth-auth0.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ Gem::Specification.new do |s|
s.authors = ['Auth0']
s.email = ['info@auth0.com']
s.homepage = 'https://github.com/auth0/omniauth-auth0'
s.summary = 'Omniauth OAuth2 strategy for the Auth0 platform.'
s.summary = 'OmniAuth OAuth2 strategy for the Auth0 platform.'
s.description = %q{Auth0 is an authentication broker that supports social identity providers as well as enterprise identity providers such as Active Directory, LDAP, Google Apps, Salesforce.

OmniAuth is a library that standardizes multi-provider authentication for web applications. It was created to be powerful, flexible, and do as little as possible.

omniauth-auth0 is the omniauth strategy for Auth0.
omniauth-auth0 is the OmniAuth strategy for Auth0.
}

s.rubyforge_project = 'omniauth-auth0'
Expand Down
Loading