lynndylanhurley / lynndylanhurley/devise_token_auth
Duration arithmetic mismatch in v1.2.6 causes newly created tokens to be immediately evicted with variable-length lifespans
Nobody has claimed this yet.
- Dominant language
- Ruby
- Stars
- 3.6k
- Forks
- 1.1k
- PR merge metrics
- No merged PRs in 30d
Description
### Summary
Version 1.2.6 introduced a bug in PR #1657 where `clean_old_tokens` uses incompatible arithmetic compared to `TokenFactory.expiry`, causing newly created tokens to be immediately filtered out when `token_lifespan` is set to variable-length durations like `6.months`.
### Root Cause
Two methods calculate "N months from now" using different arithmetic:
**1. `TokenFactory.expiry` (lib/devise_token_auth/token_factory.rb:64):**
```ruby
(Time.zone.now + lifespan).to_i # Calendar-aware
```
**2. `clean_old_tokens` (app/models/devise_token_auth/concerns/user.rb:265):**
```ruby
Time.now.to_i + DeviseTokenAuth.token_lifespan.to_i # Fixed seconds
```
### The Problem
When `token_lifespan = 6.months`:
- `6.months.to_i` = 15,778,476 seconds = **182.62 days** (average month length)
- Calendar arithmetic from March 1: March → September 1 = **184.0 days**
- **Mismatch: 119,124 seconds (1.38 days)**
This causes the filter at line 268 to reject the newly created token:
```ruby
tokens_to_keep = tokens.select do |_cid, v|
expiry = (v[:expiry] || v['expiry']).to_i
expiry <= max_lifespan_expiry # Sep 1 <= Aug 31? FALSE!
end
```
### Impact
- **Affected configurations:** Variable-length durations (`6.months`, `1.year` in certain months)
- **Not affected:** Fixed durations (`2.weeks`, `30.days`)
- **Symptom:** Password resets fail with `NoMethodError` when `max_number_of_devices` limit is reached
- **Introduced in:** v1.2.6 via PR #1657 (commit 9719d24)
- **Last working version:** v1.2.5
### Reproduction
See attached minimal reproduction case that demonstrates:
1. Time frozen at March 1st, 2026 for consistent reproduction
2. The arithmetic mismatch (1.38 day difference)
3. Newly created token being filtered out
4. The exact calculation difference
```bash
cd dta-bug-reproduction
bundle install
ruby reproduce_bug.rb
```
The script uses `travel_to` to freeze time at March 1st, 2026, ensuring the bug is consistently reproducible regardless of when the script is run.
### Proposed Fix
Change line 265 in `app/models/devise_token_auth/concerns/user.rb` to use calendar arithmetic:
```ruby
# Before (buggy):
max_lifespan_expiry = Time.now.to_i + DeviseTokenAuth.token_lifespan.to_i
# After (fixed):
max_lifespan_expiry = (Time.zone.now + DeviseTokenAuth.token_lifespan).to_i
```
This makes `clean_old_tokens` use the same arithmetic as `TokenFactory.expiry`.
### Additional Issues
1. **Inconsistent Time methods:** `TokenFactory.expiry` uses `Time.zone.now` while `clean_old_tokens` uses `Time.now` (cosmetic but should be consistent)
2. **Tests don't catch this:** The test suite uses fixed durations (`2.weeks`, `1.week`) where `to_i` conversion is lossless, and constructs expiries with the same arithmetic as `clean_old_tokens`, so the mismatch never appears in tests.
### Environment
- **devise_token_auth:** 1.2.6
- **Rails:** 8.0
- **Ruby:** 3.3.7
- **Configuration:** `config.token_lifespan = 6.months`
### Workarounds
1. **Downgrade to v1.2.5** (recommended for now)
2. **Increase `max_number_of_devices`** to prevent cleanup from triggering (doesn't fix root cause)
3. **Use fixed duration** like `180.days` instead of `6.months`
4. **Override `clean_old_tokens`** in User model with corrected arithmetic
### References
- PR #1657: https://github.com/lynndylanhurley/devise_token_auth/pull/1657
- Commit: https://github.com/lynndylanhurley/devise_token_auth/commit/9719d245d82d87cae2456207bc216d6014fda916
- Affected code: `app/models/devise_token_auth/concerns/user.rb` lines 260-281
# devise_token_auth 1.2.6 Bug Reproduction
This is a minimal reproduction case for the Duration arithmetic mismatch bug in devise_token_auth 1.2.6.
## Bug Summary
When `token_lifespan` is set to a variable-length duration like `6.months`, newly created tokens are immediately filtered out by `clean_old_tokens` due to incompatible arithmetic:
- `TokenFactory.expiry` uses calendar arithmetic: `(Time.zone.now + 6.months).to_i`
- `clean_old_tokens` uses fixed-second arithmetic: `Time.now.to_i + 6.months.to_i`
This creates a ~1.38 day mismatch (from March 1), causing the new token to be rejected.
## Setup & Run
### Option 1: Using Docker (Recommended)
Test both versions side-by-side:
```bash
docker compose up --build
```
This will:
1. Build a Ruby 3.3.7 container
2. Install dependencies
3. Test v1.2.5 (should PASS ✅)
4. Test v1.2.6 (should FAIL ❌)
### Option 2: Local Ruby
Test a specific version:
```bash
# Edit Gemfile to set version (1.2.5 or 1.2.6)
bundle install
ruby reproduce_bug.rb
```
Test both versions automatically:
```bash
chmod +x test_both_versions.sh
./test_both_versions.sh
```
**Requirements:**
- Ruby 3.3+
- Bundler
- SQLite3
## What the Script Does
The reproduction script:
1. Creates an in-memory SQLite database
2. Freezes time at March 1st, 2026 (fixed date for consistent reproduction)
3. Configures `token_lifespan = 6.months` and `max_number_of_devices = 2`
4. Demonstrates the arithmetic mismatch (1.38 day difference from March 1)
5. Creates 3 tokens to trigger cleanup
6. Checks if the newly created token survived
7. Shows PASS ✅ or FAIL ❌ with detailed explanation
## Expected Output
### v1.2.5 (Working)
```
✅ TEST PASSED - Token cleanup works correctly
```
### v1.2.6 (Buggy)
```
❌ TEST FAILED - Newly created token was immediately filtered out
Reason: Token expiry (1756684800) > Filter cutoff (1756565676)
Difference: 119124 seconds (1.38 days)
Note: Using fixed date March 1st, 2026 for consistent reproduction
```
## Files
- `Dockerfile` - Ruby 3.3.7 container setup
- `docker-compose.yml` - Docker Compose configuration
- `Gemfile` - Minimal dependencies
- `reproduce_bug.rb` - Standalone reproduction script
- `test_both_versions.sh` - Shell script to test both versions
- `README.md` - This file
- `GITHUB_ISSUE.md` - GitHub issue template
## Root Cause
PR #1657 (commit 9719d24) introduced a bug in `clean_old_tokens` at line 265:
```ruby
# Buggy (v1.2.6):
max_lifespan_expiry = Time.now.to_i + DeviseTokenAuth.token_lifespan.to_i
# Should be (like TokenFactory.expiry):
max_lifespan_expiry = (Time.zone.now + DeviseTokenAuth.token_lifespan).to_i
```
The difference:
- `6.months.to_i` = 15,778,476 seconds = 182.62 days (average)
- Calendar March → September = 184.0 days
- **Mismatch: 1.38 days**
## Impact
- **Affected:** Configurations using variable-length durations (`6.months`, `1.year`)
- **Not affected:** Fixed durations (`2.weeks`, `30.days`)
- **Symptom:** Password resets fail when `max_number_of_devices` limit is reached
- **Introduced:** v1.2.6
- **Last working:** v1.2.5
## Workarounds
1. **Downgrade to v1.2.5** (recommended)
2. **Increase `max_number_of_devices`** to prevent cleanup
3. **Use fixed duration** like `180.days` instead of `6.months`
4. **Override `clean_old_tokens`** in User model with corrected arithmetic
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with app/models/devise_token_auth/concerns/user.rb around clean_old_tokens and compare its expiry calculation with lib/devise_token_auth/token_factory.rb. Run reproduce_bug.rb with time frozen at March 1, 2026 and token_lifespan set to 6.months; done means the newly created token survives cleanup and regression coverage demonstrates the variable-duration case.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rails, ruby
- Domain
- authentication, backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100