omniauth-auth0 2.1.0 → 2.4.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,2 +1,2 @@
1
- require 'omniauth-auth0/version' # rubocop:disable Style/FileName
1
+ require 'omniauth-auth0/version'
2
2
  require 'omniauth/strategies/auth0'
@@ -1,5 +1,5 @@
1
1
  module OmniAuth
2
2
  module Auth0
3
- VERSION = '2.1.0'.freeze
3
+ VERSION = '2.4.1'.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,44 +2,71 @@ 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
8
9
  # JWT Validator class
9
10
  class JWTValidator
10
- attr_accessor :issuer
11
+ attr_accessor :issuer, :domain
11
12
 
12
13
  # Initializer
13
14
  # @param options object
14
15
  # options.domain - Application domain.
16
+ # options.issuer - Application issuer (optional).
15
17
  # options.client_id - Application Client ID.
16
18
  # options.client_secret - Application Client Secret.
17
- def initialize(options)
18
- temp_domain = URI(options.domain)
19
- temp_domain = URI("https://#{options.domain}") unless temp_domain.scheme
20
- @issuer = "#{temp_domain}/"
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)
21
26
 
22
27
  @client_id = options.client_id
23
28
  @client_secret = options.client_secret
24
29
  end
25
30
 
26
- # Decode a JWT.
27
- # @param jwt string - JWT to decode.
28
- # @return hash - The decoded token, if there were no exceptions.
29
- # @see https://github.com/jwt/ruby-jwt
30
- 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)
31
34
  head = token_head(jwt)
32
35
 
33
36
  # Make sure the algorithm is supported and get the decode key.
34
- decode_key = @client_secret
35
37
  if head[:alg] == 'RS256'
36
- decode_key = rs256_decode_key(head[:kid])
37
- elsif head[:alg] != 'HS256'
38
- 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")
39
43
  end
40
44
 
41
- # Docs: https://github.com/jwt/ruby-jwt#algorithms-and-usage
42
- JWT.decode(jwt, decode_key, true, decode_opts(head[:alg]))
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')
63
+ end
64
+
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
43
70
  end
44
71
 
45
72
  # Get the decoded head segment from a JWT.
@@ -73,34 +100,36 @@ module OmniAuth
73
100
  end
74
101
 
75
102
  private
76
-
77
- # Get the JWT decode options
78
- # 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
79
105
  # @return hash
80
106
  def decode_opts(alg)
81
107
  {
82
108
  algorithm: alg,
83
- leeway: 30,
84
- verify_expiration: true,
85
- verify_iss: true,
86
- iss: @issuer,
87
- verify_aud: true,
88
- aud: @client_id,
89
- 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
90
116
  }
91
117
  end
92
118
 
93
119
  def rs256_decode_key(kid)
94
120
  jwks_x5c = jwks_key(:x5c, kid)
95
- 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
96
125
 
97
126
  jwks_public_cert(jwks_x5c.first)
98
127
  end
99
128
 
100
- # Get a JWKS from the issuer
129
+ # Get a JWKS from the domain
101
130
  # @return void
102
131
  def jwks
103
- jwks_uri = URI(@issuer + '.well-known/jwks.json')
132
+ jwks_uri = URI(@domain + '.well-known/jwks.json')
104
133
  @jwks ||= json_parse(Net::HTTP.get(jwks_uri))
105
134
  end
106
135
 
@@ -117,6 +146,106 @@ module OmniAuth
117
146
  def json_parse(json)
118
147
  JSON.parse(json, symbolize_names: true)
119
148
  end
149
+
150
+ # Parse a URI into the desired string format
151
+ # @param uri - the URI to parse
152
+ # @return string
153
+ def uri_string(uri)
154
+ temp_domain = URI(uri)
155
+ temp_domain = URI("https://#{uri}") unless temp_domain.scheme
156
+ "#{temp_domain}/"
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
120
249
  end
121
250
  end
122
251
  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,12 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'base64'
2
4
  require 'uri'
5
+ require 'securerandom'
3
6
  require 'omniauth-oauth2'
4
7
  require 'omniauth/auth0/jwt_validator'
