omniauth-auth0 2.2.0 → 2.4.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,5 +1,5 @@
1
1
  module OmniAuth
2
2
  module Auth0
3
- VERSION = '2.2.0'.freeze
3
+ VERSION = '2.4.2'.freeze
4
4
  end
5
5
  end
@@ -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
@@ -2,6 +2,7 @@ require 'base64'
2
2
  require 'uri'
3
3
  require 'json'
4
4
  require 'omniauth'
5
+ require 'omniauth/auth0/errors'
5
6
 
6
7
  module OmniAuth
7
8
  module Auth0
@@ -15,7 +16,8 @@ module OmniAuth
15
16
  # options.issuer - Application issuer (optional).
16
17
  # options.client_id - Application Client ID.
17
18
  # options.client_secret - Application Client Secret.
18
- def initialize(options)
19
+
20
+ def initialize(options, authorize_params = {})
19
21
  @domain = uri_string(options.domain)
20
22
 
21
23
  # Use custom issuer if provided, otherwise use domain
@@ -26,23 +28,45 @@ module OmniAuth
26
28
  @client_secret = options.client_secret
27
29
  end
28
30
 
29
- # Decode a JWT.
30
- # @param jwt string - JWT to decode.
31
- # @return hash - The decoded token, if there were no exceptions.
32
- # @see https://github.com/jwt/ruby-jwt
33
- def decode(jwt)
31
+ # Verify a token's signature. Only tokens signed with the RS256 or HS256 signatures are supported.
32
+ # @return array - The token's key and signing algorithm
33
+ def verify_signature(jwt)
34
34
  head = token_head(jwt)
35
35
 
36
36
  # Make sure the algorithm is supported and get the decode key.
37
- decode_key = @client_secret
38
37
  if head[:alg] == 'RS256'
39
- decode_key = rs256_decode_key(head[:kid])
40
- elsif head[:alg] != 'HS256'
41
- raise JWT::VerificationError, :id_token_alg_unsupported
38
+ key, alg = [rs256_decode_key(head[:kid]), head[:alg]]
39
+ elsif head[:alg] == 'HS256'
40
+ key, alg = [@client_secret, head[:alg]]
41
+ else
42
+ raise OmniAuth::Auth0::TokenValidationError.new("Signature algorithm of #{head[:alg]} is not supported. Expected the ID token to be signed with RS256 or HS256")
43
+ end
44
+
45
+ # Call decode to verify the signature
46
+ JWT.decode(jwt, key, true, decode_opts(alg))
47
+
48
+ return key, alg
49
+ end
50
+
51
+ # Verify a JWT.
52
+ # @param jwt string - JWT to verify.
53
+ # @param authorize_params hash - Authorization params to verify on the JWT
54
+ # @return hash - The verified token, if there were no exceptions.
55
+ def verify(jwt, authorize_params = {})
56
+ if !jwt
57
+ raise OmniAuth::Auth0::TokenValidationError.new('ID token is required but missing')
58
+ end
59
+
60
+ parts = jwt.split('.')
61
+ if parts.length != 3
62
+ raise OmniAuth::Auth0::TokenValidationError.new('ID token could not be decoded')
42
63
  end
43
64
 
44
- # Docs: https://github.com/jwt/ruby-jwt#algorithms-and-usage
45
- JWT.decode(jwt, decode_key, true, decode_opts(head[:alg]))
65
+ key, alg = verify_signature(jwt)
66
+ id_token, header = JWT.decode(jwt, key, false)
67
+ verify_claims(id_token, authorize_params)
68
+
69
+ return id_token
46
70
  end
47
71
 
48
72
  # Get the decoded head segment from a JWT.
@@ -76,26 +100,28 @@ module OmniAuth
76
100
  end
77
101
 
78
102
  private
79
-
80
- # Get the JWT decode options
81
- # Docs: https://github.com/jwt/ruby-jwt#add-custom-header-fields
103
+ # Get the JWT decode options. We disable the claim checks since we perform our claim validation logic
104
+ # Docs: https://github.com/jwt/ruby-jwt
82
105
  # @return hash
83
106
  def decode_opts(alg)
84
107
  {
85
108
  algorithm: alg,
86
- leeway: 30,
87
- verify_expiration: true,
88
- verify_iss: true,
89
- iss: @issuer,
90
- verify_aud: true,
91
- aud: @client_id,
92
- verify_not_before: true
109
+ verify_expiration: false,
110
+ verify_iat: false,
111
+ verify_iss: false,
112
+ verify_aud: false,
113
+ verify_jti: false,
114
+ verify_subj: false,
115
+ verify_not_before: false
93
116
  }
