sessions 0.1.0 → 0.1.2

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: 342cda70d0a4f3ed6b760d8dee9ff8dea68c97151d932e77c478474e216fe9ef
4
- data.tar.gz: 6885d70e1cc6cbb5ebd9850c0b3f5d015ce22d1aeae5ba2968b0fd5bb33412e0
3
+ metadata.gz: f088a81ac84fec5df0bfd521c3d4012fa6a91a4a84e22e997e22d18ef1658b1b
4
+ data.tar.gz: d50021247992ad1bef65b9a4d5f678146e774501b6adaa0af87840ec1e8ce988
5
5
  SHA512:
6
- metadata.gz: a15d061c3b9397265aa2e7581ed5e06ce64ae50943a54aa71b45539dfe528153004422b9c782d1dcd5cd2b62a55edef8612ed26c4f8a55ee8d91c0379f3d9133
7
- data.tar.gz: 4ecef8ccd7f56454a31feb6623245c38585dbae7601fdb681daa6192e764964a4c47300dc3b8aaaa6dbe2811246a66c304210d202e5a6f9e46c40085d34a246f
6
+ metadata.gz: ab013f9e59218a51e6f580deaa8d1bf01e45a4fd22eded95d91e1d4e2ba434d433bceedddf06d349dcfdfa63fd2b3678c8aaa9742729353da439e6e01778115e
7
+ data.tar.gz: 10eabd4758d01e1cf0ed44df351f8182dfe27c80f4fec7ee2024f0500da199a1ca356c77c72d444efface0c56f091f9d1312c9d46a2c2bd80ea77a6115fe94a9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2 (2026-06-16)
4
+
5
+ Production-found hardening for Devise/Warden adoption, the path that turns already-authenticated pre-gem sessions into registry rows.
6
+
7
+ - **Adoption is now keyed by owner + scope, not user agent or time.** One pre-existing authenticated session marker is enough; Hotwire Native devices legitimately present both a WebView UA and a native HTTP-client UA, and clients that drop `Set-Cookie` responses must not mint one adopted row per UA or per day.
8
+ - **Concurrent adoption bursts are now atomic when the upgrade migration has run.** Adopted rows get a nullable `adoption_key` with a plain unique index. Normal sessions keep it `NULL`, so the index behaves like a portable partial unique index across PostgreSQL, MySQL and SQLite. Racing requests either create the one keyed row or catch the uniqueness collision, re-select it, and touch it — still inside `Sessions.safely`, so tracking never breaks login.
9
+ - **Existing installs have an explicit upgrade path.** Run `rails generate sessions:upgrade` and `rails db:migrate` to add `sessions.adoption_key`. Fresh installs get the column from `sessions:install`.
10
+ - **The adapter degrades gracefully before the migration.** If `adoption_key` is absent, it still reuses any existing adopted row for the same owner + scope, eliminating the UA split and 24-hour leak while the schema upgrade is pending; the database-level race guarantee starts once the migration is deployed.
11
+
12
+ ## 0.1.1 (2026-06-12)
13
+
14
+ Production-found fix, hours after 0.1.0: a client that forwards cookies **read-only** — the canonical case is a native app's HTTP layer that attaches the WebView's cookie but drops `Set-Cookie` responses — re-enters the Devise-mode *adoption* path on every request, because the session token the gem writes never persists client-side. Each pass minted a fresh adopted row (and adoption also skipped the per-user cap), so one phone on a polling screen accumulated hundreds of "live devices" in an hour.
15
+
16
+ - **Adoption is now idempotent**: a re-entering client matches its recent adopted row (same user, scope, and user agent within 24h) and just touches it — no new row, no token rotation (rotating would kick a sibling client validly holding that row's token, e.g. the app's WebView next to its native HTTP stack).
17
+ - **The per-user session cap now applies to suppressed (adopted) writes too** — it's the hard limit on live rows, whatever path creates them.
18
+
19
+ Plus a full-codebase audit pass:
20
+
21
+ - **The remote-revocation kick now ships its own flash copy.** The Warden adapter throws `message: :session_revoked`, and Devise's failure app resolves it via `devise.failure.session_revoked` — a key nothing shipped, so revoked devices saw a literal "Translation missing: en.devise.failure.user.session_revoked" at the exact moment the flagship feature fired. The gem now provides the key in English and Spanish (override it like any I18n key), pinned by a test that mirrors Devise's exact lookup (`I18n.t(:"#{scope}.#{message}", scope: "devise.failure", default: [message])`).
22
+ - **`config.strategy_methods` now truly overrides built-ins on a shared key.** The mapping merge let the built-in value win a duplicate key (e.g. remapping `"Rememberable"`), contradicting the documented host-wins contract; host entries now win both the iteration order and key conflicts.
23
+ - **Failed-login identity capture also reads `email_address`** — the omakase-era key, and what Devise apps with `authentication_keys = [:email_address]` post. Those failures used to record `identity: nil`, quietly gutting ATO triage and `repeated_failed_logins` for that setup.
24
+ - **The installer now validates the Devise auth model fit.** Default (non-polymorphic) mode assumes a `User` class — the same assumption `rails generate authentication` makes. A Devise app on `Member`/`Account` used to pass detection and then break at `db:migrate` (foreign key to a missing `users` table); the installer now reads `Devise.mappings` and stops with the fix (`--polymorphic`) before writing anything, and warns (without blocking) when extra scopes ride alongside `User`, since those stay silently untracked in default mode. Unreadable mappings stay permissive. README gained the matching callout.
25
+ - **API-only hosts boot.** The engine controller's class body used `helper`/`helper_method`/`layout` unconditionally — none of which exist on `ActionController::API` — and production eager-loads engine controllers whether or not the engine is mounted, so an API-only app bundling the gem for the model/trail APIs failed to boot. The view-layer DSL is now capability-guarded (mounting the HTML page still requires a `Base`-derived parent, as documented).
26
+ - **Generated migrations honor exotic primary-key types.** `primary_key_type: :string` (ULID-style apps) now flows through to the events table's PK and the `session_id` linkage column (previously coerced to `bigint`, breaking the trail↔registry join), and the serial pseudo-types map to their plain integer column equivalents for reference columns — a `t.bigserial` reference would mint its own sequence.
27
+ - `sessions_format_date`/`sessions_format_time` tolerate `nil` (custom views passing a nullable column used to hit `nil.strftime` *inside* the fallback rescue).
28
+
3
29
  ## 0.1.0 (2026-06-12)
