jwt_sessions 2.3.1 → 2.4.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 +82 -28
- data/lib/jwt_sessions.rb +8 -0
- data/lib/jwt_sessions/access_token.rb +2 -2
- data/lib/jwt_sessions/refresh_token.rb +12 -4
- data/lib/jwt_sessions/session.rb +17 -7
- data/lib/jwt_sessions/version.rb +1 -1
- data/test/units/jwt_sessions/test_session.rb +24 -0
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8fe66c91df4011e6e59d05bda8a9d43188b8f07a
|
4
|
+
data.tar.gz: 97bb42e7a8f6ecb5037b3bc6b057758dfcf2397d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a353fc811d0cd645655467088651f33b22e9dde3778a8fe2ef027bb07f5274a98acb0f9243a8791251d203e14445f453e2ee61af6829b078e4150f199a0332cc
|
7
|
+
data.tar.gz: baef0f6687c8ec9171e67a74fda5d5f4907a600e7d6ce829a5d01e39cfb62af6777dce235bcfcc989313c6d9b3273abbd914da8d0a074b9f0ceff517398f87e1
|
data/README.md
CHANGED
@@ -11,6 +11,7 @@ XSS/CSRF safe JWT auth designed for SPA
|
|
11
11
|
- [Synopsis](#synopsis)
|
12
12
|
- [Installation](#installation)
|
13
13
|
- [Getting Started](#getting-started)
|
14
|
+
* [Creating a session](#creating-a-session)
|
14
15
|
* [Rails integration](#rails-integration)
|
15
16
|
* [Non-Rails usage](#non-rails-usage)
|
16
17
|
- [Configuration](#configuration)
|
@@ -32,7 +33,9 @@ XSS/CSRF safe JWT auth designed for SPA
|
|
32
33
|
|
33
34
|
Main goal of this gem is to provide configurable, manageable, and safe stateful sessions based on JSON Web Tokens.
|
34
35
|
|
35
|
-
|
36
|
+
The gem stores JWT based sessions on the backend (currently, redis and memory stores are supported), making it possible to manage sessions, reset passwords, logout users in a reliable and secure way.
|
37
|
+
|
38
|
+
It's designed to be framework agnostic yet is easily integrable, and Rails integration is available out of the box.
|
36
39
|
|
37
40
|
Core concept behind `jwt_sessions` is that each session is represented by a pair of tokens: access and refresh, and a session store is used to handle CSRF checks and refresh token hijacking. Both tokens have configurable expiration times, but in general refresh token is supposed to have a longer lifespan than an access token. Access token is used to retrieve secured resources and refresh token is used to renew the access token once it's expired. Default token store is based on redis.
|
38
41
|
|
@@ -56,7 +59,80 @@ bundle install
|
|
56
59
|
|
57
60
|
## Getting Started
|
58
61
|
|
59
|
-
|
62
|
+
You should configure an encryption algorithm and specify the encryption key. By default the gem uses `HS256`.
|
63
|
+
|
64
|
+
```ruby
|
65
|
+
JWTSessions.encryption_key = "secret"
|
66
|
+
```
|
67
|
+
|
68
|
+
`Authorization` mixin provides helper methods which are used to retrieve access and refresh tokens from incoming requests and verify CSRF token if needed. It assumes that a token can be found either in a cookie or in a header (cookie and header names are configurable). It tries to retrieve it from headers first, then from cookies (CSRF check included) if the headers check failed.
|
69
|
+
|
70
|
+
### Creating a session
|
71
|
+
|
72
|
+
Each token contains a payload with custom session info. The payload is a regular Ruby hash. \
|
73
|
+
Usually, it contains user ID or other data which helps to identify current user but it's not necessary, the payload can be an empty hash as well.
|
74
|
+
|
75
|
+
```ruby
|
76
|
+
> payload = { user_id: user.id }
|
77
|
+
=> {:user_id=>1}
|
78
|
+
```
|
79
|
+
|
80
|
+
Generate the session with a custom payload. By default the same payload is sewn into the session's access and refresh tokens.
|
81
|
+
|
82
|
+
```ruby
|
83
|
+
> session = JWTSessions::Session.new(payload: payload)
|
84
|
+
=> #<JWTSessions::Session:0x00007fbe2cce9ea0...>
|
85
|
+
```
|
86
|
+
|
87
|
+
Sometimes it makes sense to keep different data within the payloads of access and refresh tokens. \
|
88
|
+
The access token may contain rich data including user settings, etc., while the appropriate refresh token will include only the bare minimum which will be required to reconstruct a payload for the new access token during refresh.
|
89
|
+
|
90
|
+
```ruby
|
91
|
+
session = JWTSessions::Session.new(payload: payload, refresh_payload: refresh_payload)
|
92
|
+
```
|
93
|
+
|
94
|
+
Now we can call `login` method on the session to retrieve a set of tokens.
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
> session.login
|
98
|
+
=> {:csrf=>"BmhxDRW5NAEIx...",
|
99
|
+
:access=>"eyJhbGciOiJIUzI1NiJ9...",
|
100
|
+
:access_expires_at=>"..."
|
101
|
+
:refresh=>"eyJhbGciOiJIUzI1NiJ9...",
|
102
|
+
:refresh_expires_at=>"..."}
|
103
|
+
```
|
104
|
+
|
105
|
+
Access/refresh tokens automatically contain expiration time in their payload. Yet expiration times are also added to the output just in case. \
|
106
|
+
The token's payload will be available in the controllers once the access (or refresh) token is authorized.
|
107
|
+
|
108
|
+
To perform the refresh do:
|
109
|
+
|
110
|
+
```ruby
|
111
|
+
> session.refresh(refresh_token)
|
112
|
+
=> {:csrf=>"+pk2SQrXHRo1iV1x4O...",
|
113
|
+
:access=>"eyJhbGciOiJIUzI1...",
|
114
|
+
:access_expires_at=>"..."}
|
115
|
+
```
|
116
|
+
|
117
|
+
Available `JWTSessions::Session.new` options:
|
118
|
+
|
119
|
+
- **payload**: a hash object with session data which will be included into an access token payload. Default is an empty hash.
|
120
|
+
- **refresh_payload**: a hash object with session data which will be included into a refresh token payload. Default is a value of the access payload.
|
121
|
+
- **access_claims**: a hash object with [JWT claims](https://github.com/jwt/ruby-jwt#support-for-reserved-claim-names) which will be validated within the access token payload. F.e. `{ aud: ["admin"], verify_aud: true }` meaning that the token can be used only by "admin" audience. Also, the endpoint can automatically validate claims instead. See `token_claims` method.
|
122
|
+
- **refresh_claims**: a hash object with [JWT claims](https://github.com/jwt/ruby-jwt#support-for-reserved-claim-names) which will be validated within the refresh token payload.
|
123
|
+
- **namespace**: a string object which helps to group sessions by a custom criteria. For example, sessions can be grouped by user ID, then it'll be possible to logout the user from all devises. More info [Sessions Namespace](#sessions-namespace).
|
124
|
+
- **refresh_by_access_allowed**: a boolean value. Default is false. It links access and refresh tokens (adds refresh token ID to access payload), making it possible to perform a session refresh by the last expired access token. See [Refresh with access token](#refresh-with-access-token).
|
125
|
+
- **access_exp**: an integer value. Contains an access token expiration time in seconds. The value overrides global settings. See [Expiration time](#expiration-time).
|
126
|
+
- **refresh_exp**: an integer value. Contains a refresh token expiration time in seconds. The value overrides global settings. See [Expiration time](#expiration-time).
|
127
|
+
|
128
|
+
Helper methods within `Authorization` mixin:
|
129
|
+
|
130
|
+
- **authorize_access_request!**: validates access token within the request.
|
131
|
+
- **authorize_refresh_request!**: validates refresh token within the request.
|
132
|
+
- **found_token**: a raw token found within the request.
|
133
|
+
- **payload**: a decoded token's payload.
|
134
|
+
- **claimless_payload**: a decoded token's payload without claims validation (can be used for checking data of an expired token).
|
135
|
+
- **token_claims**: the method should be defined by a developer, and is expected to return a hash-like object with claims to be validated within a token's payload.
|
60
136
|
|
61
137
|
### Rails integration
|
62
138
|
|
@@ -91,25 +167,6 @@ JWTSessions.private_key = OpenSSL::PKey::RSA.generate(2048)
|
|
91
167
|
JWTSessions.public_key = JWTSessions.private_key.public_key
|
92
168
|
```
|
93
169
|
|
94
|
-
Generate access/refresh/csrf tokens with a custom payload. \
|
95
|
-
The payload will be available in the controllers once the access (or refresh) token is authorized. \
|
96
|
-
Access/refresh tokens contain expiration time in their payload. Yet expiration times are also added to the output just in case.
|
97
|
-
|
98
|
-
```ruby
|
99
|
-
> payload = { user_id: user.id }
|
100
|
-
=> {:user_id=>1}
|
101
|
-
|
102
|
-
> session = JWTSessions::Session.new(payload: payload)
|
103
|
-
=> #<JWTSessions::Session:0x00007fbe2cce9ea0...>
|
104
|
-
|
105
|
-
> session.login
|
106
|
-
=> {:csrf=>"BmhxDRW5NAEIx...",
|
107
|
-
:access=>"eyJhbGciOiJIUzI1NiJ9...",
|
108
|
-
:access_expires_at=>"..."
|
109
|
-
:refresh=>"eyJhbGciOiJIUzI1NiJ9...",
|
110
|
-
:refresh_expires_at=>"..."}
|
111
|
-
```
|
112
|
-
|
113
170
|
You can build login controller to receive access, refresh and csrf tokens in exchange for user's login/password. \
|
114
171
|
Refresh controller - to be able to get a new access token using refresh token after access is expired. \
|
115
172
|
Here is example of a simple login controller, which returns set of tokens as a plain JSON response. \
|
@@ -130,12 +187,6 @@ class LoginController < ApplicationController
|
|
130
187
|
end
|
131
188
|
```
|
132
189
|
|
133
|
-
Since it's not required to pass an access token when you want to perform a refresh you may need to have some data in the payload of the refresh token to allow you to construct a payload of the new access token during refresh.
|
134
|
-
|
135
|
-
```ruby
|
136
|
-
session = JWTSessions::Session.new(payload: payload, refresh_payload: refresh_payload)
|
137
|
-
```
|
138
|
-
|
139
190
|
Now you can build a refresh endpoint. To protect the endpoint use before_action `authorize_refresh_request!`. \
|
140
191
|
The endpoint itself should return a renewed access token.
|
141
192
|
|
@@ -245,7 +296,7 @@ class SimpleApp < Sinatra::Base
|
|
245
296
|
include JWTSessions::Authorization
|
246
297
|
|
247
298
|
def request_headers
|
248
|
-
env.inject({}){|acc, (k,v)| acc[$1.downcase] = v if k =~ /^http_(.*)/i; acc}
|
299
|
+
env.inject({}) { |acc, (k,v)| acc[$1.downcase] = v if k =~ /^http_(.*)/i; acc }
|
249
300
|
end
|
250
301
|
|
251
302
|
def request_cookies
|
@@ -355,6 +406,7 @@ class UsersController < ApplicationController
|
|
355
406
|
def token_claims
|
356
407
|
{
|
357
408
|
aud: ["admin", "staff"],
|
409
|
+
verify_aud: true, # can be used locally instead of a global setting
|
358
410
|
exp_leeway: 15 # will be used instead of default leeway only for exp claim
|
359
411
|
}
|
360
412
|
end
|
@@ -384,6 +436,8 @@ JWTSessions.access_exp_time = 3600 # 1 hour in seconds
|
|
384
436
|
JWTSessions.refresh_exp_time = 604800 # 1 week in seconds
|
385
437
|
```
|
386
438
|
|
439
|
+
It's defined globally, but can be overridden on a session level. See `JWTSessions::Session.new` options for more info.
|
440
|
+
|
387
441
|
#### CSRF and cookies
|
388
442
|
|
389
443
|
In case when you use cookies as your tokens transport it gets vulnerable to CSRF. That's why both login and refresh methods of the `Session` class produce CSRF tokens for you. `Authorization` mixin expects that this token is sent with all requests except GET and HEAD in a header specified among this gem's settings (X-CSRF-Token by default). Verification will be done automatically and `Authorization` exception will be raised in case of mismatch between the token from the header and the one stored in session. \
|
data/lib/jwt_sessions.rb
CHANGED
@@ -136,6 +136,14 @@ module JWTSessions
|
|
136
136
|
Time.now.to_i + refresh_exp_time.to_i
|
137
137
|
end
|
138
138
|
|
139
|
+
def custom_access_expiration(time)
|
140
|
+
Time.now.to_i + (time || access_exp_time).to_i
|
141
|
+
end
|
142
|
+
|
143
|
+
def custom_refresh_expiration(time)
|
144
|
+
Time.now.to_i + (time || refresh_exp_time).to_i
|
145
|
+
end
|
146
|
+
|
139
147
|
def header_by(token_type)
|
140
148
|
send("#{token_type}_header")
|
141
149
|
end
|
@@ -29,8 +29,8 @@ module JWTSessions
|
|
29
29
|
end
|
30
30
|
|
31
31
|
class << self
|
32
|
-
def create(csrf, payload, store)
|
33
|
-
new(csrf, payload, store).tap do |inst|
|
32
|
+
def create(csrf, payload, store, expiration = JWTSessions.access_expiration)
|
33
|
+
new(csrf, payload, store, SecureRandom.uuid, expiration).tap do |inst|
|
34
34
|
store.persist_access(inst.uid, inst.csrf, inst.expiration)
|
35
35
|
end
|
36
36
|
end
|
@@ -13,15 +13,23 @@ module JWTSessions
|
|
13
13
|
@access_uid = access_uid
|
14
14
|
@access_expiration = access_expiration
|
15
15
|
@store = store
|
16
|
-
@uid = options.fetch(:uid, SecureRandom.uuid
|
17
|
-
@expiration = options.fetch(:expiration, JWTSessions.refresh_expiration
|
16
|
+
@uid = options.fetch(:uid, nil) || SecureRandom.uuid
|
17
|
+
@expiration = options.fetch(:expiration, nil) || JWTSessions.refresh_expiration
|
18
18
|
@namespace = options.fetch(:namespace, nil)
|
19
19
|
@token = Token.encode(options.fetch(:payload, {}).merge("uid" => uid, "exp" => expiration.to_i))
|
20
20
|
end
|
21
21
|
|
22
22
|
class << self
|
23
|
-
def create(csrf, access_uid, access_expiration, store, payload, namespace)
|
24
|
-
inst = new(
|
23
|
+
def create(csrf, access_uid, access_expiration, store, payload, namespace, expiration = JWTSessions.refresh_expiration)
|
24
|
+
inst = new(
|
25
|
+
csrf,
|
26
|
+
access_uid,
|
27
|
+
access_expiration,
|
28
|
+
store,
|
29
|
+
payload: payload,
|
30
|
+
namespace: namespace,
|
31
|
+
expiration: expiration
|
32
|
+
)
|
25
33
|
inst.send(:persist_in_store)
|
26
34
|
inst
|
27
35
|
end
|
data/lib/jwt_sessions/session.rb
CHANGED
@@ -20,6 +20,8 @@ module JWTSessions
|
|
20
20
|
@refresh_claims = options.fetch(:refresh_claims, {})
|
21
21
|
@namespace = options.fetch(:namespace, nil)
|
22
22
|
@refresh_by_access_allowed = options.fetch(:refresh_by_access_allowed, false)
|
23
|
+
@_access_exp = options.fetch(:access_exp, nil)
|
24
|
+
@_refresh_exp = options.fetch(:refresh_exp, nil)
|
23
25
|
end
|
24
26
|
|
25
27
|
def login
|
@@ -237,18 +239,26 @@ module JWTSessions
|
|
237
239
|
end
|
238
240
|
|
239
241
|
def create_refresh_token
|
240
|
-
@_refresh = RefreshToken.create(
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
242
|
+
@_refresh = RefreshToken.create(
|
243
|
+
@_csrf.encoded,
|
244
|
+
@_access.uid,
|
245
|
+
@_access.expiration,
|
246
|
+
store,
|
247
|
+
refresh_payload,
|
248
|
+
namespace,
|
249
|
+
JWTSessions.custom_refresh_expiration(@_refresh_exp)
|
250
|
+
)
|
246
251
|
@refresh_token = @_refresh.token
|
247
252
|
link_access_to_refresh
|
248
253
|
end
|
249
254
|
|
250
255
|
def create_access_token
|
251
|
-
@_access = AccessToken.create(
|
256
|
+
@_access = AccessToken.create(
|
257
|
+
@_csrf.encoded,
|
258
|
+
payload,
|
259
|
+
store,
|
260
|
+
JWTSessions.custom_access_expiration(@_access_exp)
|
261
|
+
)
|
252
262
|
@access_token = @_access.token
|
253
263
|
end
|
254
264
|
end
|
data/lib/jwt_sessions/version.rb
CHANGED
@@ -27,6 +27,18 @@ class TestSession < Minitest::Test
|
|
27
27
|
assert_equal payload[:test], decoded_access["test"]
|
28
28
|
end
|
29
29
|
|
30
|
+
def test_login_with_custom_exp
|
31
|
+
@new_session = JWTSessions::Session.new(
|
32
|
+
payload: payload,
|
33
|
+
access_exp: 18000, # 5 hours in seconds
|
34
|
+
refresh_exp: 18000
|
35
|
+
)
|
36
|
+
assert_equal false, tokens[:refresh_expires_at] == tokens[:access_expires_at]
|
37
|
+
new_tokens = @new_session.login
|
38
|
+
assert_equal LOGIN_KEYS, new_tokens.keys.sort
|
39
|
+
assert_equal new_tokens[:refresh_expires_at], new_tokens[:access_expires_at]
|
40
|
+
end
|
41
|
+
|
30
42
|
def test_refresh
|
31
43
|
refreshed_tokens = session.refresh(tokens[:refresh])
|
32
44
|
decoded_access = JWTSessions::Token.decode(refreshed_tokens[:access]).first
|
@@ -34,6 +46,18 @@ class TestSession < Minitest::Test
|
|
34
46
|
assert_equal payload[:test], decoded_access["test"]
|
35
47
|
end
|
36
48
|
|
49
|
+
def test_refresh_with_custom_exp
|
50
|
+
@new_session = JWTSessions::Session.new(
|
51
|
+
payload: payload,
|
52
|
+
access_exp: 18000, # 5 hours in seconds
|
53
|
+
refresh_exp: 18000
|
54
|
+
)
|
55
|
+
new_tokens = @new_session.login
|
56
|
+
refreshed_tokens = @new_session.refresh(new_tokens[:refresh])
|
57
|
+
assert_equal LOGIN_KEYS, new_tokens.keys.sort
|
58
|
+
assert_equal refreshed_tokens[:refresh_expires_at], refreshed_tokens[:access_expires_at]
|
59
|
+
end
|
60
|
+
|
37
61
|
def test_refresh_expired
|
38
62
|
JWTSessions.refresh_exp_time = 0
|
39
63
|
session = JWTSessions::Session.new(payload: payload)
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jwt_sessions
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Yulia Oletskaya
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-
|
11
|
+
date: 2019-05-01 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: jwt
|