94
117
  end
95
118
 
96
119
  def rs256_decode_key(kid)
97
120
  jwks_x5c = jwks_key(:x5c, kid)
98
- raise JWT::VerificationError, :jwks_missing_x5c if jwks_x5c.nil?
121
+
122
+ if jwks_x5c.nil?
123
+ raise OmniAuth::Auth0::TokenValidationError.new("Could not find a public key for Key ID (kid) '#{kid}'")
124
+ end
99
125
 
100
126
  jwks_public_cert(jwks_x5c.first)
101
127
  end
@@ -129,6 +155,97 @@ module OmniAuth
129
155
  temp_domain = URI("https://#{uri}") unless temp_domain.scheme
130
156
  "#{temp_domain}/"
131
157
  end
158
+
159
+ def verify_claims(id_token, authorize_params)
160
+ leeway = authorize_params[:leeway] || 60
161
+ max_age = authorize_params[:max_age]
162
+ nonce = authorize_params[:nonce]
163
+
164
+ verify_iss(id_token)
165
+ verify_sub(id_token)
166
+ verify_aud(id_token)
167
+ verify_expiration(id_token, leeway)
168
+ verify_iat(id_token)
169
+ verify_nonce(id_token, nonce)
170
+ verify_azp(id_token)
171
+ verify_auth_time(id_token, leeway, max_age)
172
+ end
173
+
174
+ def verify_iss(id_token)
175
+ issuer = id_token['iss']
176
+ if !issuer
177
+ raise OmniAuth::Auth0::TokenValidationError.new("Issuer (iss) claim must be a string present in the ID token")
178
+ elsif @issuer != issuer
179
+ raise OmniAuth::Auth0::TokenValidationError.new("Issuer (iss) claim mismatch in the ID token, expected (#{@issuer}), found (#{id_token['iss']})")
180
+ end
181
+ end
182
+
183
+ def verify_sub(id_token)
184
+ subject = id_token['sub']
185
+ if !subject || !subject.is_a?(String) || subject.empty?
186
+ raise OmniAuth::Auth0::TokenValidationError.new('Subject (sub) claim must be a string present in the ID token')
187
+ end
188
+ end
189
+
190
+ def verify_aud(id_token)
191
+ audience = id_token['aud']
192
+ if !audience || !(audience.is_a?(String) || audience.is_a?(Array))
193
+ raise OmniAuth::Auth0::TokenValidationError.new("Audience (aud) claim must be a string or array of strings present in the ID token")
194
+ elsif audience.is_a?(Array) && !audience.include?(@client_id)
195
+ raise OmniAuth::Auth0::TokenValidationError.new("Audience (aud) claim mismatch in the ID token; expected #{@client_id} but was not one of #{audience.join(', ')}")
196
+ elsif audience.is_a?(String) && audience != @client_id
197
+ raise OmniAuth::Auth0::TokenValidationError.new("Audience (aud) claim mismatch in the ID token; expected #{@client_id} but found #{audience}")
198
+ end
199
+ end
200
+
201
+ def verify_expiration(id_token, leeway)
202
+ expiration = id_token['exp']
203
+ if !expiration || !expiration.is_a?(Integer)
204
+ raise OmniAuth::Auth0::TokenValidationError.new("Expiration time (exp) claim must be a number present in the ID token")
205
+ elsif expiration <= Time.now.to_i - leeway
206
+ 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)})")
207
+ end
208
+ end
209
+
210
+ def verify_iat(id_token)
211
+ if !id_token['iat']
212
+ raise OmniAuth::Auth0::TokenValidationError.new("Issued At (iat) claim must be a number present in the ID token")
213
+ end
214
+ end
215
+
216
+ def verify_nonce(id_token, nonce)
217
+ if nonce
218
+ received_nonce = id_token['nonce']
219
+ if !received_nonce
220
+ raise OmniAuth::Auth0::TokenValidationError.new("Nonce (nonce) claim must be a string present in the ID token")
221
+ elsif nonce != received_nonce
222
+ raise OmniAuth::Auth0::TokenValidationError.new("Nonce (nonce) claim value mismatch in the ID token; expected (#{nonce}), found (#{received_nonce})")
223
+ end
224
+ end
225
+ end
226
+
227
+ def verify_azp(id_token)
228
+ audience = id_token['aud']
229
+ if audience.is_a?(Array) && audience.length > 1
230
+ azp = id_token['azp']
231
+ if !azp || !azp.is_a?(String)
232
+ 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")
233
+ elsif azp != @client_id
234
+ raise OmniAuth::Auth0::TokenValidationError.new("Authorized Party (azp) claim mismatch in the ID token; expected (#{@client_id}), found (#{azp})")
235
+ end
236
+ end
237
+ end
238
+
239
+ def verify_auth_time(id_token, leeway, max_age)
240
+ if max_age
241
+ auth_time = id_token['auth_time']
242
+ if !auth_time || !auth_time.is_a?(Integer)
243
+ 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")
244
+ elsif Time.now.to_i > auth_time + max_age + leeway;
245
+ 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)})")
246
+ end
247
+ end
248
+ end
132
249
  end