4
30
 
5
31
  First release — the missing session layer for Rails. 🔐
data/README.md CHANGED
@@ -91,6 +91,22 @@ end
91
91
 
92
92
  That's it. Every sign-in from now on lands on the devices page and in the trail — on Rails 8 auth there is literally nothing else to wire (the gem decorates the generated `Session` model automatically; your app code stays untouched).
93
93
 
94
+ > [!NOTE]
95
+ > **Auth model isn't `User`?** (Devise on `Member`/`Account`, or several `devise_for` scopes): install with `rails generate sessions:install --polymorphic` — the session owner becomes polymorphic and every scope/model gets tracked; put `has_sessions` on each of them. The default install assumes a `User` class (the same assumption `rails generate authentication` makes), and the installer stops with exactly this advice when your Devise mappings don't fit it.
96
+ >
97
+ > **API-only app?** The gem boots and tracks fine (use the model APIs and `Sessions.track_login`), but the devices page is HTML — mounting the engine needs an `ActionController::Base`-derived `config.parent_controller`, which API-only apps don't have.
98
+
99
+ ### Upgrading
100
+
101
+ Existing apps upgrading from 0.1.0 or 0.1.1 should copy the upgrade migration and run it:
102
+
103
+ ```bash
104
+ rails generate sessions:upgrade
105
+ rails db:migrate
106
+ ```
107
+
108
+ This adds `sessions.adoption_key`, the portable unique guard that makes pre-gem session adoption atomic under concurrent native-app request bursts. Fresh installs already get it from `sessions:install`.
109
+
94
110
  ## What `sessions` does (and doesn't) do
95
111
 
96
112
  **Does:**
@@ -33,12 +33,19 @@ module Sessions
33
33
 
