Skip to content

Commit 9719d24

Browse files
feat: enforce token lifespan limit when cleaning old tokens (#1657)
1 parent 64096bd commit 9719d24

2 files changed

Lines changed: 113 additions & 11 deletions

File tree

app/models/devise_token_auth/concerns/user.rb

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -258,17 +258,25 @@ def max_client_tokens_exceeded?
258258
end
259259

260260
def clean_old_tokens
261-
if tokens.present? && max_client_tokens_exceeded?
262-
# Using Enumerable#sort_by on a Hash will typecast it into an associative
263-
# Array (i.e. an Array of key-value Array pairs). However, since Hashes
264-
# have an internal order in Ruby 1.9+, the resulting sorted associative
265-
# Array can be converted back into a Hash, while maintaining the sorted
266-
# order.
267-
self.tokens = tokens.sort_by { |_cid, v| v[:expiry] || v['expiry'] }.to_h
268-
269-
# Since the tokens are sorted by expiry, shift the oldest client token
270-
# off the Hash until it no longer exceeds the maximum number of clients
271-
tokens.shift while max_client_tokens_exceeded?
261+
return if tokens.blank? || !max_client_tokens_exceeded?
262+
263+
# First, remove any tokens with expiry greater than current max allowed lifespan
264+
# this handles the case where token lifespan was reduced and old tokens exist
265+
max_lifespan_expiry = Time.now.to_i + DeviseTokenAuth.token_lifespan.to_i
266+
tokens_to_keep = tokens.select do |_cid, v|
267+
expiry = (v[:expiry] || v['expiry']).to_i
268+
expiry <= max_lifespan_expiry
272269
end
270+
271+
# Using Enumerable#sort_by on a Hash will typecast it into an associative
272+
# Array (i.e. an Array of key-value Array pairs). However, since Hashes
273+
# have an internal order in Ruby 1.9+, the resulting sorted associative
274+
# Array can be converted back into a Hash, while maintaining the sorted
275+
# order.
276+
self.tokens = tokens_to_keep.sort_by { |_cid, v| v[:expiry] || v['expiry'] }.to_h
277+
278+
# Since the tokens are sorted by expiry, shift the oldest client token
279+
# off the Hash until it no longer exceeds the maximum number of clients
280+
tokens.shift while max_client_tokens_exceeded?
273281
end
274282
end

test/models/user_test.rb

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,98 @@ class UserTest < ActiveSupport::TestCase
127127
end
128128
end
129129
end
130+
131+
describe 'clean_old_tokens' do
132+
before do
133+
@resource = create(:user, :confirmed)
134+
@token_lifespan = DeviseTokenAuth.token_lifespan
135+
@max_client_count = DeviseTokenAuth.max_number_of_devices
136+
DeviseTokenAuth.max_number_of_devices = 2
137+
DeviseTokenAuth.token_lifespan = 1.week
138+
end
139+
140+
after do
141+
DeviseTokenAuth.token_lifespan = @token_lifespan
142+
DeviseTokenAuth.max_number_of_devices = @max_client_count
143+
end
144+
145+
test 'removes tokens with expiry beyond the maximum lifespan' do
146+
# Create tokens with different expiry times
147+
current_time = Time.now.to_i
148+
149+
max_lifespan = current_time + DeviseTokenAuth.token_lifespan.to_i
150+
151+
# Valid token within lifespan
152+
@resource.tokens['valid_client'] = {
153+
'token' => 'valid_token',
154+
'expiry' => current_time + 1.day.to_i
155+
}
156+
157+
# Token exactly at max lifespan (should be kept)
158+
@resource.tokens['edge_client'] = {
159+
'token' => 'edge_token',
160+
'expiry' => max_lifespan
161+
}
162+
163+
# Token beyond max lifespan (should be removed)
164+
@resource.tokens['expired_client'] = {
165+
'token' => 'expired_token',
166+
'expiry' => max_lifespan + 1.day.to_i
167+
}
168+
169+
# Call the method under test
170+
@resource.send(:clean_old_tokens)
171+
172+
# Assert that tokens beyond lifespan were removed
173+
assert @resource.tokens.key?('valid_client'), 'Valid token should be kept'
174+
assert @resource.tokens.key?('edge_client'), 'Edge case token at max lifespan should be kept'
175+
refute @resource.tokens.key?('expired_client'), 'Token beyond max lifespan should be removed'
176+
end
177+
178+
test 'handles token lifespan reduction when creating token' do
179+
# Setup: Create the maximum allowed number of tokens with a longer lifespan
180+
DeviseTokenAuth.token_lifespan = 2.weeks
181+
DeviseTokenAuth.max_number_of_devices = 3
182+
183+
# Create tokens at different times but all within the initial long lifespan
184+
@resource.tokens = {}
185+
@resource.tokens['client_1'] = {
186+
'token' => 'token_1',
187+
'expiry' => Time.now.to_i + 12.days.to_i
188+
}
189+
190+
@resource.tokens['client_2'] = {
191+
'token' => 'token_2',
192+
'expiry' => Time.now.to_i + 10.days.to_i
193+
}
194+
195+
@resource.tokens['client_3'] = {
196+
'token' => 'token_3',
197+
'expiry' => Time.now.to_i + 5.days.to_i
198+
}
199+
200+
# We've reached the maximum number of devices/tokens
201+
assert_equal 3, @resource.tokens.length
202+
203+
# Now reduce token lifespan - simulating a config change
204+
DeviseTokenAuth.token_lifespan = 1.week
205+
206+
# Create a new token which should trigger clean_old_tokens
207+
new_auth_headers = @resource.create_new_auth_token
208+
new_client = new_auth_headers['client']
209+
210+
# The new token should exist
211+
assert @resource.tokens.key?(new_client), 'New token should exist'
212+
213+
# Tokens exceeding the new reduced lifespan should be removed
214+
refute @resource.tokens.key?('client_1'), 'Token with expiry > new lifespan should be removed'
215+
refute @resource.tokens.key?('client_2'), 'Token with expiry > new lifespan should be removed'
216+
217+
# Token within new lifespan should be kept
218+
assert @resource.tokens.key?('client_3'), 'Token within new reduced lifespan should be kept'
219+
220+
# We should have exactly 2 tokens: the new one and client_3
221+
assert_equal 2, @resource.tokens.length
222+
end
223+
end
130224
end

0 commit comments

Comments
 (0)