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.
- checksums.yaml +4 -4
- data/.circleci/config.yml +1 -28
- data/.github/CODEOWNERS +1 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +1 -1
- data/.github/stale.yml +20 -0
- data/.snyk +9 -0
- data/CHANGELOG.md +43 -0
- data/Gemfile.lock +86 -81
- data/README.md +30 -14
- data/lib/omniauth-auth0/version.rb +1 -1
- data/lib/omniauth/auth0/errors.rb +11 -0
- data/lib/omniauth/auth0/jwt_validator.rb +140 -23
- data/lib/omniauth/strategies/auth0.rb +30 -6
- data/omniauth-auth0.gemspec +1 -3
- data/spec/omniauth/auth0/jwt_validator_spec.rb +412 -61
- data/spec/omniauth/strategies/auth0_spec.rb +45 -1
- metadata +24 -6
@@ -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
|
-
|
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
|
-
#
|
30
|
-
# @
|
31
|
-
|
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
|
-
|
40
|
-
elsif head[:alg]
|
41
|
-
|
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
|
-
|
45
|
-
JWT.decode(jwt,
|
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
|
-
#
|
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
|
-
|
87
|
-
|
88
|
-
verify_iss:
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
verify_not_before:
|
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
|
-
|
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
|
-
#
|
52
|
-
|
53
|
-
|
54
|
-
|
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
|
-
|
82
|
-
|
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.
|
data/omniauth-auth0.gemspec
CHANGED
@@ -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(:
|
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(:
|
21
|
+
let(:valid_jwks) do
|
22
22
|
{
|
23
23
|
keys: [
|
24
24
|
{
|
25
|
-
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
|
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
|
-
|
100
|
+
stub_complete_jwks
|
101
101
|
end
|
102
102
|
|
103
103
|
it 'should return a key' do
|
104
|
-
expect(jwt_validator.jwks_key(:alg,
|
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,
|
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,
|
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, "#{
|
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
|
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
|
-
|
157
|
-
|
156
|
+
stub_complete_jwks
|
157
|
+
stub_expected_jwks
|
158
158
|
end
|
159
159
|
|
160
|
-
it 'should fail
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
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.
|
167
|
-
end.to raise_error(
|
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.
|
173
|
-
end.to raise_error(
|
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.
|
183
|
-
end.to raise_error(
|
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
|
196
|
+
it 'should fail when subject is missing' do
|
187
197
|
payload = {
|
188
|
-
|
189
|
-
|
198
|
+
iss: "https://#{domain}/",
|
199
|
+
sub: ''
|
190
200
|
}
|
191
201
|
token = make_hs256_token(payload)
|
192
202
|
expect do
|
193
|
-
jwt_validator.
|
194
|
-
end.to raise_error(
|
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.
|
204
|
-
end.to raise_error(
|
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.
|
215
|
-
end.to raise_error(
|
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
|
251
|
+
it 'should fail when past expiration' do
|
219
252
|
payload = {
|
220
253
|
iss: "https://#{domain}/",
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
]
|
254
|
+
sub: 'sub',
|
255
|
+
aud: client_id,
|
256
|
+
exp: past_timecode
|
225
257
|
}
|
258
|
+
|
226
259
|
token = make_hs256_token(payload)
|
227
|
-
expect
|
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
|
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
|
-
|
240
|
-
|
241
|
-
expect
|
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
|
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
|
-
|
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
|
-
|
257
|
-
expect(
|
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
|
-
|
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
|
-
|
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
|
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
|
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:
|
680
|
+
body: valid_jwks,
|
330
681
|
status: 200
|
331
682
|
)
|
332
683
|
end
|