34
34
  before_action :sessions_authenticate!
35
35
 
36
- helper Sessions::EngineHelper
37
- helper_method :sessions_current_user, :sessions_current_session
36
+ # The view-layer DSL (`helper`, `helper_method`, `layout`) doesn't exist
37
+ # on ActionController::API — and an API-only host that bundles the gem
38
+ # purely for the model/trail APIs still EAGER LOADS this class in
39
+ # production, mounted or not. Guarding keeps such hosts bootable; the
40
+ # HTML devices page itself still requires a Base-derived
41
+ # config.parent_controller (the default), which is the only setup it's
42
+ # rendered under.
43
+ helper Sessions::EngineHelper if respond_to?(:helper)
44
+ helper_method :sessions_current_user, :sessions_current_session if respond_to?(:helper_method)
38
45
 
39
46
  # nil falls through to the parent controller's regular layout
40
47
  # resolution, so by default the page looks like the rest of the host.
41
- layout :sessions_layout
48
+ layout :sessions_layout if respond_to?(:layout)
42
49
 
43
50
  private
44
51
 
@@ -102,14 +102,21 @@ module Sessions
102
102
 
103
103
  # "Signed in May 2, 2026" — localized when the host bundles date
104
104
  # formats (rails-i18n or its own locale files), with a safe fallback so
105
- # a bare host never 500s over a missing `date.formats.long`.
105
+ # a bare host never 500s over a missing `date.formats.long`. nil-safe:
106
+ # without the guard, I18n.l(nil) raises I18n::ArgumentError and the
107
+ # rescue would then call nil.strftime — a trap for custom views passing
108
+ # a nullable column.
106
109
  def sessions_format_date(date)
110
+ return nil unless date
111
+
107
112
  I18n.l(date, format: :long)
108
113
  rescue I18n::MissingTranslationData, I18n::ArgumentError
109
114
  date.strftime("%Y-%m-%d")
110
115
  end
111
116
 
112
117
  def sessions_format_time(time)
118
+ return nil unless time
119
+
113
120
  I18n.l(time, format: :short)
114
121
  rescue I18n::MissingTranslationData, I18n::ArgumentError
115
122
  time.strftime("%Y-%m-%d %H:%M")
@@ -57,3 +57,14 @@ en:
57
57
  composite: "%{client} on %{platform}"
58
58
  unknown: "Unknown device"
59
59
  bot: "Bot (%{name})"
60
+ # The flash a remotely-revoked device sees on its next request, rendered by
61
+ # Devise's failure app (the Warden adapter kicks with
62
+ # `throw :warden, message: :session_revoked`). Devise's lookup is
63
+ # `I18n.t(:"#{scope}.#{message}", scope: "devise.failure", default: [message])`
64
+ # — see Devise::FailureApp#i18n_message (devise/lib/devise/failure_app.rb).
65
+ # Without this key the flash literally reads
66
+ # "Translation missing: en.devise.failure.user.session_revoked".
67
+ # Hosts override it like any I18n key (app locale files load after engines).
68
+ devise:
69
+ failure:
70
+ session_revoked: "That session was signed out remotely. Please sign in again."
@@ -57,3 +57,10 @@ es:
57
57
  composite: "%{client} en %{platform}"
58
58
  unknown: "Dispositivo desconocido"
59
59
  bot: "Bot (%{name})"
60
+ # El aviso que ve un dispositivo cuya sesión fue revocada en remoto, en su
61
+ # siguiente petición (lo muestra la failure app de Devise; el adaptador de
62
+ # Warden expulsa con `throw :warden, message: :session_revoked`). Sin esta
63
+ # clave, el flash mostraría literalmente "Translation missing: …".
64
+ devise:
65
+ failure:
66
+ session_revoked: "Esa sesión se cerró de forma remota. Vuelve a iniciar sesión."
@@ -78,6 +78,48 @@ module Sessions
78
78
  MSG
79
79
  end
80
80
 
