omniauth-auth0 2.0.0 → 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: