sessions 0.1.2 → 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: f088a81ac84fec5df0bfd521c3d4012fa6a91a4a84e22e997e22d18ef1658b1b
4
- data.tar.gz: d50021247992ad1bef65b9a4d5f678146e774501b6adaa0af87840ec1e8ce988
3
+ metadata.gz: 2c9417a89572ea4f860d64648e8d085b9074dd4914d3078dbdf6e116c5087d10
4
+ data.tar.gz: fda35041ba991b3dbfd4c4789eedd16f17c42764805f6fba633e956920796e6b
5
5
  SHA512:
6
- metadata.gz: ab013f9e59218a51e6f580deaa8d1bf01e45a4fd22eded95d91e1d4e2ba434d433bceedddf06d349dcfdfa63fd2b3678c8aaa9742729353da439e6e01778115e
7
- data.tar.gz: 10eabd4758d01e1cf0ed44df351f8182dfe27c80f4fec7ee2024f0500da199a1ca356c77c72d444efface0c56f091f9d1312c9d46a2c2bd80ea77a6115fe94a9
6
+ metadata.gz: 428832edcb252f835b89a351f30459e9a884a288622d988df050356e5cd4322eafb70f070f9ae91b418451bdd4adfad111c2ab984eb9e2fe86950651266b7a23
7
+ data.tar.gz: d56946f36346faa79f2c27c12bd69fa954018425ba85d852e98abce8b69c79d472f973c71a73e33a324598f1ddd5bb1ec058c5a5a6d34778748d0c4fc7f03111
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## 0.1.2 (2026-06-16)
4
16
 
5
17
  Production-found hardening for Devise/Warden adoption, the path that turns already-authenticated pre-gem sessions into registry rows.
data/README.md CHANGED
@@ -98,14 +98,14 @@ That's it. Every sign-in from now on lands on the devices page and in the trail
98
98
 
99
99
  ### Upgrading
100
100
 
101
- Existing apps upgrading from 0.1.0 or 0.1.1 should copy the upgrade migration and run it:
101
+ Existing apps upgrading from 0.1.0, 0.1.1, or 0.1.2 should copy the upgrade migrations and run them:
102
102
 
103
103
  ```bash
104
104
  rails generate sessions:upgrade
105
105
  rails db:migrate
106
106
  ```
107
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`.
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
109
 
110
110
  ## What `sessions` does (and doesn't) do
111
111
 
@@ -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
@@ -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,
@@ -25,6 +25,8 @@ module Sessions
25
25
  def create_upgrade_migrations
26
26
  migration_template "add_adoption_key_to_sessions.rb.erb",
27
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")
28
30
  end
29
31
 
30
32
  def display_post_upgrade_message
@@ -32,7 +34,7 @@ module Sessions
32
34
  say "\nTo complete the upgrade:"
33
35
  say " 1. Review the generated migration."
34
36
  say " 2. Run 'rails db:migrate'."
35
- say " ⚠️ Deploy the migration before relying on adoption hardening.", :yellow
37
+ say " ⚠️ Deploy the migrations before relying on adoption hardening or app-build event columns.", :yellow
36
38
  end
37
39
 
38
40
  private
@@ -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,11 +109,23 @@ 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, attributes: {})
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
114
131
  model = Sessions.session_model
@@ -125,6 +142,7 @@ module Sessions
125
142
  end
126
143
  end
127
144
  row.sessions_suppress_login_event = suppress_login_event
145
+ row.sessions_skip_supersede = skip_supersede
128
146
  Sessions.with_request(request) { row.save! }
129
147
 
130
148
  warden.session(scope)[SESSION_KEY] = [row.id, token]
@@ -143,12 +161,20 @@ module Sessions
143
161
  session_data = warden.session(scope)
144
162
  next :skip if session_data[SKIP_SESSION_KEY]
145
163
 
146
- session_data[SESSION_KEY]
164
+ {
165
+ login: session_data[SESSION_KEY],
166
+ pending_login: session_data[PENDING_LOGIN_KEY]
167
+ }
147
168
  end
148
169
  return if data == :skip
149
170
 
150
- if data.nil?
151
- 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
152
178
  return
153
179
  end
154
180
 
@@ -160,9 +186,13 @@ module Sessions
160
186
  # never break authentication: fail OPEN, let the request through
161
187
  # untracked, try again next request.
162
188
  begin
163
- id, token = data
189
+ id, token = session_token
164
190
  found = Sessions.session_model.find_by(id: id)
165
- 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
166
196
  rescue StandardError => e
167
197
  Sessions.warn("warden.fetch failed open: #{e.class}: #{e.message}")
168
198
  return
@@ -190,6 +220,7 @@ module Sessions
190
220
  def adopt_preexisting_session(record, warden, scope)
191
221
  Sessions.safely("warden.adopt") do
192
222
  next unless row_accepts?(record)
223
+ next unless document_request?(warden.request)
193
224
 
194
225
  # IDEMPOTENT, because a client that can't persist cookies re-enters
195
226
  # adoption on EVERY request: the SESSION_KEY we write rides a
@@ -212,11 +243,141 @@ module Sessions
212
243
  end
213
244
  end
214
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
260
+ end
261
+ end
262
+
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
+
215
376
  def create_adopted_row(record, warden, scope, adoption_key:)
216
377
  attributes = { auth_detail: { "adopted" => true } }
217
378
  attributes[:adoption_key] = adoption_key if adoption_key_column?
218
379
 
219
- create_row_for(record, warden, scope, suppress_login_event: true, attributes: attributes)
380
+ create_row_for(record, warden, scope, suppress_login_event: true, skip_supersede: true, attributes: attributes)
220
381
  rescue ActiveRecord::RecordNotUnique
221
382
  adopted_row(record, scope, adoption_key: adoption_key)&.tap do |row|
222
383
  Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
@@ -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.2"
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.2
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-16 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
@@ -164,6 +164,7 @@ files:
164
164
  - lib/generators/sessions/install_generator.rb
165
165
  - lib/generators/sessions/madmin_generator.rb
166
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
167
168
  - lib/generators/sessions/templates/add_sessions_columns.rb.erb
168
169
  - lib/generators/sessions/templates/create_sessions.rb.erb
169
170
  - lib/generators/sessions/templates/create_sessions_events.rb.erb