81
+ # Default (non-polymorphic) mode assumes a `User` class — the same
82
+ # assumption `rails generate authentication` makes: `belongs_to :user`
83
+ # and a foreign key to `users`. A Devise app whose auth model is
84
+ # Member/Account would pass detection and then break at db:migrate
85
+ # (FK to a missing `users` table) or at runtime (`belongs_to :user`
86
+ # constantizing a class that doesn't exist) — catch it HERE, with the
87
+ # fix in the error message. `--polymorphic` works with any model(s).
88
+ def check_devise_auth_model_fit!
89
+ return if polymorphic?
90
+ return unless devise_detected?
91
+ # An adopted omakase table already proves whatever owner shape the
92
+ # host made work — nothing for us to second-guess.
93
+ return if adopt_existing_table?
94
+
95
+ classes = devise_auth_class_names
96
+ return if classes.empty? # mappings unreadable — stay permissive
97
+ return if classes == ["User"] # the default assumption holds
98
+
99
+ if classes.include?("User")
100
+ # User plus other scopes: default mode tracks User and SILENTLY
101
+ # skips the rest (the runtime adapter's row_accepts? guard) —
102
+ # surface that tradeoff at install time, but proceed.
103
+ say "⚠️ Multiple Devise models detected (#{classes.join(", ")}).", :yellow
104
+ say " The default install tracks only User — sessions for the other models", :yellow
105
+ say " stay untracked. Re-run with --polymorphic to track them all.", :yellow
106
+ return
107
+ end
108
+
109
+ raise Thor::Error, <<~MSG
110
+ ❌ Your Devise model is #{classes.join(", ")} — not User. The default install
111
+ assumes a `User` class (`belongs_to :user`, foreign key to `users`, the
112
+ same assumption `rails generate authentication` makes) and would break
113
+ at migrate/runtime.
114
+
115
+ Re-run with the polymorphic owner, which works with any model(s):
116
+
117
+ rails generate sessions:install --polymorphic
118
+
119
+ …then declare `has_sessions` on #{classes.join(" and ")}.
120
+ MSG
121
+ end
122
+
81
123
  def create_migration_files
82
124
  if adopt_existing_table?
83
125
  migration_template "add_sessions_columns.rb.erb",
@@ -191,6 +233,19 @@ module Sessions
191
233
  defined?(::Devise) ? true : false
192
234
  end
193
235
 
236
+ # The class names behind the host's `devise_for` scopes, as strings
237
+ # (never constantized — the generator must not boot-order-couple to
238
+ # app models). On Rails 8, `Devise.mappings` itself force-loads the
239
+ # lazy routes (Devise 5.x, lib/devise.rb), so an empty hash genuinely
240
+ # means "no devise_for drawn yet" — nothing to validate against.
241
+ def devise_auth_class_names
242
+ return [] unless devise_detected?
243
+
244
+ ::Devise.mappings.values.map { |mapping| mapping.class_name.to_s }.uniq.sort
245
+ rescue StandardError
246
+ []
247
+ end
248
+
194
249
  def adopt_existing_table?
195
250
  return @adopt_existing_table if defined?(@adopt_existing_table)
196
251
 
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # v0.1.2 upgrade: adopted rows are keyed by owner+scope so clients that never
4
+ # persist Set-Cookie responses cannot create unbounded duplicate sessions.
5
+ # Existing adopted rows keep NULL keys; the adapter claims one lazily on the
6
+ # next request. Normal, non-adopted sessions also keep NULL, so this plain
7
+ # unique index behaves like a portable partial unique index across PostgreSQL,
8
+ # MySQL and SQLite.
9
+ class AddSessionsAdoptionKeyTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
10
+ def change
11
+ add_column :<%= table_name %>, :adoption_key, :string unless column_exists?(:<%= table_name %>, :adoption_key)
12
+ add_index :<%= table_name %>, :adoption_key, unique: true unless index_exists?(:<%= table_name %>, :adoption_key)
13
+ end
14
+ end
@@ -17,6 +17,12 @@ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%
17
17
  # The Warden scope ("user") — multi-scope Devise apps.
18
18
  t.string :scope
19
19
 