8
+ require 'omniauth/auth0/telemetry'
9
+ require 'omniauth/auth0/errors'
5
10
 
6
11
  module OmniAuth
7
12
  module Strategies
8
13
  # Auth0 OmniAuth strategy
9
14
  class Auth0 < OmniAuth::Strategies::OAuth2
15
+ include OmniAuth::Auth0::Telemetry
16
+
10
17
  option :name, 'auth0'
11
18
 
12
19
  args %i[
@@ -43,10 +50,16 @@ module OmniAuth
43
50
  )
44
51
  end
45
52
 
46
- # Make sure the ID token can be verified and decoded.
47
- auth0_jwt = OmniAuth::Auth0::JWTValidator.new(options)
48
- jwt_decoded = auth0_jwt.decode(credentials['id_token'])
49
- 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
50
63
 
51
64
  credentials
52
65
  end
@@ -72,13 +85,27 @@ module OmniAuth
72
85
  # Define the parameters used for the /authorize endpoint
73
86
  def authorize_params
74
87
  params = super
75
- params['auth0Client'] = client_info
76
- parse_query = Rack::Utils.parse_query(request.query_string)
77
- params['connection'] = parse_query['connection']
78
- params['prompt'] = parse_query['prompt']
88
+ parsed_query = Rack::Utils.parse_query(request.query_string)
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
+
79
101
  params
80
102
  end
81
103
 
104
+ def build_access_token
105
+ options.token_params[:headers] = { 'Auth0-Client' => telemetry_encoded }
106
+ super
107
+ end
108
+
82
109
  # Declarative override for the request phase of authentication
83
110
  def request_phase
84
111
  if no_client_id?
@@ -96,6 +123,12 @@ module OmniAuth
96
123
  end
97
124
  end
98
125
 
126
+ def callback_phase
127
+ super
128
+ rescue OmniAuth::Auth0::TokenValidationError => e
129
+ fail!(:token_validation_error, e)
130
+ end
131
+
99
132
  private
100
133
 
101
134
  # Parse the raw user info.
@@ -125,15 +158,6 @@ module OmniAuth
125
158
  domain_url = URI("https://#{domain_url}") if domain_url.scheme.nil?
126
159
  domain_url.to_s
127
160
  end
128
-
129
- # Build the auth0Client URL parameter for metrics.
130
- def client_info
131
- client_info = JSON.dump(
132
- name: 'omniauth-auth0',
133
- version: OmniAuth::Auth0::VERSION
134
- )
135
- Base64.urlsafe_encode64(client_info)
136
- end
137
161
  end
138
162
  end
139
163
  end
@@ -16,8 +16,6 @@ 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) }
@@ -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
  ]
@@ -35,8 +35,6 @@ describe OmniAuth::Auth0::JWTValidator do
35
35
  JSON.parse(jwks_file, symbolize_names: true)
36
36
  end
37
37
 
38
- Options = Struct.new(:domain, :client_id, :client_secret)
39
-
40
38
  #
41
39
  # Specs
42
40
  #
@@ -93,56 +91,94 @@ describe OmniAuth::Auth0::JWTValidator do
93
91
  end
94
92
  end
95
93
 
96
- describe 'JWT verifier jwks_key' do
94
+ describe 'JWT verifier jwks key parsing' do
97
95
  let(:jwt_validator) do
98
96
  make_jwt_validator
99
97
  end
100
98
 
101
99
  before do
102
- stub_jwks
100
+ stub_complete_jwks
103
101
  end
104
102
 
105
103
  it 'should return a key' do
106
- expect(jwt_validator.jwks_key(:alg, jwks_kid)).to eq('RS256')
104
+ expect(jwt_validator.jwks_key(:alg, valid_jwks_kid)).to eq('RS256')
107
105
  end
108
106
 
109
107
  it 'should return an x5c key' do
110
- 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)
111
109
  end
112
110
 
113
111
  it 'should return nil if there is not key' do
114
- expect(jwt_validator.jwks_key(:auth0, jwks_kid)).to eq(nil)
112
+ expect(jwt_validator.jwks_key(:auth0, valid_jwks_kid)).to eq(nil)
115
113
  end
116
114
 
