jwt_sessions 3.2.3 → 3.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 022c03bac5aaaf4a0f238345ce73277b027edf1df4f634a1f3ccfe804c57ad32
4
- data.tar.gz: '0082926879d500097e41b7c796ef082ca8ef5d8a1a6aae6a6ea99a18c636bae0'
3
+ metadata.gz: a1d64a93985f3ada50556e0d6b411b4452e8496363c99e72429ad44adf48bdf8
4
+ data.tar.gz: d8d8c55c021199059141fd392478c8c65896fec65df88ebbeab16d1d48035b9d
5
5
  SHA512:
6
- metadata.gz: c334bcf7670a89711fad701bcc6f2a3ce46d8273e2ea8c516cc680a153978901cf395a788596a1f5becf54675ae1552eb3d553cfa1e58174f162cc6ae362c673
7
- data.tar.gz: 76593854f622fac7c417b9857040de0afa9881bdad19650d08921736b4679801ec696dbe3eb2331a30986a2fac5f02033678f4bf9bc6c68492625a8d2e58b403
6
+ metadata.gz: ccabd8a853a29a58b95c6ad66b8ed3d77437bc4c3ab4c107c935659639b0995e2f3463cbc1c66df1ad1f773d041148c66ff86cbc1276535998281d8e065783b4
7
+ data.tar.gz: 12d1e412ff49d36585c059447bffda9349ac7fdbb73bb5a6346207d5e75c3ec579830106a3d1255874b0debe3321d474359c41aa47fa5fac87fd6c34ec3babd2
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## 3.3.0 (October 13, 2026)
2
+
3
+ Fixes:
4
+
5
+ - do not restore a flushed refresh token during a refresh; a refresh racing a flush now raises `Unauthorized`
6
+
7
+ Support:
8
+
9
+ - `update_refresh` of a store adapter returns `false` when the refresh token no longer exists
10
+ - test against Ruby 4.0 and Rails 8.0 / 8.1
11
+
12
+ ## 3.2.4 (Septemper 21, 2024)
13
+
14
+ Support:
15
+
16
+ - always pass token claims when verifying session
17
+
1
18
  ## 3.2.3 (Septemper 18, 2024)
2
19
 
3
20
  Support:
@@ -79,11 +79,11 @@ module JWTSessions
79
79
  end
80
80
 
81
81
  def valid_csrf_token?(csrf_token, token_type)
82
- JWTSessions::Session.new.valid_csrf?(found_token, csrf_token, token_type)
82
+ JWTSessions::Session.new(session_claims).valid_csrf?(found_token, csrf_token, token_type)
83
83
  end
84
84
 
85
85
  def session_exists?(token_type)
86
- JWTSessions::Session.new.session_exists?(found_token, token_type)
86
+ JWTSessions::Session.new(session_claims).session_exists?(found_token, token_type)
87
87
  end
88
88
 
89
89
  def cookieless_auth(token_type)
@@ -150,5 +150,13 @@ module JWTSessions
150
150
  invalid_authorization unless session_exists?(token_type)
151
151
  check_csrf(token_type)
152
152
  end
153
+
154
+ def session_claims
155
+ claims = respond_to?(:token_claims) ? token_claims : {}
156
+ {
157
+ access_claims: claims,
158
+ refresh_claims: claims
159
+ }
160
+ end
153
161
  end
154
162
  end
@@ -221,7 +221,12 @@ module JWTSessions
221
221
  end
222
222
 
223
223
  def update_refresh_token
224
- @_refresh.update(@_access.uid, @_access.expiration, @_csrf.encoded)
224
+ updated = @_refresh.update(@_access.uid, @_access.expiration, @_csrf.encoded)
225
+ if updated == false
226
+ AccessToken.destroy(@_access.uid, store)
227
+ raise Errors::Unauthorized, "Refresh token not found"
228
+ end
229
+
225
230
  @refresh_token = @_refresh.token
