lynndylanhurley / lynndylanhurley/devise_token_auth

Duration arithmetic mismatch in v1.2.6 causes newly created tokens to be immediately evicted with variable-length lifespans

Open
#1,677 1 comment 2 reactions 0 assignees View on GitHub

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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.