133
250
  end
134
251
  end
@@ -2,9 +2,11 @@
2
2
 
3
3
  require 'base64'
4
4
  require 'uri'
5
+ require 'securerandom'
5
6
  require 'omniauth-oauth2'
6
7
  require 'omniauth/auth0/jwt_validator'
7
8
  require 'omniauth/auth0/telemetry'
9
+ require 'omniauth/auth0/errors'
8
10
 
9
11
  module OmniAuth
10
12
  module Strategies
@@ -48,10 +50,16 @@ module OmniAuth
48
50
  )
49
51
  end
50
52
 
51
- # Make sure the ID token can be verified and decoded.
52
- auth0_jwt = OmniAuth::Auth0::JWTValidator.new(options)
53
- jwt_decoded = auth0_jwt.decode(credentials['id_token'])
54
- fail!(:invalid_id_token) unless jwt_decoded.length
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
+ auth0_jwt = OmniAuth::Auth0::JWTValidator.new(options)
61
+ auth0_jwt.verify(credentials['id_token'], session_authorize_params)
62
+ end
55
63
 
56
64
  credentials
57
65
  end
@@ -78,8 +86,18 @@ module OmniAuth
78
86
  def authorize_params
79
87
  params = super
80
88
  parsed_query = Rack::Utils.parse_query(request.query_string)
81
- params['connection'] = parsed_query['connection']
82
- params['prompt'] = parsed_query['prompt']
89
+ %w[connection connection_scope prompt screen_hint].each do |key|
90
+ params[key] = parsed_query[key] if parsed_query.key?(key)
91
+ end
92
+
93
+ # Generate nonce
94
+ params[:nonce] = SecureRandom.hex
95
+ # Generate leeway if none exists
96
+ params[:leeway] = 60 unless params[:leeway]
97
+
98
+ # Store authorize params in the session for token verification
99
+ session['authorize_params'] = params
100
+
83
101
  params
84
102
  end
85
103
 
@@ -105,6 +123,12 @@ module OmniAuth
105
123
  end
106
124
  end
107
125
 
126
+ def callback_phase
127
+ super
128
+ rescue OmniAuth::Auth0::TokenValidationError => e
129
+ fail!(:token_validation_error, e)
130
+ end
131
+
108
132
  private
109
133
 
110
134
  # Parse the raw user info.
@@ -16,15 +16,13 @@ OmniAuth is a library that standardizes multi-provider authentication for web ap
16
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
24
  s.add_runtime_dependency 'omniauth-oauth2', '~> 1.5'
27
-
25
+ s.add_runtime_dependency 'omniauth', '~> 1.9'
28
26
  s.add_development_dependency 'bundler', '~> 1.9'
29
27
 
30
28
  s.license = 'MIT'
@@ -12,17 +12,17 @@ describe OmniAuth::Auth0::JWTValidator do
12
12
  let(:domain) { 'samples.auth0.com' }
13
13
  let(:future_timecode) { 32_503_680_000 }
14
14
  let(:past_timecode) { 303_912_000 }
15
- let(:jwks_kid) { 'NkJCQzIyQzRBMEU4NjhGNUU4MzU4RkY0M0ZDQzkwOUQ0Q0VGNUMwQg' }
15
+ let(:valid_jwks_kid) { 'NkJCQzIyQzRBMEU4NjhGNUU4MzU4RkY0M0ZDQzkwOUQ0Q0VGNUMwQg' }
16
16
 
17
17
  let(:rsa_private_key) do
18
18
  OpenSSL::PKey::RSA.generate 2048
19
19
  end
20
20
 
