jwt_keeper 3.3.0 → 6.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +4 -1
- data/lib/generators/templates/jwt_keeper.rb +5 -0
- data/lib/jwt_keeper.rb +0 -2
- data/lib/jwt_keeper/configuration.rb +2 -2
- data/lib/jwt_keeper/controller.rb +4 -11
- data/lib/jwt_keeper/datastore.rb +18 -2
- data/lib/jwt_keeper/exceptions.rb +3 -0
- data/lib/jwt_keeper/token.rb +32 -18
- data/lib/jwt_keeper/version.rb +1 -1
- data/spec/lib/jwt_keeper/controller_spec.rb +3 -18
- data/spec/lib/jwt_keeper/token_spec.rb +66 -3
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 135ae5f5451cdcd997de5a5208642833ef5d043038b020fc96b67415c243b8b7
|
4
|
+
data.tar.gz: f940045097cb8cbd2d281bd1b887598f7c2f0f7bf25e598b834978e61a9337f8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: dfa8fe29790a114e9f180822f32015fb5881cc268b249b757bec44df0ed419b203e33c27f439ed019cbdeecdbc6aa34512930c4210b5c000a77adb5da6dee07c
|
7
|
+
data.tar.gz: 881df471eaa098d00614578f551d2e67414cdee8260c45e7ad90a2cdca71fd2af52b3c88ebd8d9fa61bedaf9863dceaee7281a6f5f92f56b238111bf4b382527
|
data/README.md
CHANGED
@@ -32,12 +32,15 @@ raw_token_string = token.to_jwt
|
|
32
32
|
The designed rails token flow is to receive and respond to requests with the token being present in the `Authorization` part of the header. This is to allow us to seamlessly rotate the tokens on the fly without having to rebuff the request as part of the user flow. Automatic rotation happens as part of the `require_authentication` action, meaning that you will always get the latest token data as created by `generate_claims` in your controllers. This new token is added to the response with the `write_authentication_token` action.
|
33
33
|
|
34
34
|
```bash
|
35
|
-
|
35
|
+
rails generate jwt_keeper:install
|
36
36
|
```
|
37
37
|
|
38
38
|
```ruby
|
39
39
|
class ApplicationController < ActionController::Base
|
40
|
+
include JWTKeeper::Controller
|
41
|
+
|
40
42
|
before_action :require_authentication
|
43
|
+
rescue_from JWTKeeper::NotAuthenticatedError, with: :not_authenticated
|
41
44
|
|
42
45
|
def not_authenticated
|
43
46
|
# Overload to return status 401
|
@@ -1,3 +1,5 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
JWTKeeper.configure do |config|
|
2
4
|
# The time to expire for the tokens
|
3
5
|
# config.expiry = 1.hour
|
@@ -26,6 +28,9 @@ JWTKeeper.configure do |config|
|
|
26
28
|
|
27
29
|
# the location of redis config file
|
28
30
|
# config.redis_connection = Redis.new(connection_options)
|
31
|
+
# config.redis_connection = ConnectionPool.new(size: ENV.fetch('RAILS_MAX_THREADS', 5)) do
|
32
|
+
# Redis.new(url: ENV['REDISCLOUD_URL'] || 'redis://localhost:6379/')
|
33
|
+
# end
|
29
34
|
|
30
35
|
# A unique idenfitier for the token version.
|
31
36
|
# config.version = 1
|
data/lib/jwt_keeper.rb
CHANGED
@@ -10,7 +10,7 @@ module JWTKeeper
|
|
10
10
|
|
11
11
|
if token.nil?
|
12
12
|
clear_authentication_token
|
13
|
-
|
13
|
+
raise JWTKeeper::NotAuthenticatedError
|
14
14
|
end
|
15
15
|
|
16
16
|
if token.version_mismatch? || token.pending?
|
@@ -29,7 +29,7 @@ module JWTKeeper
|
|
29
29
|
@authentication_token ||=
|
30
30
|
JWTKeeper::Token.find(
|
31
31
|
request.headers['Authorization'].split.last,
|
32
|
-
cookies.signed['jwt_keeper']
|
32
|
+
cookie_secret: defined?(cookies) && cookies.signed['jwt_keeper']
|
33
33
|
)
|
34
34
|
end
|
35
35
|
|
@@ -39,7 +39,7 @@ module JWTKeeper
|
|
39
39
|
def write_authentication_token(token)
|
40
40
|
return clear_authentication_token if token.nil?
|
41
41
|
response.headers['Authorization'] = "Bearer #{token.to_jwt}"
|
42
|
-
cookies.signed['jwt_keeper'] = token.to_cookie
|
42
|
+
defined?(cookies) && cookies.signed['jwt_keeper'] = token.to_cookie
|
43
43
|
@authentication_token = token
|
44
44
|
end
|
45
45
|
|
@@ -47,17 +47,10 @@ module JWTKeeper
|
|
47
47
|
# @return [void]
|
48
48
|
def clear_authentication_token
|
49
49
|
response.headers['Authorization'] = nil
|
50
|
-
cookies.delete('jwt_keeper')
|
50
|
+
defined?(cookies) && cookies.delete('jwt_keeper')
|
51
51
|
@authentication_token = nil
|
52
52
|
end
|
53
53
|
|
54
|
-
# The default action for denying non-authenticated connections.
|
55
|
-
# You can override this method in your controllers
|
56
|
-
# @return [void]
|
57
|
-
def not_authenticated
|
58
|
-
redirect_to root_path
|
59
|
-
end
|
60
|
-
|
61
54
|
# The default action for accepting authenticated connections.
|
62
55
|
# You can override this method in your controllers
|
63
56
|
# @return [void]
|
data/lib/jwt_keeper/datastore.rb
CHANGED
@@ -27,12 +27,28 @@ module JWTKeeper
|
|
27
27
|
|
28
28
|
# @!visibility private
|
29
29
|
def set_with_expiry(jti, seconds, type)
|
30
|
-
JWTKeeper.configuration.redis_connection
|
30
|
+
redis = JWTKeeper.configuration.redis_connection
|
31
|
+
|
32
|
+
if redis.is_a?(Redis)
|
33
|
+
redis.setex(jti, seconds, type)
|
34
|
+
elsif defined?(ConnectionPool) && redis.is_a?(ConnectionPool)
|
35
|
+
redis.with { |conn| conn.setex(jti, seconds, type) }
|
36
|
+
else
|
37
|
+
throw 'Bad Redis Connection'
|
38
|
+
end
|
31
39
|
end
|
32
40
|
|
33
41
|
# @!visibility private
|
34
42
|
def get(jti)
|
35
|
-
JWTKeeper.configuration.redis_connection
|
43
|
+
redis = JWTKeeper.configuration.redis_connection
|
44
|
+
|
45
|
+
if redis.is_a?(Redis)
|
46
|
+
redis.get(jti)
|
47
|
+
elsif defined?(ConnectionPool) && redis.is_a?(ConnectionPool)
|
48
|
+
redis.with { |conn| conn.get(jti) }
|
49
|
+
else
|
50
|
+
throw 'Bad Redis Connection'
|
51
|
+
end
|
36
52
|
end
|
37
53
|
end
|
38
54
|
end
|
data/lib/jwt_keeper/token.rb
CHANGED
@@ -2,41 +2,47 @@ module JWTKeeper
|
|
2
2
|
# This class acts as the main interface to wrap the concerns of JWTs. Handling everything from
|
3
3
|
# encoding to invalidation.
|
4
4
|
class Token
|
5
|
-
attr_accessor :claims, :cookie_secret
|
5
|
+
attr_accessor :claims, :secret, :cookie_secret
|
6
6
|
|
7
7
|
# Initalizes a new web token
|
8
|
-
# @param
|
9
|
-
# @param
|
8
|
+
# @param options [Hash] the custom claims to encode
|
9
|
+
# @param secret the secret to use during encoding, defaults to config
|
10
|
+
# @param cookie_secret the cookie secret to use during encoding
|
10
11
|
# @return [void]
|
11
|
-
def initialize(
|
12
|
-
@
|
12
|
+
def initialize(options = {})
|
13
|
+
@secret = options.delete(:secret) || JWTKeeper.configuration.secret
|
14
|
+
@cookie_secret = options.delete(:cookie_secret)
|
13
15
|
@claims = {
|
14
16
|
nbf: DateTime.now.to_i, # not before
|
15
17
|
iat: DateTime.now.to_i, # issued at
|
16
18
|
jti: SecureRandom.uuid # JWT ID
|
17
19
|
}
|
20
|
+
|
18
21
|
@claims.merge!(JWTKeeper.configuration.base_claims)
|
19
|
-
@claims.merge!(
|
22
|
+
@claims.merge!(options)
|
20
23
|
@claims[:exp] = @claims[:exp].to_i if @claims[:exp].is_a?(Time)
|
21
24
|
end
|
22
25
|
|
23
26
|
# Creates a new web token
|
24
|
-
# @param
|
27
|
+
# @param options [Hash] the custom claims to encode
|
28
|
+
# @param secret the secret to use during encoding, defaults to config
|
25
29
|
# @return [Token] token object
|
26
|
-
def self.create(
|
30
|
+
def self.create(options)
|
27
31
|
cookie_secret = SecureRandom.hex(16) if JWTKeeper.configuration.cookie_lock
|
28
|
-
new(
|
32
|
+
new(options.merge(cookie_secret: cookie_secret))
|
29
33
|
end
|
30
34
|
|
31
35
|
# Decodes and validates an existing token
|
32
36
|
# @param raw_token [String] the raw token
|
33
37
|
# @param cookie_secret [String] the cookie secret
|
34
38
|
# @return [Token] token object
|
35
|
-
def self.find(raw_token, cookie_secret
|
36
|
-
claims = decode(raw_token, cookie_secret)
|
39
|
+
def self.find(raw_token, secret: nil, cookie_secret: nil, iss: nil)
|
40
|
+
claims = decode(raw_token, secret: secret, cookie_secret: cookie_secret, iss: iss)
|
37
41
|
return nil if claims.nil?
|
38
42
|
|
39
|
-
new_token = new(
|
43
|
+
new_token = new(secret: secret, cookie_secret: cookie_secret, iss: iss)
|
44
|
+
new_token.claims = claims
|
45
|
+
|
40
46
|
return nil if new_token.revoked?
|
41
47
|
new_token
|
42
48
|
end
|
@@ -67,6 +73,7 @@ module JWTKeeper
|
|
67
73
|
# @param new_claims [Hash] Used to override and update claims during rotation
|
68
74
|
# @return [Token]
|
69
75
|
def rotate(new_claims = nil)
|
76
|
+
return self if claims[:iss] != JWTKeeper.configuration.issuer
|
70
77
|
revoke
|
71
78
|
|
72
79
|
new_claims ||= claims.except(:iss, :aud, :exp, :nbf, :iat, :jti)
|
@@ -111,7 +118,11 @@ module JWTKeeper
|
|
111
118
|
# Checks if the token invalid?
|
112
119
|
# @return [Boolean]
|
113
120
|
def invalid?
|
114
|
-
self.class.decode(
|
121
|
+
self.class.decode(
|
122
|
+
encode,
|
123
|
+
secret: secret,
|
124
|
+
cookie_secret: cookie_secret
|
125
|
+
).nil? || revoked?
|
115
126
|
end
|
116
127
|
|
117
128
|
# Encodes the jwt
|
@@ -131,8 +142,11 @@ module JWTKeeper
|
|
131
142
|
end
|
132
143
|
|
133
144
|
# @!visibility private
|
134
|
-
def self.decode(raw_token, cookie_secret)
|
135
|
-
|
145
|
+
def self.decode(raw_token, secret: nil, cookie_secret: nil, iss: nil)
|
146
|
+
secret ||= JWTKeeper.configuration.secret
|
147
|
+
iss ||= JWTKeeper.configuration.issuer
|
148
|
+
|
149
|
+
JWT.decode(raw_token, secret.to_s + cookie_secret.to_s, true,
|
136
150
|
algorithm: JWTKeeper.configuration.algorithm,
|
137
151
|
verify_iss: true,
|
138
152
|
verify_aud: true,
|
@@ -140,7 +154,7 @@ module JWTKeeper
|
|
140
154
|
verify_sub: false,
|
141
155
|
verify_jti: false,
|
142
156
|
leeway: 0,
|
143
|
-
iss:
|
157
|
+
iss: iss,
|
144
158
|
aud: JWTKeeper.configuration.audience
|
145
159
|
).first.symbolize_keys
|
146
160
|
|
@@ -152,8 +166,8 @@ module JWTKeeper
|
|
152
166
|
|
153
167
|
# @!visibility private
|
154
168
|
def encode
|
155
|
-
JWT.encode(claims,
|
156
|
-
|
169
|
+
JWT.encode(claims.compact,
|
170
|
+
secret.to_s + cookie_secret.to_s,
|
157
171
|
JWTKeeper.configuration.algorithm
|
158
172
|
)
|
159
173
|
end
|
data/lib/jwt_keeper/version.rb
CHANGED
@@ -5,6 +5,7 @@ RSpec.describe JWTKeeper do
|
|
5
5
|
include_context 'initialize config'
|
6
6
|
|
7
7
|
let(:token) { JWTKeeper::Token.create(claim: "The Earth is Flat") }
|
8
|
+
|
8
9
|
subject(:test_controller) do
|
9
10
|
cookies_klass = Class.new(Hash) do
|
10
11
|
def signed
|
@@ -51,7 +52,6 @@ RSpec.describe JWTKeeper do
|
|
51
52
|
it { is_expected.to respond_to(:read_authentication_token) }
|
52
53
|
it { is_expected.to respond_to(:write_authentication_token) }
|
53
54
|
it { is_expected.to respond_to(:clear_authentication_token) }
|
54
|
-
it { is_expected.to respond_to(:not_authenticated) }
|
55
55
|
it { is_expected.to respond_to(:authenticated) }
|
56
56
|
it { is_expected.to respond_to(:regenerate_claims) }
|
57
57
|
end
|
@@ -76,13 +76,9 @@ RSpec.describe JWTKeeper do
|
|
76
76
|
|
77
77
|
context 'with expired token' do
|
78
78
|
let(:token) { JWTKeeper::Token.create(exp: 3.hours.ago) }
|
79
|
-
before do
|
80
|
-
allow(test_controller).to receive(:not_authenticated)
|
81
|
-
end
|
82
79
|
|
83
|
-
it '
|
84
|
-
subject.require_authentication
|
85
|
-
expect(subject).to have_received(:not_authenticated).once
|
80
|
+
it 'raises NotAuthenticated' do
|
81
|
+
expect { subject.require_authentication }.to raise_error(JWTKeeper::NotAuthenticatedError)
|
86
82
|
end
|
87
83
|
end
|
88
84
|
|
@@ -161,16 +157,5 @@ RSpec.describe JWTKeeper do
|
|
161
157
|
expect(subject.response.headers['Authorization']).to be_nil
|
162
158
|
end
|
163
159
|
end
|
164
|
-
|
165
|
-
describe '#not_authenticated' do
|
166
|
-
before do
|
167
|
-
allow(test_controller).to receive(:redirect_to)
|
168
|
-
end
|
169
|
-
|
170
|
-
it 'it calls redirect_to' do
|
171
|
-
subject.not_authenticated
|
172
|
-
expect(subject).to have_received(:redirect_to).with('/')
|
173
|
-
end
|
174
|
-
end
|
175
160
|
end
|
176
161
|
end
|
@@ -24,6 +24,25 @@ module JWTKeeper
|
|
24
24
|
it { is_expected.to be_instance_of described_class }
|
25
25
|
it { expect(subject.claims[:exp]).to eql private_claims[:exp] }
|
26
26
|
end
|
27
|
+
|
28
|
+
context 'when overriding default secret' do
|
29
|
+
subject { described_class.create(**private_claims, secret: secret) }
|
30
|
+
|
31
|
+
let(:secret) { SecureRandom.uuid }
|
32
|
+
|
33
|
+
it { is_expected.to be_instance_of described_class }
|
34
|
+
it { expect(subject.claims[:claim]).to eql private_claims[:claim] }
|
35
|
+
end
|
36
|
+
|
37
|
+
context 'when overriding default issuer' do
|
38
|
+
subject { described_class.create(**private_claims, iss: issuer) }
|
39
|
+
|
40
|
+
let(:issuer) { 'ISSUER' }
|
41
|
+
|
42
|
+
it { is_expected.to be_instance_of described_class }
|
43
|
+
it { expect(subject.claims[:claim]).to eql private_claims[:claim] }
|
44
|
+
it { expect(subject.claims[:iss]).to eql issuer }
|
45
|
+
end
|
27
46
|
end
|
28
47
|
|
29
48
|
describe '.find' do
|
@@ -46,20 +65,47 @@ module JWTKeeper
|
|
46
65
|
before { JWTKeeper.configure(JWTKeeper::Configuration.new(config.merge(cookie_lock: true))) }
|
47
66
|
|
48
67
|
context 'with no cookie' do
|
49
|
-
subject { described_class.find(raw_token, nil) }
|
68
|
+
subject { described_class.find(raw_token, cookie_secret: nil) }
|
50
69
|
it { is_expected.to be nil }
|
51
70
|
end
|
52
71
|
|
53
72
|
context 'with bad cookie' do
|
54
|
-
subject { described_class.find(raw_token, 'BAD_COOKIE') }
|
73
|
+
subject { described_class.find(raw_token, cookie_secret: 'BAD_COOKIE') }
|
55
74
|
it { is_expected.to be nil }
|
56
75
|
end
|
57
76
|
|
58
77
|
context 'with valid cookie' do
|
59
|
-
subject { described_class.find(raw_token, token.cookie_secret) }
|
78
|
+
subject { described_class.find(raw_token, cookie_secret: token.cookie_secret) }
|
60
79
|
it { is_expected.to be_instance_of described_class }
|
61
80
|
end
|
62
81
|
end
|
82
|
+
|
83
|
+
context 'when overriding default secret' do
|
84
|
+
subject { described_class.find(raw_token, secret: secret) }
|
85
|
+
|
86
|
+
let(:token) { described_class.create(**private_claims, secret: secret) }
|
87
|
+
let(:secret) { SecureRandom.uuid }
|
88
|
+
|
89
|
+
it { is_expected.to be_instance_of described_class }
|
90
|
+
it { expect(subject.claims[:claim]).to eql private_claims[:claim] }
|
91
|
+
end
|
92
|
+
|
93
|
+
context 'when overriding default issuer' do
|
94
|
+
subject { described_class.find(raw_token, iss: issuer) }
|
95
|
+
|
96
|
+
let(:token) { described_class.create(**private_claims, iss: issuer) }
|
97
|
+
let(:issuer) { 'ISSUER' }
|
98
|
+
|
99
|
+
it { is_expected.to be_instance_of described_class }
|
100
|
+
it { expect(subject.claims[:claim]).to eql private_claims[:claim] }
|
101
|
+
it { expect(subject.claims[:iss]).to eql issuer }
|
102
|
+
|
103
|
+
context 'with an issuer mismatch' do
|
104
|
+
subject { described_class.find(raw_token) }
|
105
|
+
|
106
|
+
it { is_expected.to be nil }
|
107
|
+
end
|
108
|
+
end
|
63
109
|
end
|
64
110
|
|
65
111
|
describe '.rotate' do
|
@@ -181,6 +227,13 @@ module JWTKeeper
|
|
181
227
|
it { expect(new_token).to be_valid }
|
182
228
|
it { expect(old_token.claims[:claim]).to eq new_token.claims[:claim] }
|
183
229
|
it { expect(old_token.cookie_secret).not_to eq new_token.cookie_secret }
|
230
|
+
|
231
|
+
context 'with a foreign issued token' do
|
232
|
+
let(:old_token) { described_class.create(**private_claims, iss: 'ISSUER') }
|
233
|
+
let(:new_token) { old_token.rotate }
|
234
|
+
|
235
|
+
it { expect(old_token).to eq new_token }
|
236
|
+
end
|
184
237
|
end
|
185
238
|
|
186
239
|
describe '#valid?' do
|
@@ -194,6 +247,16 @@ module JWTKeeper
|
|
194
247
|
context 'when valid' do
|
195
248
|
it { is_expected.to be_valid }
|
196
249
|
end
|
250
|
+
|
251
|
+
context 'with overriden secret' do
|
252
|
+
subject { described_class.create(**private_claims, secret: secret) }
|
253
|
+
|
254
|
+
let(:secret) { SecureRandom.uuid }
|
255
|
+
|
256
|
+
context 'when valid' do
|
257
|
+
it { is_expected.to be_valid }
|
258
|
+
end
|
259
|
+
end
|
197
260
|
end
|
198
261
|
|
199
262
|
describe '#invalid?' do
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jwt_keeper
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 6.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- David Rivera
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date:
|
12
|
+
date: 2021-03-05 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: bundler
|
@@ -249,7 +249,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
249
249
|
- !ruby/object:Gem::Version
|
250
250
|
version: '0'
|
251
251
|
requirements: []
|
252
|
-
rubygems_version: 3.
|
252
|
+
rubygems_version: 3.2.3
|
253
253
|
signing_key:
|
254
254
|
specification_version: 4
|
255
255
|
summary: JWT for Rails made easy
|