226
231
  link_access_to_refresh
227
232
  end
@@ -20,6 +20,8 @@ module JWTSessions
20
20
  raise NotImplementedError
21
21
  end
22
22
 
23
+ # Return false when the refresh token no longer exists. A refresh then
24
+ # fails instead of writing back a token that a flush has just removed.
23
25
  def update_refresh(_uid:, _access_expiration:, _access_uid:, _csrf:, _namespace:)
24
26
  raise NotImplementedError
25
27
  end
@@ -46,6 +46,8 @@ module JWTSessions
46
46
  end
47
47
 
48
48
  def update_refresh(uid:, access_expiration:, access_uid:, csrf:, namespace: "")
49
+ return false if value_if_not_expired(uid, "refresh", namespace.to_s).empty?
50
+
49
51
  update_refresh_fields(
50
52
  uid,
51
53
  namespace.to_s,
@@ -53,6 +55,7 @@ module JWTSessions
53
55
  access_expiration: access_expiration,
54
56
  access_uid: access_uid
55
57
  )
58
+ true
56
59
  end
57
60
 
58
61
  def all_refresh_tokens(namespace)
@@ -7,6 +7,15 @@ module JWTSessions
7
7
 
8
8
  REFRESH_KEYS = %i[csrf access_uid access_expiration expiration].freeze
9
9
  DEFAULT_POOL_SIZE = 5
10
+ # Updates a refresh token only while it exists, so that a refresh which
11
+ # races a flush cannot write the flushed token back.
12
+ UPDATE_REFRESH_SCRIPT = <<~LUA
13
+ if redis.call("EXISTS", KEYS[1]) == 0 then
14
+ return 0
15
+ end
16
+ redis.call("HMSET", KEYS[1], "csrf", ARGV[1], "access_expiration", ARGV[2], "access_uid", ARGV[3])
17
+ return 1
18
+ LUA
10
19
 
11
20
  def initialize(token_prefix: JWTSessions.token_prefix, redis_client: nil, **options)
12
21
  @prefix = token_prefix
@@ -51,24 +60,27 @@ module JWTSessions
51
60
 
52
61
  def persist_refresh(uid:, access_expiration:, access_uid:, csrf:, expiration:, namespace: nil)
53
62
  key = full_refresh_key(uid, namespace)
