omniauth-auth0 2.0.0 → 2.4.0
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.
Potentially problematic release.
This version of omniauth-auth0 might be problematic. Click here for more details.
- checksums.yaml +5 -5
- data/.circleci/config.yml +22 -0
- data/.github/CODEOWNERS +1 -0
- data/.github/ISSUE_TEMPLATE.md +39 -0
- data/.github/PULL_REQUEST_TEMPLATE.md +32 -0
- data/.github/stale.yml +20 -0
- data/.gitignore +5 -2
- data/.snyk +9 -0
- data/CHANGELOG.md +91 -1
- data/CODE_OF_CONDUCT.md +3 -0
- data/CONTRIBUTING.md +71 -0
- data/Gemfile +4 -4
- data/Gemfile.lock +167 -0
- data/README.md +114 -85
- data/Rakefile +2 -2
- data/codecov.yml +22 -0
- data/lib/omniauth-auth0.rb +1 -1
- data/lib/omniauth-auth0/version.rb +1 -1
- data/lib/omniauth/auth0/errors.rb +11 -0
- data/lib/omniauth/auth0/jwt_validator.rb +228 -0
- data/lib/omniauth/auth0/telemetry.rb +36 -0
- data/lib/omniauth/strategies/auth0.rb +77 -19
- data/omniauth-auth0.gemspec +3 -5
- data/spec/omniauth/auth0/jwt_validator_spec.rb +501 -0
- data/spec/omniauth/auth0/telemetry_spec.rb +28 -0
- data/spec/omniauth/strategies/auth0_spec.rb +73 -2
- data/spec/resources/jwks.json +28 -0
- data/spec/spec_helper.rb +8 -6
- metadata +29 -12
- data/.travis.yml +0 -6
@@ -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,19 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'base64'
|
2
4
|
require 'uri'
|
5
|
+
require 'securerandom'
|
3
6
|
require 'omniauth-oauth2'
|
7
|
+
require 'omniauth/auth0/jwt_validator'
|
8
|
+
require 'omniauth/auth0/telemetry'
|
9
|
+
require 'omniauth/auth0/errors'
|
4
10
|
|
5
11
|
module OmniAuth
|
6
12
|
module Strategies
|
7
13
|
# Auth0 OmniAuth strategy
|
8
14
|
class Auth0 < OmniAuth::Strategies::OAuth2
|
15
|
+
include OmniAuth::Auth0::Telemetry
|
16
|
+
|
9
17
|
option :name, 'auth0'
|
10
18
|
|
11
|
-
args [
|
12
|
-
|
13
|
-
|
14
|
-
|
19
|
+
args %i[
|
20
|
+
client_id
|
21
|
+
client_secret
|
22
|
+
domain
|
15
23
|
]
|
16
24
|
|
25
|
+
# Setup client URLs used during authentication
|
17
26
|
def client
|
18
27
|
options.client_options.site = domain_url
|
19
28
|
options.client_options.authorize_url = '/authorize'
|
@@ -22,25 +31,48 @@ module OmniAuth
|
|
22
31
|
super
|
23
32
|
end
|
24
33
|
|
34
|
+
# Use the "sub" key of the userinfo returned
|
35
|
+
# as the uid (globally unique string identifier).
|
25
36
|
uid { raw_info['sub'] }
|
26
37
|
|
38
|
+
# Build the API credentials hash with returned auth data.
|
27
39
|
credentials do
|
28
|
-
|
29
|
-
|
40
|
+
credentials = {
|
41
|
+
'token' => access_token.token,
|
42
|
+
'expires' => true
|
43
|
+
}
|
44
|
+
|
30
45
|
if access_token.params
|
31
|
-
|
32
|
-
|
33
|
-
|
46
|
+
credentials.merge!(
|
47
|
+
'id_token' => access_token.params['id_token'],
|
48
|
+
'token_type' => access_token.params['token_type'],
|
49
|
+
'refresh_token' => access_token.refresh_token
|
50
|
+
)
|
51
|
+
end
|
52
|
+
|
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)
|
34
62
|
end
|
35
|
-
|
63
|
+
|
64
|
+
credentials
|
36
65
|
end
|
37
66
|
|
67
|
+
# Store all raw information for use in the session.
|
38
68
|
extra do
|
39
69
|
{
|
40
70
|
raw_info: raw_info
|
41
71
|
}
|
42
72
|
end
|
43
73
|
|
74
|
+
# Build a hash of information about the user
|
75
|
+
# with keys taken from the Auth Hash Schema.
|
44
76
|
info do
|
45
77
|
{
|
46
78
|
name: raw_info['name'] || raw_info['sub'],
|
@@ -50,56 +82,82 @@ module OmniAuth
|
|
50
82
|
}
|
51
83
|
end
|
52
84
|
|
85
|
+
# Define the parameters used for the /authorize endpoint
|
53
86
|
def authorize_params
|
54
87
|
params = super
|
55
|
-
|
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
|
+
|
56
101
|
params
|
57
102
|
end
|
58
103
|
|
104
|
+
def build_access_token
|
105
|
+
options.token_params[:headers] = { 'Auth0-Client' => telemetry_encoded }
|
106
|
+
super
|
107
|
+
end
|
108
|
+
|
109
|
+
# Declarative override for the request phase of authentication
|
59
110
|
def request_phase
|
60
111
|
if no_client_id?
|
112
|
+
# Do we have a client_id for this Application?
|
61
113
|
fail!(:missing_client_id)
|
62
114
|
elsif no_client_secret?
|
115
|
+
# Do we have a client_secret for this Application?
|
63
116
|
fail!(:missing_client_secret)
|
64
117
|
elsif no_domain?
|
118
|
+
# Do we have a domain for this Application?
|
65
119
|
fail!(:missing_domain)
|
66
120
|
else
|
121
|
+
# All checks pass, run the Oauth2 request_phase method.
|
67
122
|
super
|
68
123
|
end
|
69
124
|
end
|
70
125
|
|
126
|
+
def callback_phase
|
127
|
+
super
|
128
|
+
rescue OmniAuth::Auth0::TokenValidationError => e
|
129
|
+
fail!(:token_validation_error, e)
|
130
|
+
end
|
131
|
+
|
71
132
|
private
|
72
133
|
|
134
|
+
# Parse the raw user info.
|
73
135
|
def raw_info
|
74
136
|
userinfo_url = options.client_options.userinfo_url
|
75
137
|
@raw_info ||= access_token.get(userinfo_url).parsed
|
76
138
|
end
|
77
139
|
|
140
|
+
# Check if the options include a client_id
|
78
141
|
def no_client_id?
|
79
142
|
['', nil].include?(options.client_id)
|
80
143
|
end
|
81
144
|
|
145
|
+
# Check if the options include a client_secret
|
82
146
|
def no_client_secret?
|
83
147
|
['', nil].include?(options.client_secret)
|
84
148
|
end
|
85
149
|
|
150
|
+
# Check if the options include a domain
|
86
151
|
def no_domain?
|
87
152
|
['', nil].include?(options.domain)
|
88
153
|
end
|
89
154
|
|
155
|
+
# Normalize a domain to a URL.
|
90
156
|
def domain_url
|
91
157
|
domain_url = URI(options.domain)
|
92
158
|
domain_url = URI("https://#{domain_url}") if domain_url.scheme.nil?
|
93
159
|
domain_url.to_s
|
94
160
|
end
|
95
|
-
|
96
|
-
def client_info
|
97
|
-
client_info = JSON.dump(
|
98
|
-
name: 'omniauth-auth0',
|
99
|
-
version: OmniAuth::Auth0::VERSION
|
100
|
-
)
|
101
|
-
Base64.urlsafe_encode64(client_info)
|
102
|
-
end
|
103
161
|
end
|
104
162
|
end
|
105
163
|
end
|
data/omniauth-auth0.gemspec
CHANGED
@@ -8,22 +8,20 @@ Gem::Specification.new do |s|
|
|
8
8
|
s.authors = ['Auth0']
|
9
9
|
s.email = ['info@auth0.com']
|
10
10
|
s.homepage = 'https://github.com/auth0/omniauth-auth0'
|
11
|
-
s.summary = '
|
11
|
+
s.summary = 'OmniAuth OAuth2 strategy for the Auth0 platform.'
|
12
12
|
s.description = %q{Auth0 is an authentication broker that supports social identity providers as well as enterprise identity providers such as Active Directory, LDAP, Google Apps, Salesforce.
|
13
13
|
|
14
14
|
OmniAuth is a library that standardizes multi-provider authentication for web applications. It was created to be powerful, flexible, and do as little as possible.
|
15
15
|
|
16
|
-
omniauth-auth0 is the
|
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
|
-
s.add_runtime_dependency 'omniauth-oauth2', '~> 1.
|
24
|
+
s.add_runtime_dependency 'omniauth-oauth2', '~> 1.5'
|
27
25
|
|
28
26
|
s.add_development_dependency 'bundler', '~> 1.9'
|
29
27
|
|
@@ -0,0 +1,501 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'json'
|
3
|
+
require 'jwt'
|
4
|
+
|
5
|
+
describe OmniAuth::Auth0::JWTValidator do
|
6
|
+
#
|
7
|
+
# Reused data
|
8
|
+
#
|
9
|
+
|
10
|
+
let(:client_id) { 'CLIENT_ID' }
|
11
|
+
let(:client_secret) { 'CLIENT_SECRET' }
|
12
|
+
let(:domain) { 'samples.auth0.com' }
|
13
|
+
let(:future_timecode) { 32_503_680_000 }
|
14
|
+
let(:past_timecode) { 303_912_000 }
|
15
|
+
let(:jwks_kid) { 'NkJCQzIyQzRBMEU4NjhGNUU4MzU4RkY0M0ZDQzkwOUQ0Q0VGNUMwQg' }
|
16
|
+
|
17
|
+
let(:rsa_private_key) do
|
18
|
+
OpenSSL::PKey::RSA.generate 2048
|
19
|
+
end
|
20
|
+
|
21
|
+
let(:rsa_token_jwks) do
|
22
|
+
{
|
23
|
+
keys: [
|
24
|
+
{
|
25
|
+
kid: jwks_kid,
|
26
|
+
x5c: [Base64.encode64(make_cert(rsa_private_key).to_der)]
|
27
|
+
}
|
28
|
+
]
|
29
|
+
}.to_json
|
30
|
+
end
|
31
|
+
|
32
|
+
let(:jwks) do
|
33
|
+
current_dir = File.dirname(__FILE__)
|
34
|
+
jwks_file = File.read("#{current_dir}/../../resources/jwks.json")
|
35
|
+
JSON.parse(jwks_file, symbolize_names: true)
|
36
|
+
end
|
37
|
+
|
38
|
+
#
|
39
|
+
# Specs
|
40
|
+
#
|
41
|
+
|
42
|
+
describe 'JWT verifier default values' do
|
43
|
+
let(:jwt_validator) do
|
44
|
+
make_jwt_validator
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'should have the correct issuer' do
|
48
|
+
expect(jwt_validator.issuer).to eq('https://samples.auth0.com/')
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe 'JWT verifier token_head' do
|
53
|
+
let(:jwt_validator) do
|
54
|
+
make_jwt_validator
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'should parse the head of a valid JWT' do
|
58
|
+
expect(jwt_validator.token_head(make_hs256_token)[:alg]).to eq('HS256')
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'should fail parsing the head of a blank JWT' do
|
62
|
+
expect(jwt_validator.token_head('')).to eq({})
|
63
|
+
end
|
64
|
+
|
65
|
+
it 'should fail parsing the head of an invalid JWT' do
|
66
|
+
expect(jwt_validator.token_head('.')).to eq({})
|
67
|
+
end
|
68
|
+
|
69
|
+
it 'should throw an exception for invalid JSON' do
|
70
|
+
expect do
|
71
|
+
jwt_validator.token_head('QXV0aDA=')
|
72
|
+
end.to raise_error(JSON::ParserError)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
describe 'JWT verifier jwks_public_cert' do
|
77
|
+
let(:jwt_validator) do
|
78
|
+
make_jwt_validator
|
79
|
+
end
|
80
|
+
|
81
|
+
it 'should return a public_key' do
|
82
|
+
x5c = jwks[:keys].first[:x5c].first
|
83
|
+
public_cert = jwt_validator.jwks_public_cert(x5c)
|
84
|
+
expect(public_cert.instance_of?(OpenSSL::PKey::RSA)).to eq(true)
|
85
|
+
end
|
86
|
+
|
87
|
+
it 'should fail with an invalid x5c' do
|
88
|
+
expect do
|
89
|
+
jwt_validator.jwks_public_cert('QXV0aDA=')
|
90
|
+
end.to raise_error(OpenSSL::X509::CertificateError)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
describe 'JWT verifier jwks_key' do
|
95
|
+
let(:jwt_validator) do
|
96
|
+
make_jwt_validator
|
97
|
+
end
|
98
|
+
|
99
|
+
before do
|
100
|
+
stub_jwks
|
101
|
+
end
|
102
|
+
|
103
|
+
it 'should return a key' do
|
104
|
+
expect(jwt_validator.jwks_key(:alg, jwks_kid)).to eq('RS256')
|
105
|
+
end
|
106
|
+
|
107
|
+
it 'should return an x5c key' do
|
108
|
+
expect(jwt_validator.jwks_key(:x5c, jwks_kid).length).to eq(1)
|
109
|
+
end
|
110
|
+
|
111
|
+
it 'should return nil if there is not key' do
|
112
|
+
expect(jwt_validator.jwks_key(:auth0, jwks_kid)).to eq(nil)
|
113
|
+
end
|
114
|
+
|
115
|
+
it 'should return nil if the key ID is invalid' do
|
116
|
+
expect(jwt_validator.jwks_key(:alg, "#{jwks_kid}_invalid")).to eq(nil)
|
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
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
describe 'JWT verifier verify' do
|
151
|
+
let(:jwt_validator) do
|
152
|
+
make_jwt_validator
|
153
|
+
end
|
154
|
+
|
155
|
+
before do
|
156
|
+
stub_jwks
|
157
|
+
stub_dummy_jwks
|
158
|
+
end
|
159
|
+
|
160
|
+
it 'should fail with missing issuer' do
|
161
|
+
expect do
|
162
|
+
jwt_validator.verify(make_hs256_token)
|
163
|
+
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
|
164
|
+
message: "Issuer (iss) claim must be a string present in the ID token"
|
165
|
+
}))
|
166
|
+
end
|
167
|
+
|
168
|
+
it 'should fail with invalid issuer' do
|
169
|
+
payload = {
|
170
|
+
iss: 'https://auth0.com/'
|
171
|
+
}
|
172
|
+
token = make_hs256_token(payload)
|
173
|
+
expect do
|
174
|
+
jwt_validator.verify(token)
|
175
|
+
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
|
176
|
+
message: "Issuer (iss) claim mismatch in the ID token, expected (https://samples.auth0.com/), found (https://auth0.com/)"
|
177
|
+
}))
|
178
|
+
end
|
179
|
+
|
180
|
+
it 'should fail when subject is missing' do
|
181
|
+
payload = {
|
182
|
+
iss: "https://#{domain}/",
|
183
|
+
sub: ''
|
184
|
+
}
|
185
|
+
token = make_hs256_token(payload)
|
186
|
+
expect do
|
187
|
+
jwt_validator.verify(token)
|
188
|
+
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
|
189
|
+
message: "Subject (sub) claim must be a string present in the ID token"
|
190
|
+
}))
|
191
|
+
end
|
192
|
+
|
193
|
+
it 'should fail with missing audience' do
|
194
|
+
payload = {
|
195
|
+
iss: "https://#{domain}/",
|
196
|
+
sub: 'sub'
|
197
|
+
}
|
198
|
+
token = make_hs256_token(payload)
|
199
|
+
expect do
|
200
|
+
jwt_validator.verify(token)
|
201
|
+
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
|
202
|
+
message: "Audience (aud) claim must be a string or array of strings present in the ID token"
|
203
|
+
}))
|
204
|
+
end
|
205
|
+
|
206
|
+
it 'should fail with invalid audience' do
|
207
|
+
payload = {
|
208
|
+
iss: "https://#{domain}/",
|
209
|
+
sub: 'sub',
|
210
|
+
aud: 'Auth0'
|
211
|
+
}
|
212
|
+
token = make_hs256_token(payload)
|
213
|
+
expect do
|
214
|
+
jwt_validator.verify(token)
|
215
|
+
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
|
216
|
+
message: "Audience (aud) claim mismatch in the ID token; expected #{client_id} but found Auth0"
|
217
|
+
}))
|
218
|
+
end
|
219
|
+
|
220
|
+
it 'should fail when missing expiration' do
|
221
|
+
payload = {
|
222
|
+
iss: "https://#{domain}/",
|
223
|
+
sub: 'sub',
|
224
|
+
aud: client_id
|
225
|
+
}
|
226
|
+
|
227
|
+
token = make_hs256_token(payload)
|
228
|
+
expect do
|
229
|
+
jwt_validator.verify(token)
|
230
|
+
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
|
231
|
+
message: "Expiration time (exp) claim must be a number present in the ID token"
|
232
|
+
}))
|
233
|
+
end
|
234
|
+
|
235
|
+
it 'should fail when past expiration' do
|
236
|
+
payload = {
|
237
|
+
iss: "https://#{domain}/",
|
238
|
+
sub: 'sub',
|
239
|
+
aud: client_id,
|
240
|
+
exp: past_timecode
|
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 error in the ID token; current time (#{Time.now}) is after expiration time (#{Time.at(past_timecode + 60)})"
|
248
|
+
}))
|
249
|
+
end
|
250
|
+
|
251
|
+
it 'should fail when missing iat' do
|
252
|
+
payload = {
|
253
|
+
iss: "https://#{domain}/",
|
254
|
+
sub: 'sub',
|
255
|
+
aud: client_id,
|
256
|
+
exp: future_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: "Issued At (iat) claim must be a number present in the ID token"
|
264
|
+
}))
|
265
|
+
end
|
266
|
+
|
267
|
+
it 'should fail when authorize params has nonce but nonce is missing in the token' do
|
268
|
+
payload = {
|
269
|
+
iss: "https://#{domain}/",
|
270
|
+
sub: 'sub',
|
271
|
+
aud: client_id,
|
272
|
+
exp: future_timecode,
|
273
|
+
iat: past_timecode
|
274
|
+
}
|
275
|
+
|
276
|
+
token = make_hs256_token(payload)
|
277
|
+
expect do
|
278
|
+
jwt_validator.verify(token, { nonce: 'noncey' })
|
279
|
+
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
|
280
|
+
message: "Nonce (nonce) claim must be a string present in the ID token"
|
281
|
+
}))
|
282
|
+
end
|
283
|
+
|
284
|
+
it 'should fail when authorize params has nonce but token nonce does not match' do
|
285
|
+
payload = {
|
286
|
+
iss: "https://#{domain}/",
|
287
|
+
sub: 'sub',
|
288
|
+
aud: client_id,
|
289
|
+
exp: future_timecode,
|
290
|
+
iat: past_timecode,
|
291
|
+
nonce: 'mismatch'
|
292
|
+
}
|
293
|
+
|
294
|
+
token = make_hs256_token(payload)
|
295
|
+
expect do
|
296
|
+
jwt_validator.verify(token, { nonce: 'noncey' })
|
297
|
+
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
|
298
|
+
message: "Nonce (nonce) claim value mismatch in the ID token; expected (noncey), found (mismatch)"
|
299
|
+
}))
|
300
|
+
end
|
301
|
+
|
302
|
+
it 'should fail when “aud” is an array of strings and azp claim is not present' do
|
303
|
+
aud = [
|
304
|
+
client_id,
|
305
|
+
"https://#{domain}/userinfo"
|
306
|
+
]
|
307
|
+
payload = {
|
308
|
+
iss: "https://#{domain}/",
|
309
|
+
sub: 'sub',
|
310
|
+
aud: aud,
|
311
|
+
exp: future_timecode,
|
312
|
+
iat: past_timecode
|
313
|
+
}
|
314
|
+
|
315
|
+
token = make_hs256_token(payload)
|
316
|
+
expect do
|
317
|
+
jwt_validator.verify(token)
|
318
|
+
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
|
319
|
+
message: "Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values"
|
320
|
+
}))
|
321
|
+
end
|
322
|
+
|
323
|
+
it 'should fail when "azp" claim doesnt match the expected aud' do
|
324
|
+
aud = [
|
325
|
+
client_id,
|
326
|
+
"https://#{domain}/userinfo"
|
327
|
+
]
|
328
|
+
payload = {
|
329
|
+
iss: "https://#{domain}/",
|
330
|
+
sub: 'sub',
|
331
|
+
aud: aud,
|
332
|
+
exp: future_timecode,
|
333
|
+
iat: past_timecode,
|
334
|
+
azp: 'not_expected'
|
335
|
+
}
|
336
|
+
|
337
|
+
token = make_hs256_token(payload)
|
338
|
+
expect do
|
339
|
+
jwt_validator.verify(token)
|
340
|
+
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
|
341
|
+
message: "Authorized Party (azp) claim mismatch in the ID token; expected (#{client_id}), found (not_expected)"
|
342
|
+
}))
|
343
|
+
end
|
344
|
+
|
345
|
+
it 'should fail when “max_age” sent on the authentication request and this claim is not present' do
|
346
|
+
payload = {
|
347
|
+
iss: "https://#{domain}/",
|
348
|
+
sub: 'sub',
|
349
|
+
aud: client_id,
|
350
|
+
exp: future_timecode,
|
351
|
+
iat: past_timecode
|
352
|
+
}
|
353
|
+
|
354
|
+
token = make_hs256_token(payload)
|
355
|
+
expect do
|
356
|
+
jwt_validator.verify(token, { max_age: 60 })
|
357
|
+
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
|
358
|
+
message: "Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified"
|
359
|
+
}))
|
360
|
+
end
|
361
|
+
|
362
|
+
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
|
363
|
+
payload = {
|
364
|
+
iss: "https://#{domain}/",
|
365
|
+
sub: 'sub',
|
366
|
+
aud: client_id,
|
367
|
+
exp: future_timecode,
|
368
|
+
iat: past_timecode,
|
369
|
+
auth_time: past_timecode
|
370
|
+
}
|
371
|
+
|
372
|
+
token = make_hs256_token(payload)
|
373
|
+
expect do
|
374
|
+
jwt_validator.verify(token, { max_age: 60 })
|
375
|
+
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
|
376
|
+
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)})"
|
377
|
+
}))
|
378
|
+
end
|
379
|
+
|
380
|
+
it 'should verify a valid HS256 token with multiple audiences' do
|
381
|
+
audience = [
|
382
|
+
client_id,
|
383
|
+
"https://#{domain}/userinfo"
|
384
|
+
]
|
385
|
+
payload = {
|
386
|
+
iss: "https://#{domain}/",
|
387
|
+
sub: 'sub',
|
388
|
+
aud: audience,
|
389
|
+
exp: future_timecode,
|
390
|
+
iat: past_timecode,
|
391
|
+
azp: client_id
|
392
|
+
}
|
393
|
+
token = make_hs256_token(payload)
|
394
|
+
id_token = jwt_validator.verify(token)
|
395
|
+
expect(id_token['aud']).to eq(audience)
|
396
|
+
end
|
397
|
+
|
398
|
+
it 'should verify a standard HS256 token' do
|
399
|
+
sub = 'abc123'
|
400
|
+
payload = {
|
401
|
+
iss: "https://#{domain}/",
|
402
|
+
sub: sub,
|
403
|
+
aud: client_id,
|
404
|
+
exp: future_timecode,
|
405
|
+
iat: past_timecode
|
406
|
+
}
|
407
|
+
token = make_hs256_token(payload)
|
408
|
+
verified_token = jwt_validator.verify(token)
|
409
|
+
expect(verified_token['sub']).to eq(sub)
|
410
|
+
end
|
411
|
+
|
412
|
+
it 'should verify a standard RS256 token' do
|
413
|
+
domain = 'example.org'
|
414
|
+
sub = 'abc123'
|
415
|
+
payload = {
|
416
|
+
sub: sub,
|
417
|
+
exp: future_timecode,
|
418
|
+
iss: "https://#{domain}/",
|
419
|
+
iat: past_timecode,
|
420
|
+
aud: client_id,
|
421
|
+
kid: jwks_kid
|
422
|
+
}
|
423
|
+
token = make_rs256_token(payload)
|
424
|
+
verified_token = make_jwt_validator(opt_domain: domain).verify(token)
|
425
|
+
expect(verified_token['sub']).to eq(sub)
|
426
|
+
end
|
427
|
+
end
|
428
|
+
|
429
|
+
private
|
430
|
+
|
431
|
+
def make_jwt_validator(opt_domain: domain, opt_issuer: nil)
|
432
|
+
opts = OpenStruct.new(
|
433
|
+
domain: opt_domain,
|
434
|
+
client_id: client_id,
|
435
|
+
client_secret: client_secret
|
436
|
+
)
|
437
|
+
opts[:issuer] = opt_issuer unless opt_issuer.nil?
|
438
|
+
|
439
|
+
OmniAuth::Auth0::JWTValidator.new(opts)
|
440
|
+
end
|
441
|
+
|
442
|
+
def make_hs256_token(payload = nil)
|
443
|
+
payload = { sub: 'abc123' } if payload.nil?
|
444
|
+
JWT.encode payload, client_secret, 'HS256'
|
445
|
+
end
|
446
|
+
|
447
|
+
def make_rs256_token(payload = nil)
|
448
|
+
payload = { sub: 'abc123' } if payload.nil?
|
449
|
+
JWT.encode payload, rsa_private_key, 'RS256', kid: jwks_kid
|
450
|
+
end
|
451
|
+
|
452
|
+
def make_cert(private_key)
|
453
|
+
cert = OpenSSL::X509::Certificate.new
|
454
|
+
cert.issuer = OpenSSL::X509::Name.parse('/C=BE/O=Auth0/OU=Auth0/CN=Auth0')
|
455
|
+
cert.subject = cert.issuer
|
456
|
+
cert.not_before = Time.now
|
457
|
+
cert.not_after = Time.now + 365 * 24 * 60 * 60
|
458
|
+
cert.public_key = private_key.public_key
|
459
|
+
cert.serial = 0x0
|
460
|
+
cert.version = 2
|
461
|
+
|
462
|
+
ef = OpenSSL::X509::ExtensionFactory.new
|
463
|
+
ef.subject_certificate = cert
|
464
|
+
ef.issuer_certificate = cert
|
465
|
+
cert.extensions = [
|
466
|
+
ef.create_extension('basicConstraints', 'CA:TRUE', true),
|
467
|
+
ef.create_extension('subjectKeyIdentifier', 'hash')
|
468
|
+
]
|
469
|
+
cert.add_extension ef.create_extension(
|
470
|
+
'authorityKeyIdentifier',
|
471
|
+
'keyid:always,issuer:always'
|
472
|
+
)
|
473
|
+
|
474
|
+
cert.sign private_key, OpenSSL::Digest::SHA1.new
|
475
|
+
end
|
476
|
+
|
477
|
+
def stub_jwks
|
478
|
+
stub_request(:get, 'https://samples.auth0.com/.well-known/jwks.json')
|
479
|
+
.to_return(
|
480
|
+
headers: { 'Content-Type' => 'application/json' },
|
481
|
+
body: jwks.to_json,
|
482
|
+
status: 200
|
483
|
+
)
|
484
|
+
end
|
485
|
+
|
486
|
+
def stub_bad_jwks
|
487
|
+
stub_request(:get, 'https://samples.auth0.com/.well-known/jwks-bad.json')
|
488
|
+
.to_return(
|
489
|
+
status: 404
|
490
|
+
)
|
491
|
+
end
|
492
|
+
|
493
|
+
def stub_dummy_jwks
|
494
|
+
stub_request(:get, 'https://example.org/.well-known/jwks.json')
|
495
|
+
.to_return(
|
496
|
+
headers: { 'Content-Type' => 'application/json' },
|
497
|
+
body: rsa_token_jwks,
|
498
|
+
status: 200
|
499
|
+
)
|
500
|
+
end
|
501
|
+
end
|