20
+ # Adopted sessions (pre-gem logins that had no sessions token yet)
21
+ # get one deterministic owner+scope key. Normal sessions keep this
22
+ # NULL, so the unique index below constrains only adopted rows while
23
+ # staying portable across PostgreSQL, MySQL and SQLite.
24
+ t.string :adoption_key
25
+
20
26
  # How this session started: password / oauth / google_one_tap /
21
27
  # passkey / magic_link / otp / sso / token / unknown — plus the
22
28
  # provider ("google", "apple", "github") and per-method extras
@@ -62,6 +68,7 @@ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%
62
68
 
63
69
  add_index :<%= table_name %>, :device_id
64
70
  add_index :<%= table_name %>, :token_digest, unique: true
71
+ add_index :<%= table_name %>, :adoption_key, unique: true
65
72
  add_index :<%= table_name %>, :auth_method
66
73
  add_index :<%= table_name %>, :auth_provider
67
74
  add_index :<%= table_name %>, :country_code
@@ -30,6 +30,12 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
30
30
  # The Warden scope ("user") — multi-scope Devise apps.
31
31
  t.string :scope
32
32
 
33
+ # Adopted sessions (pre-gem logins that had no sessions token yet)
34
+ # get one deterministic owner+scope key. Normal sessions keep this
35
+ # NULL, so the unique index below constrains only adopted rows while
36
+ # staying portable across PostgreSQL, MySQL and SQLite.
37
+ t.string :adoption_key
38
+
33
39
  # How this session started: password / oauth / google_one_tap /
34
40
  # passkey / magic_link / otp / sso / token / unknown — plus the
35
41
  # provider and per-method extras.
@@ -71,6 +77,7 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
71
77
 
72
78
  add_index :<%= table_name %>, :device_id
73
79
  add_index :<%= table_name %>, :token_digest, unique: true
80
+ add_index :<%= table_name %>, :adoption_key, unique: true
74
81
  add_index :<%= table_name %>, :auth_method
75
82
  add_index :<%= table_name %>, :auth_provider
76
83
  add_index :<%= table_name %>, :country_code
@@ -79,16 +86,26 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
79
86
 
80
87
  private
81
88
 
82
- # Honor the host's configured primary key type (uuid vs bigint). Reads the
83
- # same setting `rails g model` uses, so an app generated with
89
+ # Honor the host's configured primary key type (uuid, string/ULID, bigint).
90
+ # Reads the same setting `rails g model` uses, so an app generated with
84
91
  # `config.generators { |g| g.orm :active_record, primary_key_type: :uuid }`
85
92
  # gets uuid sessions tables and uuid foreign keys, automatically.
86
93
  def primary_and_foreign_key_types
87
94
  config = Rails.configuration.generators
88
95
  setting = config.options[config.orm][:primary_key_type]
89
- primary_key_type = setting || :primary_key
90
- foreign_key_type = setting || :bigint
91
- [ primary_key_type, foreign_key_type ]
96
+ [ setting || :primary_key, reference_column_type(setting) ]
97
+ end
98
+
99
+ # Reference columns hold VALUES of the PK type, so the serial pseudo-types
100
+ # map to their plain integer equivalents — a `t.bigserial` reference would
101
+ # mint its own auto-increment sequence, which is exactly wrong for a
102
+ # foreign key. Everything else (uuid, string ULIDs, …) passes through.
103
+ def reference_column_type(setting)
104
+ case setting
105
+ when nil, :bigserial then :bigint
106
+ when :serial then :integer
107
+ else setting
108
+ end
92
109
  end
93
110
 
94
111
  # match? (not equality): PostGIS apps report adapter_name "PostGIS" and
@@ -85,17 +85,26 @@ class CreateSessionsEvents < ActiveRecord::Migration<%= migration_version %>
85
85
  def primary_and_foreign_key_types
86
86
  config = Rails.configuration.generators
87
87
  setting = config.options[config.orm][:primary_key_type]
88
- primary_key_type = setting || :primary_key
89
- foreign_key_type = setting || :bigint
90
- [ primary_key_type, foreign_key_type ]
88
+ [ setting || :primary_key, reference_column_type(setting) ]
91
89
  end
92
90
 