21
- let(:rsa_token_jwks) do
21
+ let(:valid_jwks) do
22
22
  {
23
23
  keys: [
24
24
  {
25
- kid: jwks_kid,
25
+ kid: valid_jwks_kid,
26
26
  x5c: [Base64.encode64(make_cert(rsa_private_key).to_der)]
27
27
  }
28
28
  ]
@@ -91,29 +91,29 @@ describe OmniAuth::Auth0::JWTValidator do
91
91
  end
92
92
  end
93
93
 
94
- describe 'JWT verifier jwks_key' do
94
+ describe 'JWT verifier jwks key parsing' do
95
95
  let(:jwt_validator) do
96
96
  make_jwt_validator
97
97
  end
98
98
 
99
99
  before do
100
- stub_jwks
100
+ stub_complete_jwks
101
101
  end
102
102
 
103
103
  it 'should return a key' do
104
- expect(jwt_validator.jwks_key(:alg, jwks_kid)).to eq('RS256')
104
+ expect(jwt_validator.jwks_key(:alg, valid_jwks_kid)).to eq('RS256')
105
105
  end
106
106
 
107
107
  it 'should return an x5c key' do
108
- expect(jwt_validator.jwks_key(:x5c, jwks_kid).length).to eq(1)
108
+ expect(jwt_validator.jwks_key(:x5c, valid_jwks_kid).length).to eq(1)
109
109
  end
110
110
 
111
111
  it 'should return nil if there is not key' do
112
- expect(jwt_validator.jwks_key(:auth0, jwks_kid)).to eq(nil)
112
+ expect(jwt_validator.jwks_key(:auth0, valid_jwks_kid)).to eq(nil)
113
113
  end
114
114
 
115
115
  it 'should return nil if the key ID is invalid' do
116
- expect(jwt_validator.jwks_key(:alg, "#{jwks_kid}_invalid")).to eq(nil)
116
+ expect(jwt_validator.jwks_key(:alg, "#{valid_jwks_kid}_invalid")).to eq(nil)
117
117
  end
118
118
  end
119
119
 
@@ -147,30 +147,38 @@ describe OmniAuth::Auth0::JWTValidator do
147
147
  end
148
148
  end
149
149
 
150
- describe 'JWT verifier decode' do
150
+ describe 'JWT verifier verify' do
151
151
  let(:jwt_validator) do
152
152
  make_jwt_validator
153
153
  end
154
154
 
155
155
  before do
156
- stub_jwks
157
- stub_dummy_jwks
156
+ stub_complete_jwks
157
+ stub_expected_jwks
158
158
  end
159
159
 
160
- it 'should fail with passed expiration' do
161
- payload = {
162
- exp: past_timecode
163
- }
164
- token = make_hs256_token(payload)
160
+ it 'should fail when JWT is nil' do
161
+ expect do
162
+ jwt_validator.verify(nil)
163
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
164
+ message: "ID token is required but missing"
165
+ }))
166
+ end
167
+
168
+ it 'should fail when JWT is not well-formed' do
165
169
  expect do
166
- jwt_validator.decode(token)
167
- end.to raise_error(JWT::ExpiredSignature)
170
+ jwt_validator.verify('abc.123')
171
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
172
+ message: "ID token could not be decoded"
173
+ }))
168
174
  end
169
175
 
170
176
  it 'should fail with missing issuer' do
171
177
  expect do
172
- jwt_validator.decode(make_hs256_token)
173
- end.to raise_error(JWT::InvalidIssuerError)
178
+ jwt_validator.verify(make_hs256_token)
179
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
180
+ message: "Issuer (iss) claim must be a string present in the ID token"
181
+ }))
174
182
  end
175
183
 
176
184
  it 'should fail with invalid issuer' do
@@ -179,55 +187,287 @@ describe OmniAuth::Auth0::JWTValidator do
179
187
  }
180
188
  token = make_hs256_token(payload)
181
189
  expect do
182
- jwt_validator.decode(token)
183
- end.to raise_error(JWT::InvalidIssuerError)
190
+ jwt_validator.verify(token)
191
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
192
+ message: "Issuer (iss) claim mismatch in the ID token, expected (https://samples.auth0.com/), found (https://auth0.com/)"
193
+ }))
184
194
  end
185
195
 
186
- it 'should fail with a future not before' do
196
+ it 'should fail when subject is missing' do
187
197
  payload = {
188
- nbf: future_timecode,
189
- iss: "https://#{domain}/"
198
+ iss: "https://#{domain}/",
199
+ sub: ''
190
200
  }
191
201
  token = make_hs256_token(payload)
192
202
  expect do