117
115
  it 'should return nil if the key ID is invalid' do
118
- 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
+ end
118
+ end
119
+
120
+ describe 'JWT verifier custom issuer' do
121
+ context 'same as domain' do
122
+ let(:jwt_validator) do
123
+ make_jwt_validator(opt_issuer: domain)
124
+ end
125
+
126
+ it 'should have the correct issuer' do
127
+ expect(jwt_validator.issuer).to eq('https://samples.auth0.com/')
128
+ end
129
+
130
+ it 'should have the correct domain' do
131
+ expect(jwt_validator.issuer).to eq('https://samples.auth0.com/')
132
+ end
133
+ end
134
+
135
+ context 'different from domain' do
136
+ let(:jwt_validator) do
137
+ make_jwt_validator(opt_issuer: 'different.auth0.com')
138
+ end
139
+
140
+ it 'should have the correct issuer' do
141
+ expect(jwt_validator.issuer).to eq('https://different.auth0.com/')
142
+ end
143
+
144
+ it 'should have the correct domain' do
145
+ expect(jwt_validator.domain).to eq('https://samples.auth0.com/')
146
+ end
119
147
  end
120
148
  end
121
149
 
122
- describe 'JWT verifier decode' do
150
+ describe 'JWT verifier verify' do
123
151
  let(:jwt_validator) do
124
152
  make_jwt_validator
125
153
  end
126
154
 
127
155
  before do
128
- stub_jwks
129
- stub_dummy_jwks
156
+ stub_complete_jwks
157
+ stub_expected_jwks
130
158
  end
131
159
 
132
- it 'should fail with passed expiration' do
133
- payload = {
134
- exp: past_timecode
135
- }
136
- token = make_hs256_token(payload)
160
+ it 'should fail when JWT is nil' do
137
161
  expect do
138
- jwt_validator.decode(token)
139
- end.to raise_error(JWT::ExpiredSignature)
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
169
+ expect do
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
+ }))
140
174
  end
141
175
 
142
176
  it 'should fail with missing issuer' do
143
177
  expect do
144
- jwt_validator.decode(make_hs256_token)
145
- 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
+ }))
146
182
  end
147
183
 
148
184
  it 'should fail with invalid issuer' do
@@ -151,55 +187,287 @@ describe OmniAuth::Auth0::JWTValidator do
151
187
  }
152
188
  token = make_hs256_token(payload)
153
189
  expect do
154
- jwt_validator.decode(token)
155
- 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
+ }))
156
194
  end
157
195
 
158
- it 'should fail with a future not before' do
196
+ it 'should fail when subject is missing' do
159
197
  payload = {
160
- nbf: future_timecode,
161
- iss: "https://#{domain}/"
198
+ iss: "https://#{domain}/",
199
+ sub: ''
162
200
  }
163
201
  token = make_hs256_token(payload)
164
202
  expect do
165
- jwt_validator.decode(token)
166
- 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
+ }))
167
207
  end
168
208
 
169
209
  it 'should fail with missing audience' do
170
210
  payload = {
171
- iss: "https://#{domain}/"
211
+ iss: "https://#{domain}/",
212
+ sub: 'sub'
172
213
  }
173
214
  token = make_hs256_token(payload)
174
215
  expect do
175
- jwt_validator.decode(token)
176
- 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
+ }))
177
220
  end
178
221
 
179
222
  it 'should fail with invalid audience' do
180
223
  payload = {
181
224
  iss: "https://#{domain}/",
225
+ sub: 'sub',
182
226
  aud: 'Auth0'
183
227
  }
184
228
  token = make_hs256_token(payload)
185
229
  expect do
186
- jwt_validator.decode(token)
187
- 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
+ }))
188
234
  end
189
235
 
190
- it 'should decode a valid HS256 token with multiple audiences' do
236
+ it 'should fail when missing expiration' do
191
237
  payload = {
192
238
  iss: "https://#{domain}/",
193
- aud: [
194
- client_id,
195
- "https://#{domain}/userinfo"
196
- ]
239
+ sub: 'sub',
240
+ aud: client_id
197
241
  }
242
+
198
243
  token = make_hs256_token(payload)
