omniauth-auth0 2.2.0 → 2.4.2

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.
@@ -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