193
- jwt_validator.decode(token)
194
- end.to raise_error(JWT::ImmatureSignature)
203
+ jwt_validator.verify(token)
204
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
205
+ message: "Subject (sub) claim must be a string present in the ID token"
206
+ }))
195
207
  end
196
208
 
197
209
  it 'should fail with missing audience' do
198
210
  payload = {
199
- iss: "https://#{domain}/"
211
+ iss: "https://#{domain}/",
212
+ sub: 'sub'
200
213
  }
201
214
  token = make_hs256_token(payload)
202
215
  expect do
203
- jwt_validator.decode(token)
204
- end.to raise_error(JWT::InvalidAudError)
216
+ jwt_validator.verify(token)
217
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
218
+ message: "Audience (aud) claim must be a string or array of strings present in the ID token"
219
+ }))
205
220
  end
206
221
 
207
222
  it 'should fail with invalid audience' do
208
223
  payload = {
209
224
  iss: "https://#{domain}/",
225
+ sub: 'sub',
210
226
  aud: 'Auth0'
211
227
  }
212
228
  token = make_hs256_token(payload)
213
229
  expect do
214
- jwt_validator.decode(token)
215
- end.to raise_error(JWT::InvalidAudError)
230
+ jwt_validator.verify(token)
231
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
232
+ message: "Audience (aud) claim mismatch in the ID token; expected #{client_id} but found Auth0"
233
+ }))
234
+ end
235
+
236
+ it 'should fail when missing expiration' do
237
+ payload = {
238
+ iss: "https://#{domain}/",
239
+ sub: 'sub',
240
+ aud: client_id
241
+ }
242
+
243
+ token = make_hs256_token(payload)
244
+ expect do
245
+ jwt_validator.verify(token)
246
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
247
+ message: "Expiration time (exp) claim must be a number present in the ID token"
248
+ }))
216
249
  end
217
250
 
218
- it 'should decode a valid HS256 token with multiple audiences' do
251
+ it 'should fail when past expiration' do
219
252
  payload = {
220
253
  iss: "https://#{domain}/",
221
- aud: [
222
- client_id,
223
- "https://#{domain}/userinfo"
224
- ]
254
+ sub: 'sub',
255
+ aud: client_id,
256
+ exp: past_timecode
225
257
  }
258
+
226
259
  token = make_hs256_token(payload)
227
- expect(jwt_validator.decode(token).length).to eq(2)
260
+ expect do
261
+ jwt_validator.verify(token)
262
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
263
+ message: "Expiration time (exp) claim error in the ID token; current time (#{Time.now}) is after expiration time (#{Time.at(past_timecode + 60)})"
264
+ }))
228
265
  end
229
266
 
