omniauth-auth0 2.1.0 → 2.4.1

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,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