93
91
  # session_id must match the sessions table's PRIMARY KEY type (uuid hosts
94
- # store uuid linkage; bigint hosts store bigint).
92
+ # store uuid linkage, string/ULID hosts string, bigint hosts bigint).
95
93
  def session_id_column_type
96
94
  config = Rails.configuration.generators
97
- setting = config.options[config.orm][:primary_key_type]
98
- setting == :uuid ? :uuid : :bigint
95
+ reference_column_type(config.options[config.orm][:primary_key_type])
96
+ end
97
+
98
+ # Reference columns hold VALUES of the PK type, so the serial pseudo-types
99
+ # map to their plain integer equivalents — a `t.bigserial` reference would
100
+ # mint its own auto-increment sequence, which is exactly wrong for a
101
+ # foreign key. Everything else (uuid, string ULIDs, …) passes through.
102
+ def reference_column_type(setting)
103
+ case setting
104
+ when nil, :bigserial then :bigint
105
+ when :serial then :integer
106
+ else setting
107
+ end
99
108
  end
100
109
 
101
110
  # match? (not equality): PostGIS apps report adapter_name "PostGIS" and
@@ -36,6 +36,7 @@ class SessionResource < Madmin::Resource
36
36
  # NEVER rendered: the token digest is a credential hash, and the raw
37
37
  # header blobs are noise next to the parsed columns above.
38
38
  attribute :token_digest, show: false, form: false
39
+ attribute :adoption_key, show: false, form: false
39
40
  attribute :auth_detail, show: false, form: false
40
41
  attribute :client_hints, show: false, form: false
41
42
 
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Sessions
7
+ module Generators
8
+ # `rails generate sessions:upgrade` — copies version-to-version
9
+ # migrations for apps that installed a prior release. The install
10
+ # generator remains the source for fresh apps; this generator gives
11
+ # existing hosts an explicit, reviewable upgrade path.
12
+ class UpgradeGenerator < Rails::Generators::Base
13
+ include ActiveRecord::Generators::Migration
14
+
15
+ source_root File.expand_path("templates", __dir__)
16
+ desc "Install sessions upgrade migrations"
17
+
18
+ class_option :model, type: :string, default: "Session",
19
+ desc: "Session model name (escape hatch for apps with a custom session class)"
20
+
21
+ def self.next_migration_number(dir)
22
+ ActiveRecord::Generators::Base.next_migration_number(dir)
23
+ end
24
+
25
+ def create_upgrade_migrations
26
+ migration_template "add_adoption_key_to_sessions.rb.erb",
27
+ File.join(db_migrate_path, "add_sessions_adoption_key_to_#{table_name}.rb")
28
+ end
29
+
30
+ def display_post_upgrade_message
31
+ say "\n🔐 sessions upgrade migrations have been installed.", :green
32
+ say "\nTo complete the upgrade:"
33
+ say " 1. Review the generated migration."
34
+ say " 2. Run 'rails db:migrate'."
35
+ say " ⚠️ Deploy the migration before relying on adoption hardening.", :yellow
36
+ end
37
+
38
+ private
39
+
40
+ def migration_version
41
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
42
+ end
43
+
44
+ def model_name
45
+ options[:model].presence || "Session"
46
+ end
47
+
48
+ def table_name
49
+ model_name.underscore.pluralize.tr("/", "_")
50
+ end
51
+ end
52
+ end
53
+ end
@@ -36,8 +36,9 @@ module Sessions
36
36
  SKIP_ENV_KEY = "sessions.skip" # = Sessions::SKIP_ENV_KEY (set by Sessions.skip!)
37
37
 
38
38
  # The `throw :warden` message on revoked sessions — Devise's failure
39
- # app surfaces it like :timeout/:session_limited (add a
40
- # `devise.failure.session_revoked` translation for custom copy).
39
+ # app surfaces it like :timeout/:session_limited. The gem SHIPS the
40
+ # `devise.failure.session_revoked` copy (en + es, config/locales/);
41
+ # hosts override that key for custom wording.
41
42
  THROW_MESSAGE = :session_revoked
42
43
 
43
44
  module_function