230
- it 'should decode a standard HS256 token' do
267
+ it 'should pass when past expiration but within default leeway' do
268
+ exp = Time.now.to_i - 59
269
+ payload = {
270
+ iss: "https://#{domain}/",
271
+ sub: 'sub',
272
+ aud: client_id,
273
+ exp: exp,
274
+ iat: past_timecode
275
+ }
276
+
277
+ token = make_hs256_token(payload)
278
+ id_token = jwt_validator.verify(token)
279
+ expect(id_token['exp']).to eq(exp)
280
+ end
281
+
282
+ it 'should fail when past expiration and outside default leeway' do
283
+ exp = Time.now.to_i - 61
284
+ payload = {
285
+ iss: "https://#{domain}/",
286
+ sub: 'sub',
287
+ aud: client_id,
288
+ exp: exp,
289
+ iat: past_timecode
290
+ }
291
+
292
+ token = make_hs256_token(payload)
293
+ expect do
294
+ jwt_validator.verify(token)
295
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
296
+ message: "Expiration time (exp) claim error in the ID token; current time (#{Time.now}) is after expiration time (#{Time.at(exp + 60)})"
297
+ }))
298
+ end
299
+
300
+ it 'should fail when missing iat' do
301
+ payload = {
302
+ iss: "https://#{domain}/",
303
+ sub: 'sub',
304
+ aud: client_id,
305
+ exp: future_timecode
306
+ }
307
+
308
+ token = make_hs256_token(payload)
309
+ expect do
310
+ jwt_validator.verify(token)
311
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
312
+ message: "Issued At (iat) claim must be a number present in the ID token"
313
+ }))
314
+ end
315
+
316
+ it 'should fail when authorize params has nonce but nonce is missing in the token' do
317
+ payload = {
318
+ iss: "https://#{domain}/",
319
+ sub: 'sub',
320
+ aud: client_id,
321
+ exp: future_timecode,
322
+ iat: past_timecode
323
+ }
324
+
325
+ token = make_hs256_token(payload)
326
+ expect do
327
+ jwt_validator.verify(token, { nonce: 'noncey' })
328
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
329
+ message: "Nonce (nonce) claim must be a string present in the ID token"
330
+ }))
331
+ end
332
+
333
+ it 'should fail when authorize params has nonce but token nonce does not match' do
334
+ payload = {
335
+ iss: "https://#{domain}/",
336
+ sub: 'sub',
337
+ aud: client_id,
338
+ exp: future_timecode,
339
+ iat: past_timecode,
340
+ nonce: 'mismatch'
341
+ }
342
+
343
+ token = make_hs256_token(payload)
344
+ expect do
345
+ jwt_validator.verify(token, { nonce: 'noncey' })
346
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
347
+ message: "Nonce (nonce) claim value mismatch in the ID token; expected (noncey), found (mismatch)"
348
+ }))
349
+ end
350
+
351
+ it 'should fail when “aud” is an array of strings and azp claim is not present' do
352
+ aud = [
353
+ client_id,
354
+ "https://#{domain}/userinfo"
355
+ ]
356
+ payload = {
357
+ iss: "https://#{domain}/",
358
+ sub: 'sub',
359
+ aud: aud,
360
+ exp: future_timecode,
361
+ iat: past_timecode
362
+ }
363
+
364
+ token = make_hs256_token(payload)
365
+ expect do
366
+ jwt_validator.verify(token)
367
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
368
+ message: "Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values"
369
+ }))
370
+ end
371
+
372
+ it 'should fail when "azp" claim doesnt match the expected aud' do
373
+ aud = [
374
+ client_id,
375
+ "https://#{domain}/userinfo"
376
+ ]
377
+ payload = {
378
+ iss: "https://#{domain}/",
379
+ sub: 'sub',
380
+ aud: aud,
381
+ exp: future_timecode,
382
+ iat: past_timecode,
383
+ azp: 'not_expected'
384
+ }
385
+
386
+ token = make_hs256_token(payload)
387
+ expect do
388
+ jwt_validator.verify(token)
389
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
390
+ message: "Authorized Party (azp) claim mismatch in the ID token; expected (#{client_id}), found (not_expected)"
391
+ }))
392
+ end
393
+
394
+ it 'should fail when “max_age” sent on the authentication request and this claim is not present' do
395
+ payload = {
396
+ iss: "https://#{domain}/",
397
+ sub: 'sub',
398
+ aud: client_id,
399
+ exp: future_timecode,
400
+ iat: past_timecode
401
+ }
402
+
403
+ token = make_hs256_token(payload)
404
+ expect do
405
+ jwt_validator.verify(token, { max_age: 60 })
406
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
407
+ message: "Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified"
408
+ }))
409
+ end
410
+
411
+ it 'should fail when “max_age” sent on the authentication request and this claim added the “max_age” value doesn’t represent a date in the future' do
412
+ payload = {
413
+ iss: "https://#{domain}/",
414
+ sub: 'sub',
415
+ aud: client_id,
416
+ exp: future_timecode,
417
+ iat: past_timecode,
418
+ auth_time: past_timecode
419
+ }
420
+
421
+ token = make_hs256_token(payload)
422
+ expect do
423
+ jwt_validator.verify(token, { max_age: 60 })
424
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
425
+ message: "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(past_timecode + 60 + 60)})"
426
+ }))
427
+ end
428
+
429
+ it 'should fail when “max_age” sent on the authentication request and this claim added the “max_age” value doesn’t represent a date in the future, outside the default leeway' do
430
+ now = Time.now.to_i
431
+ auth_time = now - 121
432
+ max_age = 60
433
+ payload = {
434
+ iss: "https://#{domain}/",
435
+ sub: 'sub',
436
+ aud: client_id,
437
+ exp: future_timecode,
438
+ iat: past_timecode,
439
+ auth_time: auth_time
440
+ }
441
+
442
+ token = make_hs256_token(payload)
443
+ expect do
444
+ jwt_validator.verify(token, { max_age: max_age })
445
+ # Time.at(auth_time + max_age + leeway
446
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
447
+ message: "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 + 60)})"
448
+ }))
449
+ end
450
+
451
+ it 'should verify when “max_age” sent on the authentication request and this claim added the “max_age” value doesn’t represent a date in the future, outside the default leeway' do
452
+ now = Time.now.to_i
453
+ auth_time = now - 119
454
+ max_age = 60
455
+ payload = {
456
+ iss: "https://#{domain}/",
457
+ sub: 'sub',
458
+ aud: client_id,
459
+ exp: future_timecode,
460
+ iat: past_timecode,
461
+ auth_time: auth_time
462
+ }
463
+
464
+ token = make_hs256_token(payload)
465
+ id_token = jwt_validator.verify(token, { max_age: max_age })
466
+ expect(id_token['auth_time']).to eq(auth_time)
467
+ end
468
+
469
+ it 'should fail for RS256 token when kid is incorrect' do
470
+ domain = 'example.org'
231
471
  sub = 'abc123'