54
- update_refresh(
55
- uid: uid,
56
- access_expiration: access_expiration,
57
- access_uid: access_uid,
58
- csrf: csrf,
59
- namespace: namespace
63
+ storage.call("HMSET",
64
+ key,
65
+ :csrf, csrf,
66
+ :access_expiration, access_expiration,
67
+ :access_uid, access_uid,
68
+ :expiration, expiration
60
69
  )
61
- storage.call("HSET", key, :expiration, expiration)
62
70
  storage.call("EXPIREAT", key, expiration)
63
71
  end
64
72
 
65
73
  def update_refresh(uid:, access_expiration:, access_uid:, csrf:, namespace: nil)
66
- storage.call("HMSET",
74
+ updated = storage.call(
75
+ "EVAL",
76
+ UPDATE_REFRESH_SCRIPT,
77
+ 1,
67
78
  full_refresh_key(uid, namespace),
68
- :csrf, csrf,
69
- :access_expiration, access_expiration,
70
- :access_uid, access_uid
79
+ csrf,
80
+ access_expiration,
81
+ access_uid
71
82
  )
83
+ updated == 1
72
84
  end
73
85
 
74
86
  def all_refresh_tokens(namespace)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JWTSessions
4
- VERSION = "3.2.3"
4
+ VERSION = "3.3.0"
5
5
  end
@@ -60,7 +60,7 @@ class TestMemoryStoreAdapter < Minitest::Test
60
60
  expiration: expiration,
61
61
  namespace: ""
62
62
  )
63
- store.update_refresh(
63
+ assert store.update_refresh(
64
64
  uid: "uid",
65
65
  access_expiration: expiration,
66
66
  access_uid: "access_uid",
@@ -71,6 +71,28 @@ class TestMemoryStoreAdapter < Minitest::Test
71
71
  assert_equal "csrf2", refresh[:csrf]
72
72
  end
73
73
 
74
+ def test_update_refresh_after_destroy
75
+ expiration = Time.now.to_i + 3600
76
+ store.persist_refresh(
77
+ uid: "uid",
78
+ access_expiration: expiration,
79
+ access_uid: "access_uid",
80
+ csrf: "csrf",
81
+ expiration: expiration,
82
+ namespace: ""
83
+ )
84
+ store.destroy_refresh("uid", "")
85
+
86
+ refute store.update_refresh(
87
+ uid: "uid",
88
+ access_expiration: expiration,
89
+ access_uid: "access_uid",
90
+ csrf: "csrf2",
91
+ namespace: ""
92
+ )
93
+ assert_equal({}, store.fetch_refresh("uid", ""))
94
+ end
95
+
74
96
  def test_all_refresh_tokens
75
97
  expiration = Time.now.to_i + 3600
76
98
  store.persist_refresh(
@@ -11,6 +11,55 @@ class TestRedisStoreAdapter < Minitest::Test
11
11
  JWTSessions.remove_instance_variable(:@redis_url) if JWTSessions.instance_variable_defined?(:@redis_url)
12
12
  end
13
13
 
14
+ def test_update_refresh
15
+ store = JWTSessions::StoreAdapters::RedisStoreAdapter.new
16
+ expiration = Time.now.to_i + 3600
17
+ store.persist_refresh(
18
+ uid: "uid",
19
+ access_expiration: expiration,
20
+ access_uid: "access_uid",
21
+ csrf: "csrf",
22
+ expiration: expiration,
23
+ namespace: "update_refresh"
24
+ )
25
+
26
+ assert store.update_refresh(
27
+ uid: "uid",
28
+ access_expiration: expiration,
29
+ access_uid: "access_uid",
30
+ csrf: "csrf2",
31
+ namespace: "update_refresh"
32
+ )
33
+ assert_equal "csrf2", store.fetch_refresh("uid", "update_refresh")[:csrf]
34
+ ensure
35
+ store.destroy_refresh("uid", "update_refresh")
36
+ end
37
+
38
+ def test_update_refresh_after_destroy
39
+ store = JWTSessions::StoreAdapters::RedisStoreAdapter.new
40
+ expiration = Time.now.to_i + 3600
41
+ store.persist_refresh(
42
+ uid: "uid",
43
+ access_expiration: expiration,
44
+ access_uid: "access_uid",
45
+ csrf: "csrf",
46
+ expiration: expiration,
47
+ namespace: "update_refresh_after_destroy"
48
+ )
49
+ store.destroy_refresh("uid", "update_refresh_after_destroy")
50
+
51
+ refute store.update_refresh(
52
+ uid: "uid",
53
+ access_expiration: expiration,
54
+ access_uid: "access_uid",
55
+ csrf: "csrf2",
56
+ namespace: "update_refresh_after_destroy"
57
+ )
58
+ assert_empty store.storage.call("KEYS", "#{store.prefix}_update_refresh_after_destroy_*")
59
+ ensure
60
+ store.destroy_refresh("uid", "update_refresh_after_destroy")
61
+ end
62
+
14
63
  def test_error_on_mixed_redis_options
15
64
  assert_raises ArgumentError do
16
65
  JWTSessions::StoreAdapters::RedisStoreAdapter.new(
@@ -6,10 +6,24 @@ require "jwt_sessions"
6
6
  class TestAuthorization < Minitest::Test
7
7
  include JWTSessions::Authorization
8
8
 
9
+ def token_claims
10
+ {
11
+ iss: "issuer",
12
+ aud: "audience",
13
+ }
14
+ end
15
+
9
16
  def setup
10
17
  JWTSessions.signing_key = "abcdefghijklmnopqrstuvwxyzABCDEF"
11
18
  end
12
19
 
20
+ def teardown
21
+ JWTSessions.jwt_options[:verify_iss] = false
22
+ JWTSessions.jwt_options[:verify_aud] = false
23
+
24
+ JWTSessions::Session.flush_all
25
+ end
26
+
13
27
  def test_payload_when_token_is_nil
14
28
  @_raw_token = nil
15
29
 
@@ -23,4 +37,94 @@ class TestAuthorization < Minitest::Test
23
37
  assert_equal payload['user_id'], 1
24
38
  assert_equal payload['secret'], 'mystery'
25
39
  end
40
+
41
+ def test_verify_iss
42
+ JWTSessions.jwt_options[:verify_iss] = true
43
+
44
+ session = JWTSessions::Session.new(payload: { user_id: 1, iss: "issuer" })
45
+ tokens = session.login
46
+
47
+ # Extract uid from access token
48
+ uid = JWT.decode(tokens[:access], JWTSessions.public_key).first["uid"]
49
+
50
+ @_raw_token =
51
+ JWTSessions::Token.encode({ user_id: 1, uid: uid, iss: "issuer" })
52
+
53
+ assert session_exists?(:access)
54
+ end
55
+
56
+ def test_verify_iss_when_iss_is_not_correct
57
+ JWTSessions.jwt_options[:verify_iss] = true
58
+
59
+ session = JWTSessions::Session.new(payload: { user_id: 1, iss: "issuer" })
60
+ tokens = session.login
61
+
62
+ # Extract uid from access token
63
+ uid = JWT.decode(tokens[:access], JWTSessions.public_key).first["uid"]
64
+
65
+ @_raw_token =
66
+ JWTSessions::Token.encode({ user_id: 1, uid: uid, iss: "another_issuer" })
67
+
68
+ assert !session_exists?(:access)
69
+ end
70
+
71
+ def test_verify_iss_when_iss_is_not_present
72
+ JWTSessions.jwt_options[:verify_iss] = true
73
+
74
+ session = JWTSessions::Session.new(payload: { user_id: 1, iss: "issuer" })
75
+ tokens = session.login
76
+
77
+ # Extract uid from access token
78
+ uid = JWT.decode(tokens[:access], JWTSessions.public_key).first["uid"]
79
+
80
+ @_raw_token =
81
+ JWTSessions::Token.encode({ user_id: 1, uid: uid })
82
+
83
+ assert !session_exists?(:access)
84
+ end
85
+
86
+ def test_verify_aud
87
+ JWTSessions.jwt_options[:verify_aud] = true
88
+
89
+ session = JWTSessions::Session.new(payload: { user_id: 1, aud: "audience" })
90
+ tokens = session.login
91
+
92
+ # Extract uid from access token
93
+ uid = JWT.decode(tokens[:access], JWTSessions.public_key).first["uid"]
94
+
95
+ @_raw_token =
96
+ JWTSessions::Token.encode({ user_id: 1, uid: uid, aud: "audience" })
97
+
98
+ assert session_exists?(:access)
99
+ end
100
+
101
+ def test_verify_aud_when_aud_is_not_correct
102
+ JWTSessions.jwt_options[:verify_aud] = true
103
+
104
+ session = JWTSessions::Session.new(payload: { user_id: 1, aud: "audience" })
105
+ tokens = session.login
106
+
107
+ # Extract uid from access token
108
+ uid = JWT.decode(tokens[:access], JWTSessions.public_key).first["uid"]
109
+
110
+ @_raw_token =
111
+ JWTSessions::Token.encode({ user_id: 1, uid: uid, aud: "another_audience" })
112
+
113
+ assert !session_exists?(:access)
114
+ end
115
+
116
+ def test_verify_aud_when_aud_is_not_present
117
+ JWTSessions.jwt_options[:verify_aud] = true
118
+
119
+ session = JWTSessions::Session.new(payload: { user_id: 1, aud: "audience" })
120
+ tokens = session.login
121
+
122
+ # Extract uid from access token
123
+ uid = JWT.decode(tokens[:access], JWTSessions.public_key).first["uid"]
124
+
125
+ @_raw_token =
126
+ JWTSessions::Token.encode({ user_id: 1, uid: uid })
127
+
128
+ assert !session_exists?(:access)
129
+ end
26
130
  end
@@ -7,9 +7,9 @@ class TestRefreshToken < Minitest::Test
7
7
  attr_reader :csrf, :token, :access_uid
8
8
 
9
9
  def setup
10
+ JWTSessions.signing_key = "secure key"
10
11
  JWTSessions::Session.flush_all
11
12
 
12
- JWTSessions.signing_key = "secure key"
13
13
  @access_uid = SecureRandom.uuid
14
14
  @csrf = JWTSessions::CSRFToken.new
15
15
  @token = JWTSessions::RefreshToken.create(@csrf.encoded,
@@ -336,6 +336,21 @@ class TestSession < Minitest::Test
336
336
  assert_equal 0, session.flush_namespaced_access_tokens
337
337
  end
338
338
 
339
+ def test_refresh_after_concurrent_flush
340
+ refresh_token = tokens[:refresh]
341
+ racing_store = JWTSessions.token_store.dup
342
+ racing_store.define_singleton_method(:update_refresh) do |**fields|
343
+ JWTSessions::Session.new.flush_by_token(refresh_token)
344
+ super(**fields)
345
+ end
346
+ racing_session = JWTSessions::Session.new(payload: payload, store: racing_store)
347
+
348
+ assert_raises JWTSessions::Errors::Unauthorized do
349
+ racing_session.refresh(refresh_token)
350
+ end
351
+ assert_empty RedisClient.new.call("KEYS", "jwt_*")
352
+ end
353
+
339
354
  def test_flush_all
340
355
  refresh_token = @session.instance_variable_get(:"@_refresh")
341
356
  flushed_count = JWTSessions::Session.flush_all
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jwt_sessions
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.2.3
4
+ version: 3.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julija Alieckaja
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-09-18 00:00:00.000000000 Z
10
+ date: 2026-10-13 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: jwt
@@ -113,7 +112,6 @@ metadata:
113
112
  changelog_uri: https://github.com/tuwukee/jwt_sessions/blob/master/CHANGELOG.md
114
113
  source_code_uri: https://github.com/tuwukee/jwt_sessions
115
114
  bug_tracker_uri: https://github.com/tuwukee/jwt_sessions/issues
116
- post_install_message:
117
115
  rdoc_options: []
118
116
  require_paths:
119
117
  - lib
@@ -128,13 +126,10 @@ required_rubygems_version: !ruby/object:Gem::Requirement
128
126
  - !ruby/object:Gem::Version
129
127
  version: '0'
130
128
  requirements: []
131
- rubygems_version: 3.4.12
132
- signing_key:
129
+ rubygems_version: 4.0.3
133
130
  specification_version: 4
134
131
  summary: JWT Sessions
135
132
  test_files:
136
- - test/units/test_jwt_sessions.rb
137
- - test/units/test_token_store.rb
138
133
  - test/units/jwt_sessions/store_adapters/test_memory_store_adapter.rb
139
134
  - test/units/jwt_sessions/store_adapters/test_redis_store_adapter.rb
140
135
  - test/units/jwt_sessions/test_access_token.rb
@@ -143,3 +138,5 @@ test_files:
143
138
  - test/units/jwt_sessions/test_refresh_token.rb
144
139
  - test/units/jwt_sessions/test_session.rb
145
140
  - test/units/jwt_sessions/test_token.rb
141
+ - test/units/test_jwt_sessions.rb
142
+ - test/units/test_token_store.rb