@@ -107,17 +108,22 @@ module Sessions
107
108
  end
108
109
  end
109
110
 
110
- def create_row_for(record, warden, scope, suppress_login_event: false)
111
+ def create_row_for(record, warden, scope, suppress_login_event: false, attributes: {})
111
112
  token = Sessions.generate_token
112
113
  request = warden.request
114
+ model = Sessions.session_model
113
115
 
114
- row = Sessions.session_model.new(
116
+ row = model.new(
115
117
  user: record,
116
118
  scope: scope.to_s,
117
119
  ip_address: Sessions::IpAddress.resolve(request),
118
120
  user_agent: request.user_agent,
119
121
  token_digest: Sessions.token_digest(token)
120
- )
122
+ ).tap do |session|
123
+ attributes.each do |column, value|
124
+ session[column] = value if model.column_names.include?(column.to_s)
125
+ end
126
+ end
121
127
  row.sessions_suppress_login_event = suppress_login_event
122
128
  Sessions.with_request(request) { row.save! }
123
129
 
@@ -185,11 +191,83 @@ module Sessions
185
191
  Sessions.safely("warden.adopt") do
186
192
  next unless row_accepts?(record)
187
193
 
188
- row = create_row_for(record, warden, scope, suppress_login_event: true)
189
- row&.update_columns(auth_detail: { "adopted" => true })
194
+ # IDEMPOTENT, because a client that can't persist cookies re-enters
195
+ # adoption on EVERY request: the SESSION_KEY we write rides a
196
+ # Set-Cookie the client drops, so the next request adopts again.
197
+ #
198
+ # Adoption is intentionally coarse: it is a low-fidelity marker for
199
+ # "this owner already had an authenticated session when the gem
200
+ # arrived", not a real login. One owner+scope marker is enough. Do
201
+ # not key it on UA (Hotwire Native devices legitimately use WebView
202
+ # and native-client UAs) or time (cookie-dropping clients would mint
203
+ # one per day forever). When the adoption_key column is present, the
204
+ # unique index makes the first concurrent burst atomic.
205
+ adoption_key = adoption_key_for(record, scope)
206
+ if (row = adopted_row(record, scope, adoption_key: adoption_key))
207
+ Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
208
+ next
209
+ end
210
+
211
+ create_adopted_row(record, warden, scope, adoption_key: adoption_key)
190
212
  end
191
213
  end
192
214
 
215
+ def create_adopted_row(record, warden, scope, adoption_key:)
216
+ attributes = { auth_detail: { "adopted" => true } }
217
+ attributes[:adoption_key] = adoption_key if adoption_key_column?
218
+
219
+ create_row_for(record, warden, scope, suppress_login_event: true, attributes: attributes)
220
+ rescue ActiveRecord::RecordNotUnique
221
+ adopted_row(record, scope, adoption_key: adoption_key)&.tap do |row|
222
+ Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
223
+ end
224
+ end
225
+
226
+ def adopted_row(record, scope, adoption_key:)
227
+ model = Sessions.session_model
228
+ rows = model.where(user: record)
229
+ rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
230
+
231
+ row = model.find_by(adoption_key: adoption_key) if adoption_key_column?(model) && adoption_key.present?
232
+ row = nil unless adopted_row?(row)
233
+ row ||= rows.order(created_at: :desc).detect { |candidate| adopted_row?(candidate) }
234
+
235
+ claim_adoption_key(row, adoption_key)
236
+ end
237
+
238
+ def adopted_row?(row)
239
+ row && row.try(:auth_detail).to_h["adopted"]
240
+ end
241
+
242
+ def claim_adoption_key(row, adoption_key)
243
+ return row unless row
244
+ return row unless adoption_key_column?(row.class)
245
+ return row if adoption_key.blank? || row.try(:adoption_key).present?
246
+
247
+ row.update_columns(adoption_key: adoption_key)
248
+ row.adoption_key = adoption_key
249
+ row
250
+ rescue ActiveRecord::RecordNotUnique
251
+ row.class.find_by(adoption_key: adoption_key) || row
252
+ end
253
+
254
+ def adoption_key_for(record, scope)
255
+ owner_id = record.respond_to?(:to_key) ? Array(record.to_key).join("/") : record.try(:id)
256
+ return if owner_id.blank?
257
+
258
+ owner_type = if record.class.respond_to?(:polymorphic_name)
259
+ record.class.polymorphic_name
260
+ else
261
+ record.class.name
262
+ end
263
+
264
+ "adopt:#{Sessions.token_digest([owner_type, owner_id, scope.to_s].join("\0"))}"
265
+ end
266
+
267
+ def adoption_key_column?(model = Sessions.session_model)
268
+ model.column_names.include?("adoption_key")
269
+ end
270
+
193
271
  # SCOPE-PRECISE teardown: only this scope's warden entries go (the