232
472
  payload = {
233
473
  sub: sub,
@@ -236,12 +476,16 @@ describe OmniAuth::Auth0::JWTValidator do
236
476
  iat: past_timecode,
237
477
  aud: client_id
238
478
  }
239
- token = make_hs256_token(payload)
240
- decoded_token = jwt_validator.decode(token)
241
- expect(decoded_token.first['sub']).to eq(sub)
479
+ invalid_kid = 'invalid-kid'
480
+ token = make_rs256_token(payload, invalid_kid)
481
+ expect do
482
+ verified_token = make_jwt_validator(opt_domain: domain).verify(token)
483
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
484
+ message: "Could not find a public key for Key ID (kid) 'invalid-kid'"
485
+ }))
242
486
  end
243
487
 
244
- it 'should decode a standard RS256 token' do
488
+ it 'should fail when RS256 token has invalid signature' do
245
489
  domain = 'example.org'
246
490
  sub = 'abc123'
247
491
  payload = {
@@ -249,12 +493,124 @@ describe OmniAuth::Auth0::JWTValidator do
249
493
  exp: future_timecode,
250
494
  iss: "https://#{domain}/",
251
495
  iat: past_timecode,
496
+ aud: client_id
497
+ }
498
+ token = make_rs256_token(payload) + 'bad'
499
+ expect do
500
+ verified_token = make_jwt_validator(opt_domain: domain).verify(token)
501
+ end.to raise_error(an_instance_of(JWT::VerificationError).and having_attributes({
502
+ message: "Signature verification raised"
503
+ }))
504
+ end
505
+
506
+ it 'should fail when algorithm is not RS256 or HS256' do
507
+ payload = {
508
+ iss: "https://#{domain}/",
509
+ sub: 'abc123',
510
+ aud: client_id,
511
+ exp: future_timecode,
512
+ iat: past_timecode
513
+ }
514
+ token = JWT.encode payload, 'secret', 'HS384'
515
+ expect do
516
+ jwt_validator.verify(token)
517
+ end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
518
+ message: "Signature algorithm of HS384 is not supported. Expected the ID token to be signed with RS256 or HS256"
519
+ }))
520
+ end
521
+
522
+ it 'should fail when HS256 token has invalid signature' do
523
+ payload = {
524
+ iss: "https://#{domain}/",
525
+ sub: 'abc123',
526
+ aud: client_id,
527
+ exp: future_timecode,
528
+ iat: past_timecode
529
+ }
530
+ token = make_hs256_token(payload, 'bad_secret')
531
+ expect do
532
+ # validator is configured to use "CLIENT_SECRET" by default
533
+ jwt_validator.verify(token)
534
+ end.to raise_error(an_instance_of(JWT::VerificationError))
535
+ end
536
+
537
+ it 'should verify a valid HS256 token with multiple audiences' do
538
+ audience = [
539
+ client_id,
540
+ "https://#{domain}/userinfo"
541
+ ]
542
+ payload = {
543
+ iss: "https://#{domain}/",
544
+ sub: 'sub',
545
+ aud: audience,
546
+ exp: future_timecode,
547
+ iat: past_timecode,
548
+ azp: client_id
549
+ }
550
+ token = make_hs256_token(payload)
551
+ id_token = jwt_validator.verify(token)
552
+ expect(id_token['aud']).to eq(audience)
553
+ end
554
+
555
+ it 'should verify a standard HS256 token' do
556
+ sub = 'abc123'
557
+ payload = {
558
+ iss: "https://#{domain}/",
559
+ sub: sub,
252
560
  aud: client_id,
253
- kid: jwks_kid
561
+ exp: future_timecode,
562
+ iat: past_timecode
563
+ }
564
+ token = make_hs256_token(payload)
565
+ verified_token = jwt_validator.verify(token)
566
+ expect(verified_token['sub']).to eq(sub)
567
+ end
568
+
569
+ it 'should verify a standard RS256 token' do
570
+ domain = 'example.org'
571
+ sub = 'abc123'
572
+ payload = {
573
+ sub: sub,
574
+ exp: future_timecode,
575
+ iss: "https://#{domain}/",
576
+ iat: past_timecode,
577
+ aud: client_id
254
578
  }
