Skip to content

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

Description

@naanlizard

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):

(Time.zone.now + lifespan).to_i  # Calendar-aware

2. clean_old_tokens (app/models/devise_token_auth/concerns/user.rb:265):

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:

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 fix: enforce token lifespan limit when cleaning old tokens #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
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:

# 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

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:

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:

# Edit Gemfile to set version (1.2.5 or 1.2.6)
bundle install
ruby reproduce_bug.rb

Test both versions automatically:

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:

# 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions