jwt_sessions 1.2.1 → 1.3.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 +63 -0
- data/lib/jwt_sessions/access_token.rb +15 -2
- data/lib/jwt_sessions/authorization.rb +11 -0
- data/lib/jwt_sessions/session.rb +46 -8
- data/lib/jwt_sessions/version.rb +1 -1
- data/test/units/jwt_sessions/test_session.rb +10 -0
- metadata +18 -18
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4bdd67c0de6950cd6f99bc26f1500c06367f76f4
|
4
|
+
data.tar.gz: aa5c2c26f7e15cac228492af9a9b4a1571dab08b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9399b04dd8484864cb084ef80f284389ec04eb6b22cc8b46e95a5c3670849e6c5c069f10613d058e13c2817809990ab207e360b0a4ed5a0ce656686c7b90c87a
|
7
|
+
data.tar.gz: 88cc2e5441dc8b0afae1e167ca9b7a3d4f68796927824e8b03727a42a7e83451c790a05ad8fb9050f69b23facae1265f9b7a51a43170a2285ca48df157a99e9b
|
data/README.md
CHANGED
@@ -19,9 +19,11 @@ XSS/CSRF safe JWT auth designed for SPA
|
|
19
19
|
+ [Request headers and cookies names](#request-headers-and-cookies-names)
|
20
20
|
+ [Expiration time](#expiration-time)
|
21
21
|
+ [CSRF and cookies](#csrf-and-cookies)
|
22
|
+
+ [Refresh with access token](#refresh-with-access-token)
|
22
23
|
+ [Refresh token hijack protection](#refresh-token-hijack-protection)
|
23
24
|
- [Flush Sessions](#flush-sessions)
|
24
25
|
+ [Sessions Namespace](#sessions-namespace)
|
26
|
+
+ [Logout](#logout)
|
25
27
|
- [Examples](#examples)
|
26
28
|
- [Contributing](#contributing)
|
27
29
|
- [License](#license)
|
@@ -372,6 +374,60 @@ session = JWTSessions::Session.new
|
|
372
374
|
session.masked_csrf(access_token)
|
373
375
|
```
|
374
376
|
|
377
|
+
##### Refresh with access token
|
378
|
+
|
379
|
+
Sometimes it's not secure enough to store the refresh tokens in web / JS clients.
|
380
|
+
That's why you have a possibility to operate only with an access token, and to not pass the refresh to the client at all.
|
381
|
+
Session accepts `refresh_by_access_allowed: true` setting, which links the access token to the according refresh token.
|
382
|
+
Example Rails login controller, which passes an access token token via cookies and renders CSRF.
|
383
|
+
|
384
|
+
```ruby
|
385
|
+
class LoginController < ApplicationController
|
386
|
+
include ActionController::Cookies
|
387
|
+
|
388
|
+
def create
|
389
|
+
user = User.find_by!(email: params[:email])
|
390
|
+
if user.authenticate(params[:password])
|
391
|
+
|
392
|
+
payload = { user_id: user.id }
|
393
|
+
session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
|
394
|
+
tokens = session.login
|
395
|
+
cookies[JWTSessions.access_cookie] = tokens[:access]
|
396
|
+
|
397
|
+
render json: { csrf: tokens[:csrf] }
|
398
|
+
else
|
399
|
+
render json: 'Invalid email or password', status: :unauthorized
|
400
|
+
end
|
401
|
+
end
|
402
|
+
end
|
403
|
+
```
|
404
|
+
|
405
|
+
The gem provides an ability to refresh the session by access token.
|
406
|
+
|
407
|
+
```ruby
|
408
|
+
tokens = session.refresh_by_access(access_token)
|
409
|
+
```
|
410
|
+
|
411
|
+
In case of token forgery and successful refresh performed by an atacker - the original user will have to logout.
|
412
|
+
To protect the endpoint use before_action `authorize_refresh_by_access_request!`.
|
413
|
+
Example Rails refresh by access controller with cookies as token transport.
|
414
|
+
|
415
|
+
```ruby
|
416
|
+
class RefreshController < ApplicationController
|
417
|
+
include ActionController::Cookies
|
418
|
+
before_action :authorize_refresh_by_access_request!
|
419
|
+
|
420
|
+
def create
|
421
|
+
session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
|
422
|
+
tokens = session.refresh_by_access(found_token)
|
423
|
+
cookies[JWTSessions.access_cookie] = tokens[:access]
|
424
|
+
|
425
|
+
render json: { csrf: tokens[:csrf] }
|
426
|
+
end
|
427
|
+
end
|
428
|
+
|
429
|
+
```
|
430
|
+
|
375
431
|
#### Refresh token hijack protection
|
376
432
|
|
377
433
|
There is a security recommendation regarding the usage of refresh tokens: only perform refresh when an access token gets expired. \
|
@@ -418,6 +474,13 @@ To force flush of all app sessions
|
|
418
474
|
JWTSessions::Session.flush_all
|
419
475
|
```
|
420
476
|
|
477
|
+
##### Logout
|
478
|
+
|
479
|
+
To logout you need to remove both access and refresh tokens from the store.
|
480
|
+
Flush sessions methods can be used to perform logout.
|
481
|
+
Refresh token or refresh token UID is required to flush a session.
|
482
|
+
To logout with an access token `refresh_by_access_allowed` setting should be set to true on an access token creation. If logout by access token is allowed it's recommended to ignore the expiration claim and to allow to logout with expired access token.
|
483
|
+
|
421
484
|
## Examples
|
422
485
|
|
423
486
|
[Rails API](test/support/dummy_api) \
|
@@ -1,6 +1,8 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
module JWTSessions
|
2
4
|
class AccessToken
|
3
|
-
attr_reader :
|
5
|
+
attr_reader :payload, :uid, :expiration, :csrf, :store
|
4
6
|
|
5
7
|
def initialize(csrf, payload, store, uid = SecureRandom.uuid, expiration = JWTSessions.access_expiration)
|
6
8
|
@csrf = csrf
|
@@ -8,13 +10,24 @@ module JWTSessions
|
|
8
10
|
@expiration = expiration
|
9
11
|
@payload = payload
|
10
12
|
@store = store
|
11
|
-
@token = Token.encode(payload.merge(uid: uid, exp: expiration.to_i))
|
12
13
|
end
|
13
14
|
|
14
15
|
def destroy
|
15
16
|
store.destroy_access(uid)
|
16
17
|
end
|
17
18
|
|
19
|
+
def refresh_uid=(uid)
|
20
|
+
self.payload['ruid'] = uid
|
21
|
+
end
|
22
|
+
|
23
|
+
def refresh_uid
|
24
|
+
payload['ruid']
|
25
|
+
end
|
26
|
+
|
27
|
+
def token
|
28
|
+
Token.encode(payload.merge(uid: uid, exp: expiration.to_i))
|
29
|
+
end
|
30
|
+
|
18
31
|
class << self
|
19
32
|
def create(csrf, payload, store)
|
20
33
|
new(csrf, payload, store).tap do |inst|
|
@@ -21,6 +21,17 @@ module JWTSessions
|
|
21
21
|
end
|
22
22
|
end
|
23
23
|
|
24
|
+
def authorize_refresh_by_access_request!
|
25
|
+
begin
|
26
|
+
cookieless_auth(:access)
|
27
|
+
rescue Errors::Unauthorized
|
28
|
+
cookie_based_auth(:access)
|
29
|
+
end
|
30
|
+
# only latest access token can be used for refresh
|
31
|
+
invalid_authorization unless session_exists?(:access)
|
32
|
+
check_csrf(:access)
|
33
|
+
end
|
34
|
+
|
24
35
|
private
|
25
36
|
|
26
37
|
def invalid_authorization
|
data/lib/jwt_sessions/session.rb
CHANGED
@@ -2,16 +2,24 @@
|
|
2
2
|
|
3
3
|
module JWTSessions
|
4
4
|
class Session
|
5
|
-
attr_reader :access_token,
|
6
|
-
|
5
|
+
attr_reader :access_token,
|
6
|
+
:refresh_token,
|
7
|
+
:csrf_token
|
8
|
+
|
9
|
+
attr_accessor :payload,
|
10
|
+
:store,
|
11
|
+
:refresh_payload,
|
12
|
+
:namespace,
|
13
|
+
:refresh_by_access_allowed
|
7
14
|
|
8
15
|
def initialize(options = {})
|
9
|
-
@store
|
10
|
-
@refresh_payload
|
11
|
-
@payload
|
12
|
-
@access_claims
|
13
|
-
@refresh_claims
|
14
|
-
@namespace
|
16
|
+
@store = options.fetch(:store, JWTSessions.token_store)
|
17
|
+
@refresh_payload = options.fetch(:refresh_payload, {})
|
18
|
+
@payload = options.fetch(:payload, {})
|
19
|
+
@access_claims = options.fetch(:access_claims, {})
|
20
|
+
@refresh_claims = options.fetch(:refresh_claims, {})
|
21
|
+
@namespace = options.fetch(:namespace, nil)
|
22
|
+
@refresh_by_access_allowed = options.fetch(:refresh_by_access_allowed, false)
|
15
23
|
end
|
16
24
|
|
17
25
|
def login
|
@@ -42,6 +50,12 @@ module JWTSessions
|
|
42
50
|
refresh_by_uid(&block)
|
43
51
|
end
|
44
52
|
|
53
|
+
def refresh_by_access(token, &block)
|
54
|
+
ruid = access_token_ruid(token)
|
55
|
+
retrieve_refresh_token(ruid)
|
56
|
+
refresh_by_uid(&block)
|
57
|
+
end
|
58
|
+
|
45
59
|
def flush_by_token(token)
|
46
60
|
uid = token_uid(token, :refresh, @refresh_claims)
|
47
61
|
flush_by_uid(uid)
|
@@ -119,6 +133,22 @@ module JWTSessions
|
|
119
133
|
uid
|
120
134
|
end
|
121
135
|
|
136
|
+
def access_token_ruid(token)
|
137
|
+
token_payload = JWTSessions::Token.decode(token, @access_claims).first
|
138
|
+
|
139
|
+
# ensure access token exists in the store
|
140
|
+
uid = token_payload.fetch('uid', nil)
|
141
|
+
data = store.fetch_access(uid)
|
142
|
+
raise Errors::Unauthorized, 'Access token not found' if data.empty?
|
143
|
+
|
144
|
+
ruid = token_payload.fetch('ruid', nil)
|
145
|
+
if ruid.nil?
|
146
|
+
message = "Access token payload does not contain refresh uid"
|
147
|
+
raise Errors::InvalidPayload, message
|
148
|
+
end
|
149
|
+
ruid
|
150
|
+
end
|
151
|
+
|
122
152
|
def retrieve_refresh_token(uid)
|
123
153
|
@_refresh = RefreshToken.find(uid, store, namespace)
|
124
154
|
end
|
@@ -149,6 +179,13 @@ module JWTSessions
|
|
149
179
|
def update_refresh_token
|
150
180
|
@_refresh.update(@_access.uid, @_access.expiration, @_csrf.encoded)
|
151
181
|
@refresh_token = @_refresh.token
|
182
|
+
link_access_to_refresh
|
183
|
+
end
|
184
|
+
|
185
|
+
def link_access_to_refresh
|
186
|
+
return unless refresh_by_access_allowed
|
187
|
+
@_access.refresh_uid = @_refresh.uid
|
188
|
+
@access_token = @_access.token
|
152
189
|
end
|
153
190
|
|
154
191
|
def create_csrf_token
|
@@ -164,6 +201,7 @@ module JWTSessions
|
|
164
201
|
refresh_payload,
|
165
202
|
namespace)
|
166
203
|
@refresh_token = @_refresh.token
|
204
|
+
link_access_to_refresh
|
167
205
|
end
|
168
206
|
|
169
207
|
def create_access_token
|
data/lib/jwt_sessions/version.rb
CHANGED
@@ -33,6 +33,16 @@ class TestSession < Minitest::Test
|
|
33
33
|
assert_equal payload[:test], decoded_access['test']
|
34
34
|
end
|
35
35
|
|
36
|
+
def test_refresh_by_access
|
37
|
+
session = JWTSessions::Session.new(payload: payload, refresh_by_access_allowed: true)
|
38
|
+
tokens = session.login
|
39
|
+
refreshed_tokens = session.refresh_by_access(tokens[:access])
|
40
|
+
decoded_access = JWTSessions::Token.decode(refreshed_tokens[:access]).first
|
41
|
+
assert_equal EXPECTED_KEYS, refreshed_tokens.keys.sort
|
42
|
+
assert_equal payload[:test], decoded_access['test']
|
43
|
+
assert_equal session.instance_variable_get('@_refresh').uid, decoded_access['ruid']
|
44
|
+
end
|
45
|
+
|
36
46
|
def test_refresh_with_block_not_expired
|
37
47
|
assert_raises JWTSessions::Errors::Unauthorized do
|
38
48
|
session.refresh(tokens[:refresh]) do
|
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: 1.
|
4
|
+
version: 1.3.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: 2018-
|
11
|
+
date: 2018-06-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: jwt
|
@@ -54,58 +54,58 @@ dependencies:
|
|
54
54
|
name: bundler
|
55
55
|
requirement: !ruby/object:Gem::Requirement
|
56
56
|
requirements:
|
57
|
-
- - "
|
57
|
+
- - "~>"
|
58
58
|
- !ruby/object:Gem::Version
|
59
|
-
version: '
|
59
|
+
version: '1.16'
|
60
60
|
type: :development
|
61
61
|
prerelease: false
|
62
62
|
version_requirements: !ruby/object:Gem::Requirement
|
63
63
|
requirements:
|
64
|
-
- - "
|
64
|
+
- - "~>"
|
65
65
|
- !ruby/object:Gem::Version
|
66
|
-
version: '
|
66
|
+
version: '1.16'
|
67
67
|
- !ruby/object:Gem::Dependency
|
68
68
|
name: minitest
|
69
69
|
requirement: !ruby/object:Gem::Requirement
|
70
70
|
requirements:
|
71
|
-
- - "
|
71
|
+
- - "~>"
|
72
72
|
- !ruby/object:Gem::Version
|
73
|
-
version: '
|
73
|
+
version: '5.11'
|
74
74
|
type: :development
|
75
75
|
prerelease: false
|
76
76
|
version_requirements: !ruby/object:Gem::Requirement
|
77
77
|
requirements:
|
78
|
-
- - "
|
78
|
+
- - "~>"
|
79
79
|
- !ruby/object:Gem::Version
|
80
|
-
version: '
|
80
|
+
version: '5.11'
|
81
81
|
- !ruby/object:Gem::Dependency
|
82
82
|
name: pry
|
83
83
|
requirement: !ruby/object:Gem::Requirement
|
84
84
|
requirements:
|
85
|
-
- - "
|
85
|
+
- - "~>"
|
86
86
|
- !ruby/object:Gem::Version
|
87
|
-
version: '0'
|
87
|
+
version: '0.11'
|
88
88
|
type: :development
|
89
89
|
prerelease: false
|
90
90
|
version_requirements: !ruby/object:Gem::Requirement
|
91
91
|
requirements:
|
92
|
-
- - "
|
92
|
+
- - "~>"
|
93
93
|
- !ruby/object:Gem::Version
|
94
|
-
version: '0'
|
94
|
+
version: '0.11'
|
95
95
|
- !ruby/object:Gem::Dependency
|
96
96
|
name: rake
|
97
97
|
requirement: !ruby/object:Gem::Requirement
|
98
98
|
requirements:
|
99
|
-
- - "
|
99
|
+
- - "~>"
|
100
100
|
- !ruby/object:Gem::Version
|
101
|
-
version: '
|
101
|
+
version: '12.3'
|
102
102
|
type: :development
|
103
103
|
prerelease: false
|
104
104
|
version_requirements: !ruby/object:Gem::Requirement
|
105
105
|
requirements:
|
106
|
-
- - "
|
106
|
+
- - "~>"
|
107
107
|
- !ruby/object:Gem::Version
|
108
|
-
version: '
|
108
|
+
version: '12.3'
|
109
109
|
description: XSS/CSRF safe JWT auth designed for SPA
|
110
110
|
email: yulia.oletskaya@gmail.com
|
111
111
|
executables: []
|