jwt_keeper 2.0.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.
- checksums.yaml +7 -0
- data/.editorconfig +14 -0
- data/.gitignore +21 -0
- data/.rspec +3 -0
- data/.rubocop.yml +22 -0
- data/.travis.yml +20 -0
- data/Gemfile +3 -0
- data/LICENSE +23 -0
- data/README.md +83 -0
- data/Rakefile +7 -0
- data/docker-compose.yml +4 -0
- data/example.env +1 -0
- data/jwt_keeper.gemspec +36 -0
- data/lib/generators/keeper/install/install_generator.rb +15 -0
- data/lib/generators/templates/jwt_keeper.rb +32 -0
- data/lib/jwt_keeper/configuration.rb +30 -0
- data/lib/jwt_keeper/controller.rb +66 -0
- data/lib/jwt_keeper/datastore.rb +39 -0
- data/lib/jwt_keeper/engine.rb +12 -0
- data/lib/jwt_keeper/exceptions.rb +19 -0
- data/lib/jwt_keeper/token.rb +137 -0
- data/lib/jwt_keeper/version.rb +4 -0
- data/lib/jwt_keeper.rb +28 -0
- data/spec/lib/keeper/configuration_spec.rb +5 -0
- data/spec/lib/keeper/controller_spec.rb +188 -0
- data/spec/lib/keeper/datastore_spec.rb +70 -0
- data/spec/lib/keeper/token_spec.rb +180 -0
- data/spec/lib/keeper_spec.rb +38 -0
- data/spec/spec_helper.rb +58 -0
- metadata +263 -0
@@ -0,0 +1,188 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
RSpec.describe JWTKeeper do
|
4
|
+
describe 'Controller' do
|
5
|
+
include_context 'initialize config'
|
6
|
+
|
7
|
+
let(:token) { JWTKeeper::Token.create(claim: "Jet fuel can't melt steel beams") }
|
8
|
+
subject(:test_controller) do
|
9
|
+
instance = Class.new do
|
10
|
+
attr_accessor :request, :response
|
11
|
+
include RSpec::Mocks::ExampleMethods
|
12
|
+
include JWTKeeper::Controller
|
13
|
+
|
14
|
+
def session
|
15
|
+
{ return_to_url: 'http://www.example.com' }
|
16
|
+
end
|
17
|
+
|
18
|
+
def root_path
|
19
|
+
'/'
|
20
|
+
end
|
21
|
+
|
22
|
+
def regenerate_claims(_old_token)
|
23
|
+
{ regenerate_claims: true }
|
24
|
+
end
|
25
|
+
|
26
|
+
def redirect_to(path, message = nil)
|
27
|
+
end
|
28
|
+
end.new
|
29
|
+
|
30
|
+
instance.request =
|
31
|
+
instance_double('Request', headers: { 'Authorization' => "Bearer #{token}" })
|
32
|
+
instance.response =
|
33
|
+
instance_double('Response', headers: {})
|
34
|
+
instance
|
35
|
+
end
|
36
|
+
|
37
|
+
describe '#included' do
|
38
|
+
it { is_expected.to respond_to(:require_authentication) }
|
39
|
+
it { is_expected.to respond_to(:authentication_token) }
|
40
|
+
it { is_expected.to respond_to(:authentication_token=) }
|
41
|
+
it { is_expected.to respond_to(:redirect_back_or_to) }
|
42
|
+
it { is_expected.to respond_to(:not_authenticated) }
|
43
|
+
it { is_expected.to respond_to(:authenticated) }
|
44
|
+
end
|
45
|
+
|
46
|
+
describe '#require_authentication' do
|
47
|
+
context 'with valid token' do
|
48
|
+
before do
|
49
|
+
allow(test_controller).to receive(:authenticated)
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'calls authenticated' do
|
53
|
+
subject.require_authentication
|
54
|
+
expect(subject).to have_received(:authenticated).once
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'does not rotates the token' do
|
58
|
+
expect { subject.require_authentication }.to_not change {
|
59
|
+
subject.authentication_token.id
|
60
|
+
}
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
context 'with expired token' do
|
65
|
+
let(:token) { JWTKeeper::Token.create(exp: 3.hours.ago) }
|
66
|
+
before do
|
67
|
+
allow(test_controller).to receive(:not_authenticated)
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'calls not_authenticated' do
|
71
|
+
subject.require_authentication
|
72
|
+
expect(subject).to have_received(:not_authenticated).once
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
context 'with pending token' do
|
77
|
+
let(:token) do
|
78
|
+
token = JWTKeeper::Token.create({})
|
79
|
+
JWTKeeper::Token.rotate(token.id)
|
80
|
+
token
|
81
|
+
end
|
82
|
+
before(:each) do
|
83
|
+
allow(test_controller).to receive(:authenticated)
|
84
|
+
end
|
85
|
+
|
86
|
+
it 'calls authenticated' do
|
87
|
+
subject.require_authentication
|
88
|
+
expect(subject).to have_received(:authenticated).once
|
89
|
+
end
|
90
|
+
|
91
|
+
it 'rotates the token' do
|
92
|
+
expect { subject.require_authentication }.to change {
|
93
|
+
subject.authentication_token.id
|
94
|
+
}
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
context 'with version_mismatch token' do
|
99
|
+
let(:token) { JWTKeeper::Token.create(ver: 'mismatch') }
|
100
|
+
before(:each) do
|
101
|
+
allow(test_controller).to receive(:authenticated)
|
102
|
+
end
|
103
|
+
|
104
|
+
it 'calls authenticated' do
|
105
|
+
subject.require_authentication
|
106
|
+
expect(subject).to have_received(:authenticated).once
|
107
|
+
end
|
108
|
+
|
109
|
+
it 'rotates the token' do
|
110
|
+
expect { subject.require_authentication }.to change {
|
111
|
+
subject.authentication_token.id
|
112
|
+
}
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
describe '#regenerate_claims' do
|
118
|
+
let(:token) do
|
119
|
+
token = JWTKeeper::Token.create({})
|
120
|
+
JWTKeeper::Token.rotate(token.id)
|
121
|
+
token
|
122
|
+
end
|
123
|
+
before(:each) do
|
124
|
+
allow(test_controller).to receive(:authenticated)
|
125
|
+
end
|
126
|
+
|
127
|
+
it 'is used to update the token claims on rotation' do
|
128
|
+
expect(subject.authentication_token.claims[:regenerate_claims]).to be nil
|
129
|
+
expect { subject.require_authentication }.to change(subject, :authentication_token)
|
130
|
+
expect(subject.authentication_token.claims[:regenerate_claims]).to be true
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
describe '#respond_with_authentication' do
|
135
|
+
before do
|
136
|
+
subject.authentication_token = token
|
137
|
+
end
|
138
|
+
|
139
|
+
it 'sets the reponses token with the authentication_token' do
|
140
|
+
subject.respond_with_authentication
|
141
|
+
expect(subject.response.headers['Authorization']).to eq "Bearer #{token}"
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
describe '#authentication_token' do
|
146
|
+
context 'valid request in token' do
|
147
|
+
it 'returns the decoded token from the current request' do
|
148
|
+
expect(subject.authentication_token.claims[:claim]).to eq "Jet fuel can't melt steel beams"
|
149
|
+
end
|
150
|
+
end
|
151
|
+
context 'no token in request' do
|
152
|
+
before do
|
153
|
+
token = JWTKeeper::Token.create(exp: 3.hours.ago)
|
154
|
+
subject.request =
|
155
|
+
instance_double('Request', headers: { 'Authorization' => "Bearer #{token}" })
|
156
|
+
end
|
157
|
+
|
158
|
+
it 'returns nil' do
|
159
|
+
expect(subject.authentication_token).to be nil
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
describe '#redirect_back_or_to' do
|
165
|
+
let(:path) { 'http://www.example.com' }
|
166
|
+
|
167
|
+
before do
|
168
|
+
allow(test_controller).to receive(:redirect_to)
|
169
|
+
end
|
170
|
+
|
171
|
+
it 'it calls redirect_to' do
|
172
|
+
subject.redirect_back_or_to(path)
|
173
|
+
expect(subject).to have_received(:redirect_to).with(path, anything)
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
describe '#not_authenticated' do
|
178
|
+
before do
|
179
|
+
allow(test_controller).to receive(:redirect_to)
|
180
|
+
end
|
181
|
+
|
182
|
+
it 'it calls redirect_to' do
|
183
|
+
subject.not_authenticated
|
184
|
+
expect(subject).to have_received(:redirect_to).with('/')
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
RSpec.describe JWTKeeper::Datastore do
|
2
|
+
include_context 'initialize config'
|
3
|
+
let(:jti) { SecureRandom.uuid }
|
4
|
+
|
5
|
+
describe '.rotate' do
|
6
|
+
before { described_class.rotate(jti, 30) }
|
7
|
+
|
8
|
+
it 'stores a token_id with a soft expiry' do
|
9
|
+
expect(described_class.send(:get, jti)).to eq 'soft'
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
describe '.pending?' do
|
14
|
+
context 'with a missing token' do
|
15
|
+
it 'returns false' do
|
16
|
+
expect(described_class.pending?(jti)).to be false
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
context 'with a revoked token' do
|
21
|
+
before { described_class.revoke(jti, 30) }
|
22
|
+
|
23
|
+
it 'returns false' do
|
24
|
+
expect(described_class.pending?(jti)).to be false
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
context 'with a pending token' do
|
29
|
+
before { described_class.rotate(jti, 30) }
|
30
|
+
|
31
|
+
it 'returns true' do
|
32
|
+
expect(described_class.pending?(jti)).to be true
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
describe '.revoke' do
|
38
|
+
before do
|
39
|
+
described_class.revoke(jti, 30)
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'stores a token_id with a hard expiry' do
|
43
|
+
expect(described_class.send(:get, jti)).to eq 'hard'
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
describe '.revoked?' do
|
48
|
+
context 'with a missing token' do
|
49
|
+
it 'returns false' do
|
50
|
+
expect(described_class.revoked?(jti)).to be false
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
context 'with a revoked token' do
|
55
|
+
before { described_class.revoke(jti, 30) }
|
56
|
+
|
57
|
+
it 'returns true' do
|
58
|
+
expect(described_class.revoked?(jti)).to be true
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
context 'with a pending token' do
|
63
|
+
before { described_class.rotate(jti, 30) }
|
64
|
+
|
65
|
+
it 'returns false' do
|
66
|
+
expect(described_class.revoked?(jti)).to be false
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,180 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
module JWTKeeper
|
4
|
+
RSpec.describe Token do
|
5
|
+
include_context 'initialize config'
|
6
|
+
let(:private_claims) { { claim: "Jet fuel can't melt steel beams" } }
|
7
|
+
let(:raw_token) { described_class.create(private_claims).to_jwt }
|
8
|
+
|
9
|
+
describe '.create' do
|
10
|
+
subject { described_class.create(private_claims) }
|
11
|
+
|
12
|
+
it { is_expected.to be_instance_of described_class }
|
13
|
+
it { expect(subject.claims[:claim]).to eql private_claims[:claim] }
|
14
|
+
end
|
15
|
+
|
16
|
+
describe '.find' do
|
17
|
+
subject { described_class.find(raw_token) }
|
18
|
+
|
19
|
+
it { is_expected.to be_instance_of described_class }
|
20
|
+
it { expect(subject.claims[:claim]).to eql private_claims[:claim] }
|
21
|
+
|
22
|
+
context 'with invalid token' do
|
23
|
+
let(:private_claims) { { exp: 1.hour.ago } }
|
24
|
+
|
25
|
+
it { is_expected.to be nil }
|
26
|
+
end
|
27
|
+
|
28
|
+
context 'with revoked token' do
|
29
|
+
before { described_class.find(raw_token).revoke }
|
30
|
+
|
31
|
+
it { is_expected.to be nil }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe '.rotate' do
|
36
|
+
subject(:token) { described_class.create(private_claims) }
|
37
|
+
before(:each) { described_class.rotate(token.id) }
|
38
|
+
|
39
|
+
it 'marks the token for rotation' do
|
40
|
+
expect(token.pending?).to eq true
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
describe '.revoke' do
|
45
|
+
subject(:token) { described_class.create(private_claims) }
|
46
|
+
|
47
|
+
it 'invalidates the token' do
|
48
|
+
expect(token.valid?).to eq true
|
49
|
+
expect(token.revoked?).to eq false
|
50
|
+
|
51
|
+
expect(described_class.revoke(token.claims[:jti]))
|
52
|
+
|
53
|
+
expect(token.valid?).to eq false
|
54
|
+
expect(token.revoked?).to eq true
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
describe '#revoke' do
|
59
|
+
subject(:token) { described_class.create(private_claims) }
|
60
|
+
|
61
|
+
it 'invalidates the token' do
|
62
|
+
expect(token.valid?).to eq true
|
63
|
+
expect(token.revoked?).to eq false
|
64
|
+
|
65
|
+
expect(token.revoke)
|
66
|
+
|
67
|
+
expect(token.valid?).to eq false
|
68
|
+
expect(token.revoked?).to eq true
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
describe '#revoked?' do
|
73
|
+
subject(:token) { described_class.create(private_claims) }
|
74
|
+
|
75
|
+
context 'with a revoked token' do
|
76
|
+
before { token.revoke }
|
77
|
+
|
78
|
+
it { is_expected.to be_revoked }
|
79
|
+
end
|
80
|
+
|
81
|
+
context 'with a pending token' do
|
82
|
+
before { described_class.rotate(token.id) }
|
83
|
+
|
84
|
+
it { is_expected.not_to be_revoked }
|
85
|
+
end
|
86
|
+
|
87
|
+
context 'with a valid token' do
|
88
|
+
it { is_expected.not_to be_revoked }
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
describe '#pending?' do
|
93
|
+
subject(:token) { described_class.create(private_claims) }
|
94
|
+
|
95
|
+
context 'with a revoked token' do
|
96
|
+
before { token.revoke }
|
97
|
+
|
98
|
+
it { is_expected.not_to be_pending }
|
99
|
+
end
|
100
|
+
|
101
|
+
context 'with a config pending token' do
|
102
|
+
before { token.claims[:ver] = 'version' }
|
103
|
+
|
104
|
+
it { is_expected.to_not be_pending }
|
105
|
+
end
|
106
|
+
|
107
|
+
context 'with a redis pending token' do
|
108
|
+
before { described_class.rotate(token.id) }
|
109
|
+
|
110
|
+
it { is_expected.to be_pending }
|
111
|
+
end
|
112
|
+
|
113
|
+
context 'with a valid token' do
|
114
|
+
it { is_expected.not_to be_pending }
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
describe '#version_mismatch?' do
|
119
|
+
subject(:token) { described_class.create(private_claims) }
|
120
|
+
|
121
|
+
context 'with a revoked token' do
|
122
|
+
before { token.revoke }
|
123
|
+
|
124
|
+
it { is_expected.not_to be_version_mismatch }
|
125
|
+
end
|
126
|
+
|
127
|
+
context 'with a config pending token' do
|
128
|
+
before { token.claims[:ver] = 'version' }
|
129
|
+
|
130
|
+
it { is_expected.to be_version_mismatch }
|
131
|
+
end
|
132
|
+
|
133
|
+
context 'with a redis pending token' do
|
134
|
+
before { described_class.rotate(token.id) }
|
135
|
+
|
136
|
+
it { is_expected.to_not be_version_mismatch }
|
137
|
+
end
|
138
|
+
|
139
|
+
context 'with a valid token' do
|
140
|
+
it { is_expected.not_to be_version_mismatch }
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
describe '#rotate' do
|
145
|
+
let(:old_token) { described_class.create(private_claims) }
|
146
|
+
let(:new_token) { old_token.dup.rotate }
|
147
|
+
before { new_token }
|
148
|
+
|
149
|
+
it { expect(old_token).to be_invalid }
|
150
|
+
it { expect(new_token).to be_valid }
|
151
|
+
it { expect(old_token.claims[:claim]).to eq new_token.claims[:claim] }
|
152
|
+
end
|
153
|
+
|
154
|
+
describe '#valid?' do
|
155
|
+
subject { described_class.create(private_claims) }
|
156
|
+
|
157
|
+
context 'when invalid' do
|
158
|
+
before { JWTKeeper.configure(JWTKeeper::Configuration.new(test_config.merge(expiry: -1.hours))) }
|
159
|
+
it { is_expected.not_to be_valid }
|
160
|
+
end
|
161
|
+
|
162
|
+
context 'when valid' do
|
163
|
+
it { is_expected.to be_valid }
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
describe '#invalid?' do
|
168
|
+
subject { described_class.create(private_claims) }
|
169
|
+
|
170
|
+
context 'when invalid' do
|
171
|
+
before { JWTKeeper.configure(JWTKeeper::Configuration.new(test_config.merge(expiry: -1.hours))) }
|
172
|
+
it { is_expected.to be_invalid }
|
173
|
+
end
|
174
|
+
|
175
|
+
context 'when valid' do
|
176
|
+
it { is_expected.not_to be_invalid }
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
RSpec.describe JWTKeeper do
|
4
|
+
describe '#configure' do
|
5
|
+
let(:test_config) do
|
6
|
+
{
|
7
|
+
algorithm: 'HS256',
|
8
|
+
secret: 'secret',
|
9
|
+
expiry: 24.hours,
|
10
|
+
issuer: 'api.example.com',
|
11
|
+
audience: 'example.com',
|
12
|
+
redis_connection: Redis.new(url: ENV['REDIS_URL'])
|
13
|
+
}
|
14
|
+
end
|
15
|
+
|
16
|
+
context 'without block' do
|
17
|
+
before do
|
18
|
+
described_class.configure(JWTKeeper::Configuration.new(test_config))
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'sets the configuration based on param' do
|
22
|
+
expect(described_class.configuration.secret).to eql test_config[:secret]
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
context 'with block' do
|
27
|
+
before do
|
28
|
+
described_class.configure do |config|
|
29
|
+
config.secret = test_config[:secret]
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'sets configuration based on the block' do
|
34
|
+
expect(described_class.configuration.secret).to eql test_config[:secret]
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'dotenv'
|
2
|
+
Dotenv.load
|
3
|
+
|
4
|
+
require 'simplecov'
|
5
|
+
require 'codeclimate-test-reporter'
|
6
|
+
|
7
|
+
SimpleCov.formatter =
|
8
|
+
SimpleCov::Formatter::MultiFormatter.new([
|
9
|
+
SimpleCov::Formatter::HTMLFormatter,
|
10
|
+
CodeClimate::TestReporter::Formatter
|
11
|
+
])
|
12
|
+
SimpleCov.start
|
13
|
+
|
14
|
+
require 'rails'
|
15
|
+
require 'jwt_keeper'
|
16
|
+
|
17
|
+
RSpec.configure do |config|
|
18
|
+
config.expect_with :rspec do |expectations|
|
19
|
+
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
20
|
+
end
|
21
|
+
|
22
|
+
config.mock_with :rspec do |mocks|
|
23
|
+
mocks.verify_partial_doubles = true
|
24
|
+
end
|
25
|
+
|
26
|
+
config.filter_run :focus
|
27
|
+
config.run_all_when_everything_filtered = true
|
28
|
+
config.example_status_persistence_file_path = 'spec/examples.txt'
|
29
|
+
config.disable_monkey_patching!
|
30
|
+
|
31
|
+
config.default_formatter = 'doc' if config.files_to_run.one?
|
32
|
+
|
33
|
+
# config.profile_examples = 10
|
34
|
+
|
35
|
+
# Run specs in random order to surface order dependencies. If you find an
|
36
|
+
# order dependency and want to debug it, you can fix the order by providing
|
37
|
+
# the seed, which is printed after each run.
|
38
|
+
# --seed 1234
|
39
|
+
config.order = :random
|
40
|
+
Kernel.srand config.seed
|
41
|
+
end
|
42
|
+
|
43
|
+
RSpec.shared_context 'initialize config' do
|
44
|
+
let(:test_config) do
|
45
|
+
{
|
46
|
+
algorithm: 'HS256',
|
47
|
+
secret: 'secret',
|
48
|
+
expiry: 24.hours,
|
49
|
+
issuer: 'api.example.com',
|
50
|
+
audience: 'example.com',
|
51
|
+
redis_connection: Redis.new(url: ENV['REDIS_URL'])
|
52
|
+
}
|
53
|
+
end
|
54
|
+
|
55
|
+
before(:each) do
|
56
|
+
JWTKeeper.configure(JWTKeeper::Configuration.new(test_config))
|
57
|
+
end
|
58
|
+
end
|