sessions 0.1.1 → 0.1.3

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: 7a11c0aa8a096b0cc19e25a62967da2414cdf8732a2587cdd9a23ed7d989fd67
4
- data.tar.gz: ea2e6a1fa8a96771886c86e74d6dfa391fffa4138e825006511222dd221652a4
3
+ metadata.gz: 2c9417a89572ea4f860d64648e8d085b9074dd4914d3078dbdf6e116c5087d10
4
+ data.tar.gz: fda35041ba991b3dbfd4c4789eedd16f17c42764805f6fba633e956920796e6b
5
5
  SHA512:
6
- metadata.gz: d4aa37c48143f477dc820e85b8a44158682f4f40991411bb382af74fefd20d0001050dd2e7324830cea73e4edc459e252f3762aaea034bd4386bacda488f088c
7
- data.tar.gz: d29228d847cb0acd7e6b348a872de1ae988afdbe5e70c69509f24a41aa18992109f57ba2964cddc66a08a6f09802cbdfe9a55d1a8d9fc4e6d01caeb92d9db6a8
6
+ metadata.gz: 428832edcb252f835b89a351f30459e9a884a288622d988df050356e5cd4322eafb70f070f9ae91b418451bdd4adfad111c2ab984eb9e2fe86950651266b7a23
7
+ data.tar.gz: d56946f36346faa79f2c27c12bd69fa954018425ba85d852e98abce8b69c79d472f973c71a73e33a324598f1ddd5bb1ec058c5a5a6d34778748d0c4fc7f03111
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.3 (2026-06-21)
4
+
5
+ Production-found hardening for Hotwire Native / Devise remember-me startup flows, plus compatibility and generated admin-schema fixes.
6
+
7
+ - **Remembered Warden logins on non-document requests are deferred until the first document navigation.** A native iOS path-configuration fetch like `/native/configurations/ios/v1.json` can carry the remember-me cookie before the WebView entry page loads; the gem now keeps that login pending instead of recording a misleading `RailsFast/... CFNetwork/...` row/event, then records the real WebView/native device on the HTML request with the original `remembered` auth detail.
8
+ - **Remembered restores for an already-live device now reattach to the existing row.** iOS WebView startup bursts can run several remembered document requests before the app settles; if the signed device cookie already names a live row for the same user/scope, the gem reuses it, writes no duplicate login event, and validates the tokenless Warden session against that same signed device cookie on later fetches. A destroyed row still kicks normally.
9
+ - **Internal same-device superseding is quiet housekeeping again.** Replacing an abandoned same-browser row no longer writes a user-facing `revoked` / `superseded` event; remote revocations, password-change revocations, expiry and logout still write their normal trail entries.
10
+ - **Pre-gem adoption also waits for a document request.** Existing authenticated sessions no longer get adopted from background JSON/native HTTP requests before the actual browser/WebView request can name the device.
11
+ - **Trackdown integration duck-types optional fields.** `sessions` now tolerates older `trackdown` releases that do not expose `region` or coordinate methods, preserving valid country/city data instead of swallowing the whole geo result.
12
+ - **`sessions_events.app_build` is now part of the trail schema.** Fresh installs get the column, login/logout/revocation events copy it from the registry row, and `rails generate sessions:upgrade` adds it for existing installs so the generated Madmin resource matches the database.
13
+ - **Password-bearing non-idempotent requests classify as password logins.** This covers Devise password-reset/update flows that sign the user in with `PATCH` instead of `POST`, avoiding misleading `auth_method: "unknown"` rows.
14
+
15
+ ## 0.1.2 (2026-06-16)
16
+
17
+ Production-found hardening for Devise/Warden adoption, the path that turns already-authenticated pre-gem sessions into registry rows.
18
+
19
+ - **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.
20
+ - **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.
21
+ - **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`.
22
+ - **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.
23
+
3
24
  ## 0.1.1 (2026-06-12)
4
25
 
5
26
  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.
data/README.md CHANGED
@@ -96,6 +96,17 @@ That's it. Every sign-in from now on lands on the devices page and in the trail
96
96
  >
97
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
98
 
99
+ ### Upgrading
100
+
101
+ Existing apps upgrading from 0.1.0, 0.1.1, or 0.1.2 should copy the upgrade migrations and run them:
102
+
103
+ ```bash
104
+ rails generate sessions:upgrade
105
+ rails db:migrate
106
+ ```
107
+
108
+ This adds `sessions.adoption_key` (for installs before 0.1.2), the portable unique guard that makes pre-gem session adoption atomic under concurrent native-app request bursts, and `sessions_events.app_build` (for installs before 0.1.3), which keeps the event trail and generated madmin resource in sync. Fresh installs already get both from `sessions:install`.
109
+
99
110
  ## What `sessions` does (and doesn't) do
100
111
 
101
112
  **Does:**
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # v0.1.3 upgrade: events mirror the registry's native app build so generated
4
+ # madmin resources never reference a missing sessions_events.app_build column.
5
+ class AddSessionsAppBuildToSessionsEvents < ActiveRecord::Migration<%= migration_version %>
6
+ def change
7
+ add_column :sessions_events, :app_build, :string unless column_exists?(:sessions_events, :app_build)
8
+ end
9
+ 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
@@ -52,6 +52,7 @@ class CreateSessionsEvents < ActiveRecord::Migration<%= migration_version %>
52
52
  t.string :device_model
53
53
  t.string :app_name
54
54
  t.string :app_version
55
+ t.string :app_build
55
56
 
56
57
  # Geo via trackdown (soft dependency). lat/lng precision-reduced per
57
58
  # config.geo_precision (2 decimals ≈ 1km) — privacy now,
@@ -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,55 @@
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
+ migration_template "add_app_build_to_sessions_events.rb.erb",
29
+ File.join(db_migrate_path, "add_sessions_app_build_to_sessions_events.rb")
30
+ end
31
+
32
+ def display_post_upgrade_message
33
+ say "\n🔐 sessions upgrade migrations have been installed.", :green
34
+ say "\nTo complete the upgrade:"
35
+ say " 1. Review the generated migration."
36
+ say " 2. Run 'rails db:migrate'."
37
+ say " ⚠️ Deploy the migrations before relying on adoption hardening or app-build event columns.", :yellow
38
+ end
39
+
40
+ private
41
+
42
+ def migration_version
43
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
44
+ end
45
+
46
+ def model_name
47
+ options[:model].presence || "Session"
48
+ end
49
+
50
+ def table_name
51
+ model_name.underscore.pluralize.tr("/", "_")
52
+ end
53
+ end
54
+ end
55
+ end
@@ -27,6 +27,11 @@ module Sessions
27
27
  # Key inside `warden.session(scope)` holding [row_id, raw_token].
28
28
  SESSION_KEY = "sessions"
29
29
 
30
+ # Rememberable can restore a user on background/native JSON requests
31
+ # before the browser/WebView has actually navigated. Defer the row
32
+ # until a document request can name the user-visible device.
33
+ PENDING_LOGIN_KEY = "sessions.pending_login"
34
+
30
35
  # Sticky per-scope flag: a login recorded with `sessions_skip: true`
31
36
  # must not be kicked by the fetch validation later (session_limitable's
32
37
  # third skip layer).
@@ -104,22 +109,40 @@ module Sessions
104
109
 
105
110
  next unless row_accepts?(record)
106
111
 
112
+ auth = Sessions::Classifier.classify(warden.request)
113
+ if deferred_login_request?(warden.request, auth)
114
+ stash_pending_login(warden, scope, auth)
115
+ next
116
+ end
117
+
118
+ if (row = remembered_existing_row(record, warden, scope, auth))
119
+ attach_existing_row(row, warden, scope)
120
+ next
121
+ end
122
+
123
+ warden.session(scope).delete(PENDING_LOGIN_KEY)
107
124
  create_row_for(record, warden, scope)
108
125
  end
109
126
  end
110
127
 
111
- def create_row_for(record, warden, scope, suppress_login_event: false)
128
+ def create_row_for(record, warden, scope, suppress_login_event: false, skip_supersede: false, attributes: {})
112
129
  token = Sessions.generate_token
113
130
  request = warden.request
131
+ model = Sessions.session_model
114
132
 
115
- row = Sessions.session_model.new(
133
+ row = model.new(
116
134
  user: record,
117
135
  scope: scope.to_s,
118
136
  ip_address: Sessions::IpAddress.resolve(request),
119
137
  user_agent: request.user_agent,
120
138
  token_digest: Sessions.token_digest(token)
121
- )
139
+ ).tap do |session|
140
+ attributes.each do |column, value|
141
+ session[column] = value if model.column_names.include?(column.to_s)
142
+ end
143
+ end
122
144
  row.sessions_suppress_login_event = suppress_login_event
145
+ row.sessions_skip_supersede = skip_supersede
123
146
  Sessions.with_request(request) { row.save! }
124
147
 
125
148
  warden.session(scope)[SESSION_KEY] = [row.id, token]
@@ -138,12 +161,20 @@ module Sessions
138
161
  session_data = warden.session(scope)
139
162
  next :skip if session_data[SKIP_SESSION_KEY]
140
163
 
141
- session_data[SESSION_KEY]
164
+ {
165
+ login: session_data[SESSION_KEY],
166
+ pending_login: session_data[PENDING_LOGIN_KEY]
167
+ }
142
168
  end
143
169
  return if data == :skip
144
170
 
145
- if data.nil?
146
- adopt_preexisting_session(record, warden, scope)
171
+ session_token = data && data[:login]
172
+ if session_token.nil?
173
+ if data && data[:pending_login]
174
+ activate_pending_login(record, warden, scope, data[:pending_login])
175
+ else
176
+ adopt_preexisting_session(record, warden, scope)
177
+ end
147
178
  return
148
179
  end
149
180
 
@@ -155,9 +186,13 @@ module Sessions
155
186
  # never break authentication: fail OPEN, let the request through
156
187
  # untracked, try again next request.
157
188
  begin
158
- id, token = data
189
+ id, token = session_token
159
190
  found = Sessions.session_model.find_by(id: id)
160
- row = found if found&.sessions_token_matches?(token)
191
+ row = if found&.sessions_token_matches?(token)
192
+ found
193
+ elsif token.nil? && existing_row_session?(found, record, scope, warden.request)
194
+ found
195
+ end
161
196
  rescue StandardError => e
162
197
  Sessions.warn("warden.fetch failed open: #{e.class}: #{e.message}")
163
198
  return
@@ -185,37 +220,213 @@ module Sessions
185
220
  def adopt_preexisting_session(record, warden, scope)
186
221
  Sessions.safely("warden.adopt") do
187
222
  next unless row_accepts?(record)
223
+ next unless document_request?(warden.request)
188
224
 
189
225
  # IDEMPOTENT, because a client that can't persist cookies re-enters
190
226
  # adoption on EVERY request: the SESSION_KEY we write rides a
191
- # Set-Cookie the client drops, so the next request adopts again
192
- # unbounded rows (production-found: a native HTTP layer that
193
- # forwarded cookies read-only minted one adopted row per location
194
- # ping, hundreds per ride). Same user, same scope, same UA,
195
- # recently adopted that's this same client: touch it, mint
196
- # nothing. No token rotation either a sibling client sharing the
197
- # cookie jar (the app's WebView next to its native HTTP stack) may
198
- # hold a VALID key to this row, and rotating would kick it.
199
- if (row = recent_adopted_row(record, warden, scope))
227
+ # Set-Cookie the client drops, so the next request adopts again.
228
+ #
229
+ # Adoption is intentionally coarse: it is a low-fidelity marker for
230
+ # "this owner already had an authenticated session when the gem
231
+ # arrived", not a real login. One owner+scope marker is enough. Do
232
+ # not key it on UA (Hotwire Native devices legitimately use WebView
233
+ # and native-client UAs) or time (cookie-dropping clients would mint
234
+ # one per day forever). When the adoption_key column is present, the
235
+ # unique index makes the first concurrent burst atomic.
236
+ adoption_key = adoption_key_for(record, scope)
237
+ if (row = adopted_row(record, scope, adoption_key: adoption_key))
200
238
  Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
201
239
  next
202
240
  end
203
241
 
204
- row = create_row_for(record, warden, scope, suppress_login_event: true)
205
- row&.update_columns(auth_detail: { "adopted" => true })
242
+ create_adopted_row(record, warden, scope, adoption_key: adoption_key)
243
+ end
244
+ end
245
+
246
+ def activate_pending_login(record, warden, scope, pending_login)
247
+ Sessions.safely("warden.pending_login") do
248
+ next unless row_accepts?(record)
249
+ next unless document_request?(warden.request)
250
+
251
+ if (row = pending_existing_row(record, warden, scope, pending_login))
252
+ attach_existing_row(row, warden, scope)
253
+ warden.session(scope).delete(PENDING_LOGIN_KEY)
254
+ next row
255
+ end
256
+
257
+ row = create_row_for(record, warden, scope, attributes: pending_login_attributes(pending_login))
258
+ warden.session(scope).delete(PENDING_LOGIN_KEY)
259
+ row
206
260
  end
207
261
  end
208
262
 
209
- def recent_adopted_row(record, warden, scope)
263
+ def deferred_login_request?(request, auth)
264
+ remembered_login?(auth) && !document_request?(request)
265
+ end
266
+
267
+ def remembered_login?(auth)
268
+ detail = auth[:detail].to_h
269
+ detail["remembered"] || detail[:remembered]
270
+ rescue StandardError
271
+ false
272
+ end
273
+
274
+ def stash_pending_login(warden, scope, auth)
275
+ warden.session(scope)[PENDING_LOGIN_KEY] = login_auth_attributes(auth).transform_keys(&:to_s)
276
+ end
277
+
278
+ def login_auth_attributes(auth)
279
+ {
280
+ auth_method: auth[:method],
281
+ auth_provider: auth[:provider],
282
+ auth_detail: auth[:detail].presence
283
+ }.compact
284
+ end
285
+
286
+ def pending_login_attributes(attributes)
287
+ attributes.to_h.slice("auth_method", "auth_provider", "auth_detail")
288
+ rescue StandardError
289
+ {}
290
+ end
291
+
292
+ def pending_existing_row(record, warden, scope, pending_login)
293
+ auth = { detail: pending_login.to_h["auth_detail"] || {} }
294
+ remembered_existing_row(record, warden, scope, auth)
295
+ end
296
+
297
+ def remembered_existing_row(record, warden, scope, auth)
298
+ return unless remembered_login?(auth)
299
+
300
+ device_id = device_id_from_request(warden.request)
301
+ return if device_id.blank?
302
+
303
+ rows = Sessions.session_model.where(user: record, device_id: device_id)
304
+ rows = rows.where(scope: scope.to_s) if Sessions.session_model.column_names.include?("scope")
305
+ rows.order(created_at: :desc).first
306
+ rescue StandardError
307
+ nil
308
+ end
309
+
310
+ def attach_existing_row(row, warden, scope)
311
+ warden.session(scope)[SESSION_KEY] = [row.id, nil]
312
+ Sessions.safely("warden.remembered_existing.touch") { row.touch_last_seen!(warden.request) }
313
+ row
314
+ end
315
+
316
+ def existing_row_session?(row, record, scope, request)
317
+ return false unless row
318
+
319
+ device_id = device_id_from_request(request)
320
+ return false if device_id.blank?
321
+ return false unless row.try(:device_id) == device_id
322
+ return false unless row.user == record
323
+ return false if row.respond_to?(:scope) && row.scope.present? && row.scope != scope.to_s
324
+
325
+ true
326
+ rescue StandardError
327
+ false
328
+ end
329
+
330
+ def device_id_from_request(request)
331
+ return unless request.respond_to?(:cookie_jar)
332
+
333
+ request.cookie_jar.signed[Sessions::DEVICE_COOKIE].presence
334
+ rescue StandardError
335
+ nil
336
+ end
337
+
338
+ def document_request?(request)
339
+ return true unless request
340
+ return false if non_document_path?(request)
341
+
342
+ accept = request_header(request, "HTTP_ACCEPT").to_s
343
+ return true if accept.empty? || accept == "*/*"
344
+ return true if accept.match?(%r{\btext/html\b|\bapplication/xhtml\+xml\b|\btext/vnd\.turbo-stream\.html\b})
345
+ return false if accept.match?(%r{\b(?:application|text)/(?:[\w.+-]+\+)?json\b})
346
+ return false if request_header(request, "HTTP_X_REQUESTED_WITH").to_s.casecmp("XMLHttpRequest").zero?
347
+
348
+ if request.respond_to?(:format)
349
+ format = request.format
350
+ return true if format.respond_to?(:html?) && format.html?
351
+ return false if format.respond_to?(:json?) && format.json?
352
+ end
353
+
354
+ true
355
+ rescue StandardError
356
+ true
357
+ end
358
+
359
+ def non_document_path?(request)
360
+ path = if request.respond_to?(:path)
361
+ request.path
362
+ elsif request.respond_to?(:path_info)
363
+ request.path_info
364
+ end
365
+ File.extname(path.to_s).delete(".").casecmp("json").zero?
366
+ end
367
+
368
+ def request_header(request, key)
369
+ if request.respond_to?(:get_header)
370
+ request.get_header(key)
371
+ elsif request.respond_to?(:env)
372
+ request.env[key]
373
+ end
374
+ end
375
+
376
+ def create_adopted_row(record, warden, scope, adoption_key:)
377
+ attributes = { auth_detail: { "adopted" => true } }
378
+ attributes[:adoption_key] = adoption_key if adoption_key_column?
379
+
380
+ create_row_for(record, warden, scope, suppress_login_event: true, skip_supersede: true, attributes: attributes)
381
+ rescue ActiveRecord::RecordNotUnique
382
+ adopted_row(record, scope, adoption_key: adoption_key)&.tap do |row|
383
+ Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
384
+ end
385
+ end
386
+
387
+ def adopted_row(record, scope, adoption_key:)
210
388
  model = Sessions.session_model
211
389
  rows = model.where(user: record)
212
390
  rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
213
- rows = rows.where(user_agent: warden.request.user_agent) if model.column_names.include?("user_agent")
214
391
 
215
- rows.where(model.arel_table[:created_at].gt(24.hours.ago))
216
- .order(created_at: :desc)
217
- .limit(10)
218
- .detect { |row| row.try(:auth_detail).to_h["adopted"] }
392
+ row = model.find_by(adoption_key: adoption_key) if adoption_key_column?(model) && adoption_key.present?
393
+ row = nil unless adopted_row?(row)
394
+ row ||= rows.order(created_at: :desc).detect { |candidate| adopted_row?(candidate) }
395
+
396
+ claim_adoption_key(row, adoption_key)
397
+ end
398
+
399
+ def adopted_row?(row)
400
+ row && row.try(:auth_detail).to_h["adopted"]
401
+ end
402
+
403
+ def claim_adoption_key(row, adoption_key)
404
+ return row unless row
405
+ return row unless adoption_key_column?(row.class)
406
+ return row if adoption_key.blank? || row.try(:adoption_key).present?
407
+
408
+ row.update_columns(adoption_key: adoption_key)
409
+ row.adoption_key = adoption_key
410
+ row
411
+ rescue ActiveRecord::RecordNotUnique
412
+ row.class.find_by(adoption_key: adoption_key) || row
413
+ end
414
+
415
+ def adoption_key_for(record, scope)
416
+ owner_id = record.respond_to?(:to_key) ? Array(record.to_key).join("/") : record.try(:id)
417
+ return if owner_id.blank?
418
+
419
+ owner_type = if record.class.respond_to?(:polymorphic_name)
420
+ record.class.polymorphic_name
421
+ else
422
+ record.class.name
423
+ end
424
+
425
+ "adopt:#{Sessions.token_digest([owner_type, owner_id, scope.to_s].join("\0"))}"
426
+ end
427
+
428
+ def adoption_key_column?(model = Sessions.session_model)
429
+ model.column_names.include?("adoption_key")
219
430
  end
220
431
 
221
432
  # SCOPE-PRECISE teardown: only this scope's warden entries go (the
@@ -77,7 +77,7 @@ module Sessions
77
77
  from_omniauth(request) ||
78
78
  from_warden(request) ||
79
79
  from_google_sign_in(request) ||
80
- from_password_post(request) ||
80
+ from_password_request(request) ||
81
81
  blank
82
82
  rescue StandardError => e
83
83
  Sessions.warn("auth classification failed: #{e.class}: #{e.message}")
@@ -176,10 +176,11 @@ module Sessions
176
176
  nil
177
177
  end
178
178
 
179
- # A POST that exchanged a password for a session IS a password login —
180
- # covers the omakase SessionsController and hand-rolled password forms.
181
- def from_password_post(request)
182
- return unless request.respond_to?(:post?) && request.post?
179
+ # A non-idempotent request that exchanged a password for a session IS a
180
+ # password login — covers the omakase SessionsController, hand-rolled
181
+ # password forms, and Devise password-reset PATCHes that sign the user in.
182
+ def from_password_request(request)
183
+ return unless credential_request?(request)
183
184
 
184
185
  params = request.params
185
186
  return unless params.is_a?(Hash) || params.respond_to?(:[])
@@ -190,6 +191,18 @@ module Sessions
190
191
  nil
191
192
  end
192
193
 
194
+ def credential_request?(request)
195
+ method = if request.respond_to?(:request_method)
196
+ request.request_method
197
+ elsif request.respond_to?(:method)
198
+ request.method
199
+ end
200
+
201
+ !%w[GET HEAD OPTIONS].include?(method.to_s.upcase)
202
+ rescue StandardError
203
+ false
204
+ end
205
+
193
206
  def password_param?(params)
194
207
  return true if params["password"].present?
195
208
 
@@ -79,13 +79,15 @@ module Sessions
79
79
 
80
80
  def columns_from(result)
81
81
  return {} unless result
82
- return {} if result.country_code.to_s.empty?
82
+
83
+ country_code = location_value(result, :country_code)
84
+ return {} if country_code.to_s.empty?
83
85
 
84
86
  {
85
- country_code: result.country_code,
86
- country_name: presence(result.country_name),
87
- city: presence(result.city),
88
- region: presence(result.region)
87
+ country_code: country_code,
88
+ country_name: presence(location_value(result, :country_name)),
89
+ city: presence(location_value(result, :city)),
90
+ region: presence(location_value(result, :region))
89
91
  }.compact
90
92
  end
91
93
 
@@ -93,17 +95,25 @@ module Sessions
93
95
  # config.geo_precision (2 decimals ≈ 1km — privacy now,
94
96
  # impossible-travel math later).
95
97
  def coordinates_from(result)
96
- return {} unless result.respond_to?(:latitude) && result.latitude
98
+ latitude = location_value(result, :latitude)
99
+ longitude = location_value(result, :longitude)
100
+ return {} if latitude.nil? || longitude.nil?
97
101
 
98
102
  precision = Sessions.config.geo_precision
99
103
  {
100
- latitude: result.latitude.to_f.round(precision),
101
- longitude: result.longitude.to_f.round(precision)
104
+ latitude: latitude.to_f.round(precision),
105
+ longitude: longitude.to_f.round(precision)
102
106
  }
103
107
  rescue StandardError
104
108
  {}
105
109
  end
106
110
 
111
+ def location_value(result, attribute)
112
+ result.public_send(attribute) if result.respond_to?(attribute)
113
+ rescue StandardError
114
+ nil
115
+ end
116
+
107
117
  def presence(value)
108
118
  value.nil? || value.to_s.empty? || value.to_s == "Unknown" ? nil : value
109
119
  end
@@ -57,7 +57,7 @@ module Sessions
57
57
 
58
58
  # Set on adopted rows (sessions that predate the gem) so adoption
59
59
  # doesn't fabricate a `login` event in the trail.
60
- attr_accessor :sessions_suppress_login_event
60
+ attr_accessor :sessions_suppress_login_event, :sessions_skip_supersede
61
61
 
62
62
  before_create :sessions_enrich
63
63
  after_create_commit :sessions_record_login
@@ -335,22 +335,22 @@ module Sessions
335
335
 
336
336
  def sessions_record_login
337
337
  Sessions.safely("record_login") do
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
346
-
347
338
  # Same browser signing in again (abandoned session, expired
348
339
  # remember-me, browser update — anything) replaces its old row
349
340
  # instead of stacking a duplicate device. Runs BEFORE new-device
350
341
  # detection on purpose: the trail (which survives the superseded
351
342
  # row) is what remembers known devices, so dedup never causes
352
343
  # false "new device" alerts.
353
- Sessions.safely("supersede") { sessions_supersede_previous_rows! }
344
+ Sessions.safely("supersede") { sessions_supersede_previous_rows! } unless sessions_skip_supersede
345
+
346
+ if sessions_suppress_login_event
347
+ # Suppressed writes skip the trail event and the new-device hook
348
+ # — but never the cap: it's the hard limit on LIVE rows, and a
349
+ # misbehaving client looping through adoption/remembered refreshes
350
+ # must hit it like everyone else.
351
+ sessions_enforce_cap!
352
+ next
353
+ end
354
354
 
355
355
  new_device = Sessions.safely("new_device") { sessions_new_device? } || false
356
356
 
@@ -401,9 +401,9 @@ module Sessions
401
401
  # The dedup half of browser continuity: prior live rows for the SAME
402
402
  # user on the SAME browser install are superseded by this login. A
403
403
  # quiet destroy on purpose — no on_session_revoked hook, no
404
- # remember-me rotation (this is housekeeping, not a security event;
405
- # the trail records it as revoked/:superseded). Scoped to the user:
406
- # a shared computer with two accounts keeps both rows.
404
+ # remember-me rotation, no trail event (this is housekeeping, not a
405
+ # security event). Scoped to the user: a shared computer with two
406
+ # accounts keeps both rows.
407
407
  def sessions_supersede_previous_rows!
408
408
  return if try(:device_id).blank?
409
409
 
@@ -438,6 +438,8 @@ module Sessions
438
438
  next if user.nil? || user.destroyed?
439
439
 
440
440
  reason = revocation_reason&.to_sym
441
+ next if reason == :superseded
442
+
441
443
  event_name = case reason
442
444
  when :logout then "logout"
443
445
  when :expired then "expired"
@@ -483,6 +485,7 @@ module Sessions
483
485
  device_model: try(:device_model),
484
486
  app_name: try(:app_name),
485
487
  app_version: try(:app_version),
488
+ app_build: try(:app_build),
486
489
  country_code: try(:country_code),
487
490
  country_name: try(:country_name),
488
491
  city: try(:city),
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sessions
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
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.1
4
+ version: 0.1.3
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-21 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: actionpack
@@ -163,6 +163,8 @@ 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
167
+ - lib/generators/sessions/templates/add_app_build_to_sessions_events.rb.erb
166
168
  - lib/generators/sessions/templates/add_sessions_columns.rb.erb
167
169
  - lib/generators/sessions/templates/create_sessions.rb.erb
168
170
  - lib/generators/sessions/templates/create_sessions_events.rb.erb
@@ -173,6 +175,7 @@ files:
173
175
  - lib/generators/sessions/templates/madmin/sessions_controller.rb
174
176
  - lib/generators/sessions/templates/session.rb.erb
175
177
  - lib/generators/sessions/templates/sessions_sweep_job.rb
178
+ - lib/generators/sessions/upgrade_generator.rb
176
179
  - lib/generators/sessions/views_generator.rb
177
180
  - lib/sessions.rb
178
181
  - lib/sessions/adapters/omakase.rb