194
272
  # serialized user key and our token stash) — an admin scope riding
195
273
  # the same rack session, and unrelated host session data (carts,
@@ -225,7 +303,9 @@ module Sessions
225
303
  credentials = request.params[scope.to_s]
226
304
  next unless credentials.is_a?(Hash)
227
305
 
228
- identity = credentials.values_at("email", "login", "username", "phone").compact.first
306
+ # `email_address` included: it's the omakase-era key, and Devise
307
+ # apps configure `authentication_keys = [:email_address]` too.
308
+ identity = credentials.values_at("email", "email_address", "login", "username", "phone").compact.first
229
309
 
230
310
  Sessions::Event.record_failure(
231
311
  request,
@@ -153,7 +153,13 @@ module Sessions
153
153
  end
154
154
 
155
155
  def method_for_strategy(strategy_name)
156
- Sessions.config.strategy_methods.merge(STRATEGY_METHODS).each do |substring, method|
156
+ # Host entries are consulted FIRST (Hash#merge keeps the receiver's
157
+ # keys in front, so config substrings win the iteration order) and the
158
+ # block makes them WIN on a shared key too — a bare `merge` would let
159
+ # the built-in value silently clobber a host override of, say,
160
+ # "Rememberable".
161
+ mappings = Sessions.config.strategy_methods.merge(STRATEGY_METHODS) { |_key, custom, _builtin| custom }
162
+ mappings.each do |substring, method|
157
163
  return method if strategy_name.include?(substring)
158
164
  end
159
165
  nil
@@ -335,7 +335,14 @@ module Sessions
335
335
 
336
336
  def sessions_record_login
337
337
  Sessions.safely("record_login") do
338
- next if sessions_suppress_login_event
338
+ if sessions_suppress_login_event
339
+ # Suppressed writes (adoption) skip the trail event, dedup and
340
+ # the new-device hook — but never the cap: it's the hard limit on
341
+ # LIVE rows, and a misbehaving client looping through adoption
342
+ # must hit it like everyone else.
343
+ sessions_enforce_cap!
344
+ next
345
+ end
339
346
 
340
347
  # Same browser signing in again (abandoned session, expired
341
348
  # remember-me, browser update — anything) replaces its old row
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sessions
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sessions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-12 00:00:00.000000000 Z
10
+ date: 2026-06-16 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: actionpack
@@ -163,6 +163,7 @@ files:
163
163
  - gemfiles/rails_8.1.gemfile
164
164
  - lib/generators/sessions/install_generator.rb
165
165
  - lib/generators/sessions/madmin_generator.rb
166
+ - lib/generators/sessions/templates/add_adoption_key_to_sessions.rb.erb
166
167
  - lib/generators/sessions/templates/add_sessions_columns.rb.erb
167
168
  - lib/generators/sessions/templates/create_sessions.rb.erb
168
169
  - lib/generators/sessions/templates/create_sessions_events.rb.erb
@@ -173,6 +174,7 @@ files:
173
174
  - lib/generators/sessions/templates/madmin/sessions_controller.rb
174
175
  - lib/generators/sessions/templates/session.rb.erb
175
176
  - lib/generators/sessions/templates/sessions_sweep_job.rb
177
+ - lib/generators/sessions/upgrade_generator.rb
176
178
  - lib/generators/sessions/views_generator.rb
177
179
  - lib/sessions.rb
178
180
  - lib/sessions/adapters/omakase.rb