omniauth-auth0 2.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/codecov.yml ADDED
@@ -0,0 +1,22 @@
1
+ coverage:
2
+ precision: 2
3
+ round: down
4
+ range: "60...100"
5
+ status:
6
+ project:
7
+ default:
8
+ enabled: true
9
+ target: auto
10
+ threshold: 5%
11
+ if_no_uploads: error
12
+ patch:
13
+ default:
14
+ enabled: true
15
+ target: 80%
16
+ threshold: 30%
17
+ if_no_uploads: error
18
+ changes:
19
+ default:
20
+ enabled: true
21
+ if_no_uploads: error
22
+ comment: false
@@ -0,0 +1,11 @@
1
+ module OmniAuth
2
+ module Auth0
3
+ class TokenValidationError < StandardError
4
+ attr_reader :error_reason
5
+ def initialize(msg)
6
+ @error_reason = msg
7
+ super(msg)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,278 @@
1
+ require 'base64'
2
+ require 'uri'
3
+ require 'json'
4
+ require 'omniauth'
5
+ require 'omniauth/auth0/errors'
6
+
7
+ module OmniAuth
8
+ module Auth0
9
+ # JWT Validator class
10
+ class JWTValidator
11
+ attr_accessor :issuer, :domain
12
+
13
+ # Initializer
14
+ # @param options object
15
+ # options.domain - Application domain.
16
+ # options.issuer - Application issuer (optional).
17
+ # options.client_id - Application Client ID.
18
+ # options.client_secret - Application Client Secret.
19
+
20
+ def initialize(options, authorize_params = {})
21
+ @domain = uri_string(options.domain)
22
+
23
+ # Use custom issuer if provided, otherwise use domain
24
+ @issuer = @domain
25
+ @issuer = uri_string(options.issuer) if options.respond_to?(:issuer)
26
+
27
+ @client_id = options.client_id
28
+ @client_secret = options.client_secret
29
+ end
30
+
31
+ # Verify a token's signature. Only tokens signed with the RS256 or HS256 signatures are supported.
32
+ # Deprecated: Please use `decode` instead
33
+ # @return array - The token's key and signing algorithm
34
+ def verify_signature(jwt)
35
+ head = token_head(jwt)
36
+ key, alg = extract_key(head)
37
+
38
+ # Call decode to verify the signature
39
+ JWT.decode(jwt, key, true, decode_opts(alg))
40
+ return key, alg
41
+ end
42
+
43
+ # Decodes a JWT and verifies it's signature. Only tokens signed with the RS256 or HS256 signatures are supported.
44
+ # @param jwt string - JWT to verify.
45
+ # @return hash - The decoded token, if there were no exceptions.
46
+ # @see https://github.com/jwt/ruby-jwt
47
+ def decode(jwt)
48
+ head = token_head(jwt)
49
+ key, alg = extract_key(head)
50
+
51
+ # Call decode to verify the signature
52
+ JWT.decode(jwt, key, true, decode_opts(alg))
53
+ end
54
+
55
+ # Verify a JWT.
56
+ # @param jwt string - JWT to verify.
57
+ # @param authorize_params hash - Authorization params to verify on the JWT
58
+ # @return hash - The verified token payload, if there were no exceptions.
59
+ def verify(jwt, authorize_params = {})
60
+ if !jwt
61
+ raise OmniAuth::Auth0::TokenValidationError.new('ID token is required but missing')
62
+ end
63
+
64
+ parts = jwt.split('.')
65
+ if parts.length != 3
66
+ raise OmniAuth::Auth0::TokenValidationError.new('ID token could not be decoded')
67
+ end
68
+
69
+ id_token, header = decode(jwt)
70
+ verify_claims(id_token, authorize_params)
71
+
72
+ return id_token
73
+ end
74
+
75
+ # Get the decoded head segment from a JWT.
76
+ # @return hash - The parsed head of the JWT passed, empty hash if not.
77
+ def token_head(jwt)
78
+ jwt_parts = jwt.split('.')
79
+ return {} if blank?(jwt_parts) || blank?(jwt_parts[0])
80
+
81
+ json_parse(Base64.decode64(jwt_parts[0]))
82
+ end
83
+
84
+ # Get the JWKS from the issuer and return a public key.
85
+ # @param x5c string - X.509 certificate chain from a JWKS.
86
+ # @return key - The X.509 certificate public key.
87
+ def jwks_public_cert(x5c)
88
+ x5c = Base64.decode64(x5c)
89
+
90
+ # https://docs.ruby-lang.org/en/2.4.0/OpenSSL/X509/Certificate.html
91
+ OpenSSL::X509::Certificate.new(x5c).public_key
92
+ end
93
+
94
+ # Return a specific key from a JWKS object.
95
+ # @param key string - Key to find in the JWKS.
96
+ # @param kid string - Key ID to identify the right JWK.
97
+ # @return nil|string
98
+ def jwks_key(key, kid)
99
+ return nil if blank?(jwks[:keys])
100
+
101
+ matching_jwk = jwks[:keys].find { |jwk| jwk[:kid] == kid }
102
+ matching_jwk[key] if matching_jwk
103
+ end
104
+
105
+ private
106
+ # Get the JWT decode options. We disable the claim checks since we perform our claim validation logic
107
+ # Docs: https://github.com/jwt/ruby-jwt
108
+ # @return hash
109
+ def decode_opts(alg)
110
+ {
111
+ algorithm: alg,
112
+ verify_expiration: false,
113
+ verify_iat: false,
114
+ verify_iss: false,
115
+ verify_aud: false,
116
+ verify_jti: false,
117
+ verify_subj: false,
118
+ verify_not_before: false
119
+ }
120
+ end
121
+
122
+ def extract_key(head)
123
+ if head[:alg] == 'RS256'
124
+ key, alg = [rs256_decode_key(head[:kid]), head[:alg]]
125
+ elsif head[:alg] == 'HS256'
126
+ key, alg = [@client_secret, head[:alg]]
127
+ else
128
+ raise OmniAuth::Auth0::TokenValidationError.new("Signature algorithm of #{head[:alg]} is not supported. Expected the ID token to be signed with RS256 or HS256")
129
+ end
130
+ end
131
+
132
+ def rs256_decode_key(kid)
133
+ jwks_x5c = jwks_key(:x5c, kid)
134
+
135
+ if jwks_x5c.nil?
136
+ raise OmniAuth::Auth0::TokenValidationError.new("Could not find a public key for Key ID (kid) '#{kid}'")
137
+ end
138
+
139
+ jwks_public_cert(jwks_x5c.first)
140
+ end
141
+
142
+ # Get a JWKS from the domain
143
+ # @return void
144
+ def jwks
145
+ jwks_uri = URI(@domain + '.well-known/jwks.json')
146
+ @jwks ||= json_parse(Net::HTTP.get(jwks_uri))
147
+ end
148
+
149
+ # Rails Active Support blank method.
150
+ # @param obj object - Object to check for blankness.
151
+ # @return boolean
152
+ def blank?(obj)
153
+ obj.respond_to?(:empty?) ? obj.empty? : !obj
154
+ end
155
+
156
+ # Parse JSON with symbolized names.
157
+ # @param json string - JSON to parse.
158
+ # @return hash
159
+ def json_parse(json)
160
+ JSON.parse(json, symbolize_names: true)
161
+ end
162
+
163
+ # Parse a URI into the desired string format
164
+ # @param uri - the URI to parse
165
+ # @return string
166
+ def uri_string(uri)
167
+ temp_domain = URI(uri)
168
+ temp_domain = URI("https://#{uri}") unless temp_domain.scheme
169
+ temp_domain = temp_domain.to_s
170
+ temp_domain.end_with?('/') ? temp_domain : "#{temp_domain}/"
171
+ end
172
+
173
+ def verify_claims(id_token, authorize_params)
174
+ leeway = authorize_params[:leeway] || 60
175
+ max_age = authorize_params[:max_age]
176
+ nonce = authorize_params[:nonce]
177
+ organization = authorize_params[:organization]
178
+
179
+ verify_iss(id_token)
180
+ verify_sub(id_token)
181
+ verify_aud(id_token)
182
+ verify_expiration(id_token, leeway)
183
+ verify_iat(id_token)
184
+ verify_nonce(id_token, nonce)
185
+ verify_azp(id_token)
186
+ verify_auth_time(id_token, leeway, max_age)
187
+ verify_org(id_token, organization)
188
+ end
189
+
190
+ def verify_iss(id_token)
191
+ issuer = id_token['iss']
192
+ if !issuer
193
+ raise OmniAuth::Auth0::TokenValidationError.new("Issuer (iss) claim must be a string present in the ID token")
194
+ elsif @issuer != issuer
195
+ raise OmniAuth::Auth0::TokenValidationError.new("Issuer (iss) claim mismatch in the ID token, expected (#{@issuer}), found (#{id_token['iss']})")
196
+ end
197
+ end
198
+
199
+ def verify_sub(id_token)
200
+ subject = id_token['sub']
201
+ if !subject || !subject.is_a?(String) || subject.empty?
202
+ raise OmniAuth::Auth0::TokenValidationError.new('Subject (sub) claim must be a string present in the ID token')
203
+ end
204
+ end
205
+
206
+ def verify_aud(id_token)
207
+ audience = id_token['aud']
208
+ if !audience || !(audience.is_a?(String) || audience.is_a?(Array))
209
+ raise OmniAuth::Auth0::TokenValidationError.new("Audience (aud) claim must be a string or array of strings present in the ID token")
210
+ elsif audience.is_a?(Array) && !audience.include?(@client_id)
211
+ raise OmniAuth::Auth0::TokenValidationError.new("Audience (aud) claim mismatch in the ID token; expected #{@client_id} but was not one of #{audience.join(', ')}")
212
+ elsif audience.is_a?(String) && audience != @client_id
213
+ raise OmniAuth::Auth0::TokenValidationError.new("Audience (aud) claim mismatch in the ID token; expected #{@client_id} but found #{audience}")
214
+ end
215
+ end
216
+
217
+ def verify_expiration(id_token, leeway)
218
+ expiration = id_token['exp']
219
+ if !expiration || !expiration.is_a?(Integer)
220
+ raise OmniAuth::Auth0::TokenValidationError.new("Expiration time (exp) claim must be a number present in the ID token")
221
+ elsif expiration <= Time.now.to_i - leeway
222
+ raise OmniAuth::Auth0::TokenValidationError.new("Expiration time (exp) claim error in the ID token; current time (#{Time.now}) is after expiration time (#{Time.at(expiration + leeway)})")
223
+ end
224
+ end
225
+
226
+ def verify_iat(id_token)
227
+ if !id_token['iat']
228
+ raise OmniAuth::Auth0::TokenValidationError.new("Issued At (iat) claim must be a number present in the ID token")
229
+ end
230
+ end
231
+
232
+ def verify_nonce(id_token, nonce)
233
+ if nonce
234
+ received_nonce = id_token['nonce']
235
+ if !received_nonce
236
+ raise OmniAuth::Auth0::TokenValidationError.new("Nonce (nonce) claim must be a string present in the ID token")
237
+ elsif nonce != received_nonce
238
+ raise OmniAuth::Auth0::TokenValidationError.new("Nonce (nonce) claim value mismatch in the ID token; expected (#{nonce}), found (#{received_nonce})")
239
+ end
240
+ end
241
+ end
242
+
243
+ def verify_azp(id_token)
244
+ audience = id_token['aud']
245
+ if audience.is_a?(Array) && audience.length > 1
246
+ azp = id_token['azp']
247
+ if !azp || !azp.is_a?(String)
248
+ raise OmniAuth::Auth0::TokenValidationError.new("Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values")
249
+ elsif azp != @client_id
250
+ raise OmniAuth::Auth0::TokenValidationError.new("Authorized Party (azp) claim mismatch in the ID token; expected (#{@client_id}), found (#{azp})")
251
+ end
252
+ end
253
+ end
254
+
255
+ def verify_auth_time(id_token, leeway, max_age)
256
+ if max_age
257
+ auth_time = id_token['auth_time']
258
+ if !auth_time || !auth_time.is_a?(Integer)
259
+ raise OmniAuth::Auth0::TokenValidationError.new("Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified")
260
+ elsif Time.now.to_i > auth_time + max_age + leeway;
261
+ raise OmniAuth::Auth0::TokenValidationError.new("Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Current time (#{Time.now}) is after last auth time (#{Time.at(auth_time + max_age + leeway)})")
262
+ end
263
+ end
264
+ end
265
+
266
+ def verify_org(id_token, organization)
267
+ if organization
268
+ org_id = id_token['org_id']
269
+ if !org_id || !org_id.is_a?(String)
270
+ raise OmniAuth::Auth0::TokenValidationError.new("Organization Id (org_id) claim must be a string present in the ID token")
271
+ elsif org_id != organization
272
+ raise OmniAuth::Auth0::TokenValidationError.new("Organization Id (org_id) claim value mismatch in the ID token; expected '#{organization}', found '#{org_id}'")
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,36 @@
1
+ require 'json'
2
+
3
+ module OmniAuth
4
+ module Auth0
5
+ # Module to provide necessary telemetry for API requests.
6
+ module Telemetry
7
+
8
+ # Return a telemetry hash to be encoded and sent to Auth0.
9
+ # @return hash
10
+ def telemetry
11
+ telemetry = {
12
+ name: 'omniauth-auth0',
13
+ version: OmniAuth::Auth0::VERSION,
14
+ env: {
15
+ ruby: RUBY_VERSION
16
+ }
17
+ }
18
+ add_rails_version telemetry
19
+ end
20
+
21
+ # JSON-ify and base64 encode the current telemetry.
22
+ # @return string
23
+ def telemetry_encoded
24
+ Base64.urlsafe_encode64(JSON.dump(telemetry))
25
+ end
26
+
27
+ private
28
+
29
+ def add_rails_version(telemetry)
30
+ return telemetry unless Gem.loaded_specs['rails'].respond_to? :version
31
+ telemetry[:env][:rails] = Gem.loaded_specs['rails'].version.to_s
32
+ telemetry
33
+ end
34
+ end
35
+ end
36
+ end
@@ -1,19 +1,28 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'base64'
2
4
  require 'uri'