199
- expect(jwt_validator.decode(token).length).to eq(2)
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
+ }))
200
249
  end
201
250
 
202
- it 'should decode a standard HS256 token' do
251
+ it 'should fail when past expiration' do
252
+ payload = {
253
+ iss: "https://#{domain}/",
254
+ sub: 'sub',
255
+ aud: client_id,
256
+ exp: past_timecode
257
+ }
258
+
259
+ token = make_hs256_token(payload)
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
+ }))
265
+ end
266
+
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'
203
471
  sub = 'abc123'
204
472
  payload = {
205
473
  sub: sub,
@@ -208,12 +476,16 @@ describe OmniAuth::Auth0::JWTValidator do
208
476
  iat: past_timecode,
209
477
  aud: client_id
210
478
  }
211
- token = make_hs256_token(payload)
212
- decoded_token = jwt_validator.decode(token)
213
- 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
+ }))
214
486
  end
215
487
 
216
- it 'should decode a standard RS256 token' do
488
+ it 'should fail when RS256 token has invalid signature' do
217
489
  domain = 'example.org'
218
490
  sub = 'abc123'
219
491
  payload = {
@@ -221,35 +493,150 @@ describe OmniAuth::Auth0::JWTValidator do
221
493
  exp: future_timecode,
222
494
  iss: "https://#{domain}/",
223
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,
224
560
  aud: client_id,
225
- 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
226
578
  }
227
579
  token = make_rs256_token(payload)
228
- decoded_token = make_jwt_validator(domain).decode(token)
229
- 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')
230
614
  end
231
615
  end
232
616
 
233
617
  private
234
618
 
235
- def make_jwt_validator(opt_domain = domain)
236
- OmniAuth::Auth0::JWTValidator.new(
237
- Options.new(
238
- opt_domain,
239
- client_id,
240
- client_secret
241
- )
619
+ def make_jwt_validator(opt_domain: domain, opt_issuer: nil)
620
+ opts = OpenStruct.new(
621
+ domain: opt_domain,
622
+ client_id: client_id,
623
+ client_secret: client_secret
242
624
  )
625
+ opts[:issuer] = opt_issuer unless opt_issuer.nil?
626
+
627
+ OmniAuth::Auth0::JWTValidator.new(opts)
243
628
  end
244
629
 
245
- def make_hs256_token(payload = nil)
630
+ def make_hs256_token(payload = nil, secret = nil)
246
631
  payload = { sub: 'abc123' } if payload.nil?
247
- JWT.encode payload, client_secret, 'HS256'
632
+ secret = client_secret if secret.nil?
633
+ JWT.encode payload, secret, 'HS256'
248
634
  end
249
635
 
250
- def make_rs256_token(payload = nil)
636
+ def make_rs256_token(payload = nil, kid = nil)
251
637
  payload = { sub: 'abc123' } if payload.nil?
252
- 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
253
640
  end
254
641
 
255
642
  def make_cert(private_key)
@@ -277,7 +664,7 @@ describe OmniAuth::Auth0::JWTValidator do
277
664
  cert.sign private_key, OpenSSL::Digest::SHA1.new
278
665
  end
279
666
 
280
- def stub_jwks
667
+ def stub_complete_jwks
281
668
  stub_request(:get, 'https://samples.auth0.com/.well-known/jwks.json')
282
669
  .to_return(
283
670
  headers: { 'Content-Type' => 'application/json' },
@@ -286,18 +673,11 @@ describe OmniAuth::Auth0::JWTValidator do
286
673
  )
287
674
  end
288
675
 
289
- def stub_bad_jwks
290
- stub_request(:get, 'https://samples.auth0.com/.well-known/jwks-bad.json')
291
- .to_return(
292
- status: 404
293
- )
294
- end
295
-
296
- def stub_dummy_jwks
676
+ def stub_expected_jwks
297
677
  stub_request(:get, 'https://example.org/.well-known/jwks.json')
298
678
  .to_return(
299
679
  headers: { 'Content-Type' => 'application/json' },
300
- body: rsa_token_jwks,
680
+ body: valid_jwks,
301
681
  status: 200
302
682
  )
303
683
  end