255
579
  token = make_rs256_token(payload)
256
- decoded_token = make_jwt_validator(opt_domain: domain).decode(token)
257
- expect(decoded_token.first['sub']).to eq(sub)
580
+ verified_token = make_jwt_validator(opt_domain: domain).verify(token)
581
+ expect(verified_token['sub']).to eq(sub)
582
+ end
583
+
584
+ it 'should verify a HS256 JWT signature when calling verify signature directly' do
585
+ sub = 'abc123'
586
+ payload = {
587
+ iss: "https://#{domain}/",
588
+ sub: sub,
589
+ aud: client_id,
590
+ exp: future_timecode,
591
+ iat: past_timecode
592
+ }
593
+ token = make_hs256_token(payload)
594
+ verified_token_signature = jwt_validator.verify_signature(token)
595
+ expect(verified_token_signature[0]).to eq('CLIENT_SECRET')
596
+ expect(verified_token_signature[1]).to eq('HS256')
597
+ end
598
+
599
+ it 'should verify a RS256 JWT signature verify signature directly' do
600
+ domain = 'example.org'
601
+ sub = 'abc123'
602
+ payload = {
603
+ sub: sub,
604
+ exp: future_timecode,
605
+ iss: "https://#{domain}/",
606
+ iat: past_timecode,
607
+ aud: client_id
608
+ }
609
+ token = make_rs256_token(payload)
610
+ verified_token_signature = make_jwt_validator(opt_domain: domain).verify_signature(token)
611
+ expect(verified_token_signature.length).to be(2)
612
+ expect(verified_token_signature[0]).to be_a(OpenSSL::PKey::RSA)
613
+ expect(verified_token_signature[1]).to eq('RS256')
258
614
  end
259
615
  end
260
616
 
@@ -271,14 +627,16 @@ describe OmniAuth::Auth0::JWTValidator do
271
627
  OmniAuth::Auth0::JWTValidator.new(opts)
272
628
  end
273
629
 
274
- def make_hs256_token(payload = nil)
630
+ def make_hs256_token(payload = nil, secret = nil)
275
631
  payload = { sub: 'abc123' } if payload.nil?
276
- JWT.encode payload, client_secret, 'HS256'
632
+ secret = client_secret if secret.nil?
633
+ JWT.encode payload, secret, 'HS256'
277
634
  end
278
635
 
279
- def make_rs256_token(payload = nil)
636
+ def make_rs256_token(payload = nil, kid = nil)
280
637
  payload = { sub: 'abc123' } if payload.nil?
281
- JWT.encode payload, rsa_private_key, 'RS256', kid: jwks_kid
638
+ kid = valid_jwks_kid if kid.nil?
639
+ JWT.encode payload, rsa_private_key, 'RS256', kid: kid
282
640
  end
283
641
 
284
642
  def make_cert(private_key)
@@ -306,7 +664,7 @@ describe OmniAuth::Auth0::JWTValidator do
306
664
  cert.sign private_key, OpenSSL::Digest::SHA1.new
307
665
  end
308
666
 
309
- def stub_jwks
667
+ def stub_complete_jwks
310
668
  stub_request(:get, 'https://samples.auth0.com/.well-known/jwks.json')
311
669
  .to_return(
312
670
  headers: { 'Content-Type' => 'application/json' },
@@ -315,18 +673,11 @@ describe OmniAuth::Auth0::JWTValidator do
315
673
  )
316
674
  end
317
675
 
318
- def stub_bad_jwks
319
- stub_request(:get, 'https://samples.auth0.com/.well-known/jwks-bad.json')
320
- .to_return(
321
- status: 404
322
- )
323
- end
324
-
325
- def stub_dummy_jwks
676
+ def stub_expected_jwks
326
677
  stub_request(:get, 'https://example.org/.well-known/jwks.json')
327
678
  .to_return(
328
679
  headers: { 'Content-Type' => 'application/json' },
329
- body: rsa_token_jwks,
680
+ body: valid_jwks,
330
681
  status: 200
331
682
  )
332
683
  end