5
+ require 'securerandom'
3
6
  require 'omniauth-oauth2'
7
+ require 'omniauth/auth0/jwt_validator'
8
+ require 'omniauth/auth0/telemetry'
9
+ require 'omniauth/auth0/errors'
4
10
 
5
11
  module OmniAuth
6
12
  module Strategies
7
13
  # Auth0 OmniAuth strategy
8
14
  class Auth0 < OmniAuth::Strategies::OAuth2
15
+ include OmniAuth::Auth0::Telemetry
16
+
9
17
  option :name, 'auth0'
10
18
 
11
- args [
12
- :client_id,
13
- :client_secret,
14
- :domain
19
+ args %i[
20
+ client_id
21
+ client_secret
22
+ domain
15
23
  ]
16
24
 
25
+ # Setup client URLs used during authentication
17
26
  def client
18
27
  options.client_options.site = domain_url
19
28
  options.client_options.authorize_url = '/authorize'
@@ -22,25 +31,47 @@ module OmniAuth
22
31
  super
23
32
  end
24
33
 
34
+ # Use the "sub" key of the userinfo returned
35
+ # as the uid (globally unique string identifier).
25
36
  uid { raw_info['sub'] }
26
37
 
38
+ # Build the API credentials hash with returned auth data.
27
39
  credentials do
28
- hash = { 'token' => access_token.token }
29
- hash['expires'] = true
40
+ credentials = {
41
+ 'token' => access_token.token,
42
+ 'expires' => true
43
+ }
44
+
30
45
  if access_token.params
31
- hash['id_token'] = access_token.params['id_token']
32
- hash['token_type'] = access_token.params['token_type']
33
- hash['refresh_token'] = access_token.refresh_token
46
+ credentials.merge!(
47
+ 'id_token' => access_token.params['id_token'],
48
+ 'token_type' => access_token.params['token_type'],
49
+ 'refresh_token' => access_token.refresh_token
50
+ )
51
+ end
52
+
53
+ # Retrieve and remove authorization params from the session
54
+ session_authorize_params = session['authorize_params'] || {}
55
+ session.delete('authorize_params')
56
+
57
+ auth_scope = session_authorize_params[:scope]
58
+ if auth_scope.respond_to?(:include?) && auth_scope.include?('openid')
59
+ # Make sure the ID token can be verified and decoded.
60
+ jwt_validator.verify(credentials['id_token'], session_authorize_params)
34
61
  end
35
- hash
62
+
63
+ credentials
36
64
  end
37
65
 
66
+ # Store all raw information for use in the session.
38
67
  extra do
39
68
  {
40
69
  raw_info: raw_info
41
70
  }
42
71
  end
43
72
 
73
+ # Build a hash of information about the user
74
+ # with keys taken from the Auth Hash Schema.
44
75
  info do
45
76
  {
46
77
  name: raw_info['name'] || raw_info['sub'],
@@ -50,56 +81,93 @@ module OmniAuth
50
81
  }
51
82
  end
52
83
 
84
+ # Define the parameters used for the /authorize endpoint
53
85
  def authorize_params
54
86
  params = super
55
- params['auth0Client'] = client_info
87
+ %w[connection connection_scope prompt screen_hint login_hint organization invitation ui_locales].each do |key|
88
+ params[key] = request.params[key] if request.params.key?(key)
89
+ end
90
+
91
+ # Generate nonce
92
+ params[:nonce] = SecureRandom.hex
93
+ # Generate leeway if none exists
94
+ params[:leeway] = 60 unless params[:leeway]
95
+
96
+ # Store authorize params in the session for token verification
97
+ session['authorize_params'] = params.to_hash
98
+
56
99
  params
57
100
  end
58
101
 
102
+ def build_access_token
103
+ options.token_params[:headers] = { 'Auth0-Client' => telemetry_encoded }
104
+ super
105
+ end
106
+
107
+ # Declarative override for the request phase of authentication
59
108
  def request_phase
60
109
  if no_client_id?
110
+ # Do we have a client_id for this Application?
61
111
  fail!(:missing_client_id)
62
112
  elsif no_client_secret?
113
+ # Do we have a client_secret for this Application?
63
114
  fail!(:missing_client_secret)
64
115
  elsif no_domain?
116
+ # Do we have a domain for this Application?
65
117
  fail!(:missing_domain)
66
118
  else
119
+ # All checks pass, run the Oauth2 request_phase method.
67
120
  super
68
121
  end
69
122
  end
70
123
 
124
+ def callback_phase
125
+ super
126
+ rescue OmniAuth::Auth0::TokenValidationError => e
127
+ fail!(:token_validation_error, e)
128
+ end
129
+
71
130
  private
131
+ def jwt_validator
132
+ @jwt_validator ||= OmniAuth::Auth0::JWTValidator.new(options)
133
+ end
72
134
 
135
+ # Parse the raw user info.
73
136
  def raw_info
74
- userinfo_url = options.client_options.userinfo_url
75
- @raw_info ||= access_token.get(userinfo_url).parsed
137
+ return @raw_info if @raw_info
138
+
139
+ if access_token["id_token"]
140
+ claims, header = jwt_validator.decode(access_token["id_token"])
141
+ @raw_info = claims
142
+ else
143
+ userinfo_url = options.client_options.userinfo_url
144
+ @raw_info = access_token.get(userinfo_url).parsed
145
+ end
146
+
147
+ return @raw_info
76
148
  end
77
149
 
150
+ # Check if the options include a client_id
78
151
  def no_client_id?
79
152
  ['', nil].include?(options.client_id)
80
153
  end
81
154
 
155
+ # Check if the options include a client_secret
82
156
  def no_client_secret?
83
157
  ['', nil].include?(options.client_secret)
84
158
  end
85
159
 
160
+ # Check if the options include a domain
86
161
  def no_domain?
87
162
  ['', nil].include?(options.domain)
88
163
  end
89
164
 
165
+ # Normalize a domain to a URL.
90
166
  def domain_url
91
167
  domain_url = URI(options.domain)
92
168
  domain_url = URI("https://#{domain_url}") if domain_url.scheme.nil?
93
169
  domain_url.to_s
94
170
  end
95
-
96
- def client_info
97
- client_info = JSON.dump(
98
- name: 'omniauth-auth0',
99
- version: OmniAuth::Auth0::VERSION
100
- )
101
- Base64.urlsafe_encode64(client_info)
102
- end
103
171
  end
104
172
  end
105
173
  end
@@ -1,5 +1,5 @@
1
1
  module OmniAuth
2
2
  module Auth0
3
- VERSION = '2.0.0'.freeze
3
+ VERSION = '3.1.0'.freeze
4
4
  end
5
5
  end
@@ -1,2 +1,2 @@
1
- require 'omniauth-auth0/version' # rubocop:disable Style/FileName
1
+ require 'omniauth-auth0/version'
2
2
  require 'omniauth/strategies/auth0'
@@ -8,24 +8,23 @@ Gem::Specification.new do |s|
8
8
  s.authors = ['Auth0']
9
9
  s.email = ['info@auth0.com']
10
10
  s.homepage = 'https://github.com/auth0/omniauth-auth0'
11
- s.summary = 'Omniauth OAuth2 strategy for the Auth0 platform.'
11
+ s.summary = 'OmniAuth OAuth2 strategy for the Auth0 platform.'
12
12
  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.
13
13
 
14
14
  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.
15
15
 
16
- omniauth-auth0 is the omniauth strategy for Auth0.
16
+ omniauth-auth0 is the OmniAuth strategy for Auth0.
17
17
  }
18
18
 
19
- s.rubyforge_project = 'omniauth-auth0'
20
-
21
19
  s.files = `git ls-files`.split("\n")
22
20
  s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
23
21
  s.executables = `git ls-files -- bin/*`.split('\n').map{ |f| File.basename(f) }
24
22
  s.require_paths = ['lib']
25
23
 
26
- s.add_runtime_dependency 'omniauth-oauth2', '~> 1.4'
24
+ s.add_runtime_dependency 'omniauth', '~> 2'
25
+ s.add_runtime_dependency 'omniauth-oauth2', '~> 1'
26
+
27
+ s.add_development_dependency 'bundler'
27
28
 
28
- s.add_development_dependency 'bundler', '~> 1.9'
29
-
30
29
  s.license = 'MIT'
31
30
  end
data/opslevel.yml ADDED
@@ -0,0 +1,6 @@
1
+ ---
2
+ version: 1
3
+ repository:
4
+ owner: dx_sdks
5
+ tier:
6
+ tags: