sessions 0.1.0

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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +61 -0
  3. data/.simplecov +54 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +26 -0
  6. data/CHANGELOG.md +26 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +454 -0
  10. data/Rakefile +32 -0
  11. data/app/assets/stylesheets/sessions.css +50 -0
  12. data/app/controllers/sessions/application_controller.rb +159 -0
  13. data/app/controllers/sessions/devices_controller.rb +48 -0
  14. data/app/helpers/sessions/engine_helper.rb +126 -0
  15. data/app/views/sessions/_device.html.erb +40 -0
  16. data/app/views/sessions/_devices.html.erb +34 -0
  17. data/app/views/sessions/_event.html.erb +13 -0
  18. data/app/views/sessions/_history.html.erb +20 -0
  19. data/app/views/sessions/devices/history.html.erb +5 -0
  20. data/app/views/sessions/devices/index.html.erb +15 -0
  21. data/config/locales/en.yml +59 -0
  22. data/config/locales/es.yml +59 -0
  23. data/config/routes.rb +17 -0
  24. data/docs/PRD.md +743 -0
  25. data/docs/research/01-carhey.md +250 -0
  26. data/docs/research/02-ecosystem.md +261 -0
  27. data/docs/research/03-rails-core.md +220 -0
  28. data/docs/research/04-devise-warden.md +249 -0
  29. data/docs/research/05-oauth.md +193 -0
  30. data/docs/research/06-prior-art.md +312 -0
  31. data/docs/research/07-device-detection.md +250 -0
  32. data/docs/research/08-rails8-landscape.md +216 -0
  33. data/docs/research/09-market-security.md +450 -0
  34. data/gemfiles/rails_7.1.gemfile +34 -0
  35. data/gemfiles/rails_7.2.gemfile +34 -0
  36. data/gemfiles/rails_8.0.gemfile +34 -0
  37. data/gemfiles/rails_8.1.gemfile +34 -0
  38. data/lib/generators/sessions/install_generator.rb +230 -0
  39. data/lib/generators/sessions/madmin_generator.rb +95 -0
  40. data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +81 -0
  41. data/lib/generators/sessions/templates/create_sessions.rb.erb +101 -0
  42. data/lib/generators/sessions/templates/create_sessions_events.rb.erb +108 -0
  43. data/lib/generators/sessions/templates/initializer.rb +201 -0
  44. data/lib/generators/sessions/templates/madmin/event_resource.rb +77 -0
  45. data/lib/generators/sessions/templates/madmin/session_events_controller.rb +21 -0
  46. data/lib/generators/sessions/templates/madmin/session_resource.rb +65 -0
  47. data/lib/generators/sessions/templates/madmin/sessions_controller.rb +30 -0
  48. data/lib/generators/sessions/templates/session.rb.erb +14 -0
  49. data/lib/generators/sessions/templates/sessions_sweep_job.rb +20 -0
  50. data/lib/generators/sessions/views_generator.rb +33 -0
  51. data/lib/sessions/adapters/omakase.rb +195 -0
  52. data/lib/sessions/adapters/omniauth.rb +64 -0
  53. data/lib/sessions/adapters/warden.rb +293 -0
  54. data/lib/sessions/classifier.rb +208 -0
  55. data/lib/sessions/configuration.rb +441 -0
  56. data/lib/sessions/current.rb +20 -0
  57. data/lib/sessions/device.rb +411 -0
  58. data/lib/sessions/engine.rb +120 -0
  59. data/lib/sessions/errors.rb +24 -0
  60. data/lib/sessions/geolocation.rb +111 -0
  61. data/lib/sessions/ip_address.rb +56 -0
  62. data/lib/sessions/jobs/geolocate_job.rb +58 -0
  63. data/lib/sessions/macros.rb +26 -0
  64. data/lib/sessions/middleware.rb +41 -0
  65. data/lib/sessions/models/concerns/device_display.rb +134 -0
  66. data/lib/sessions/models/concerns/has_sessions.rb +116 -0
  67. data/lib/sessions/models/concerns/model.rb +513 -0
  68. data/lib/sessions/models/event.rb +293 -0
  69. data/lib/sessions/version.rb +5 -0
  70. data/lib/sessions.rb +423 -0
  71. metadata +225 -0
@@ -0,0 +1,513 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # The registry concern — included into the host's session-of-record model
5
+ # (`Session`). On Rails 8 omakase apps the adapter includes it
6
+ # automatically at boot (the generated 2-line model stays untouched); in
7
+ # Devise mode the install generator writes a 3-line shell that includes it
8
+ # explicitly. Either way, ALL gem logic lives here, so the host's model
9
+ # file never goes stale.
10
+ #
11
+ # One mental model: **rows = active sessions; events = history.** A row is
12
+ # destroyed on logout/revocation/expiry (instant remote revocation — the
13
+ # same omakase semantics as Rails 8.1's own password-reset destroy_all),
14
+ # and its tombstone lives in the `sessions_events` trail.
15
+ #
16
+ # The three lifecycle callbacks observe 100% of both adapters' flows:
17
+ #
18
+ # before_create — enrich the row: normalize the IP, parse the
19
+ # device, capture client hints, classify the auth
20
+ # method, geolocate (when free).
21
+ # after_create_commit — write the `login` event, detect new devices,
22
+ # enforce the per-user cap, enqueue async geo.
23
+ # after_destroy_commit — write the `logout`/`revoked`/`expired` event.
24
+ #
25
+ # Every callback body is error-isolated: a parsing/geo/event failure may
26
+ # lose a log row; it may NEVER break a sign-in.
27
+ module Model
28
+ extend ActiveSupport::Concern
29
+
30
+ # device_name / location / country_flag / native predicates /
31
+ # auth_method_label — shared with Sessions::Event, which carries the
32
+ # same parsed device columns.
33
+ include Sessions::DeviceDisplay
34
+
35
+ # How long without activity before a session is grouped as "inactive"
36
+ # on the devices page (UI grouping only — never enforcement; expiry is
37
+ # the opt-in idle_timeout/max_session_lifetime pair).
38
+ INACTIVE_AFTER = 30.days
39
+
40
+ included do
41
+ # The Devise-mode shell model needs the association declared; the
42
+ # omakase host model already has `belongs_to :user` and is left
43
+ # untouched. Polymorphic when the table was generated with
44
+ # --polymorphic (detected by the user_type column).
45
+ unless reflect_on_association(:user)
46
+ if Sessions::Model.polymorphic_table?(self)
47
+ belongs_to :user, polymorphic: true
48
+ else
49
+ belongs_to :user
50
+ end
51
+ end
52
+
53
+ # Transient revocation context: set by `revoke!` (and the adapters'
54
+ # logout labeling) before the row is destroyed, read by the
55
+ # after_destroy_commit event writer.
56
+ attr_accessor :revocation_reason, :revoked_by
57
+
58
+ # Set on adopted rows (sessions that predate the gem) so adoption
59
+ # doesn't fabricate a `login` event in the trail.
60
+ attr_accessor :sessions_suppress_login_event
61
+
62
+ before_create :sessions_enrich
63
+ after_create_commit :sessions_record_login
64
+ after_destroy_commit :sessions_record_end
65
+
66
+ scope :by_recency, lambda {
67
+ # COALESCE keeps never-touched sessions ordered by creation time,
68
+ # portably across sqlite/postgres/mysql.
69
+ order(Arel.sql("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) DESC"))
70
+ }
71
+ scope :active, lambda {
72
+ where("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) >= ?", INACTIVE_AFTER.ago)
73
+ }
74
+ scope :inactive, lambda {
75
+ where("COALESCE(#{table_name}.last_seen_at, #{table_name}.created_at) < ?", INACTIVE_AFTER.ago)
76
+ }
77
+ end
78
+
79
+ class_methods do
80
+ # The user-facing trail rows linked to this registry (`session_id` is
81
+ # a plain column, no FK — history must survive row destruction).
82
+ def sessions_events_for(session_id)
83
+ Sessions::Event.where(session_id: session_id)
84
+ end
85
+ end
86
+
87
+ def self.polymorphic_table?(klass)
88
+ klass.table_exists? && klass.column_names.include?("user_type")
89
+ rescue StandardError
90
+ false
91
+ end
92
+
93
+ # --- The candy ---------------------------------------------------------------
94
+ #
95
+ # device_name / location / country_flag / hotwire_native? / web? /
96
+ # via_oauth? / auth_method_label … live in Sessions::DeviceDisplay.
97
+
98
+ # The moment of last activity: the throttled touch when present, else
99
+ # sign-in time.
100
+ def last_active_at
101
+ try(:last_seen_at) || created_at
102
+ end
103
+
104
+ # "Active now" — activity within the touch window. The window IS
105
+ # config.touch_every (last_seen_at lags by up to one throttle window by
106
+ # design), so the devices-page badge stays truthful whatever the host
107
+ # configured.
108
+ def active_now?(window = Sessions.config.touch_every || 5.minutes)
109
+ activity = last_active_at
110
+ activity.present? && activity > window.ago
111
+ end
112
+
113
+ # Whether this row is the one serving +request+ — powers the
114
+ # "This device" badge (which is also the row the devices page refuses
115
+ # to revoke).
116
+ def current?(request = Sessions::Current.request)
117
+ return false unless request
118
+
119
+ Sessions.current(request) == self
120
+ end
121
+
122
+ # Stamp the second factor onto an ALREADY-LIVE session — the affordance
123
+ # for step-up flows where the row exists before the challenge completes
124
+ # (a post-login TOTP gate, a WebAuthn step-up before sensitive areas):
125
+ #
126
+ # Sessions.current(request)&.second_factor!("totp")
127
+ #
128
+ # Flows that verify the second factor BEFORE the session exists
129
+ # (devise-two-factor, authentication-zero's challenge controllers,
130
+ # devise-otp) don't need this — they classify at login via the strategy
131
+ # map or a `Sessions.tag` call (see the README's two-factor recipes).
132
+ # Reading happens through `second_factor` / `second_factor?`.
133
+ def second_factor!(kind)
134
+ detail = (try(:auth_detail) || {}).to_h
135
+ update!(auth_detail: detail.merge("second_factor" => kind.to_s))
136
+ end
137
+
138
+ # --- Revocation -----------------------------------------------------------
139
+
140
+ # Destroy this session — remote logout, effective on that device's very
141
+ # next request (both adapters validate liveness per request). Writes a
142
+ # `revoked` (or `expired`) event with the reason and actor, rotates the
143
+ # user's remember-me credentials in Devise mode (config.revoke_remember_me),
144
+ # and fires the on_session_revoked hook.
145
+ def revoke!(reason: :user_revoked, by: nil)
146
+ self.revocation_reason = reason
147
+ self.revoked_by = by
148
+ destroy!
149
+
150
+ Sessions.safely("revoke_remember_me") { sessions_forget_remember_me! } if Sessions.config.revoke_remember_me
151
+ Sessions.safely("on_session_revoked hook") do
152
+ Sessions.config.on_session_revoked.call(session: self, by: by, reason: reason)
153
+ end
154
+
155
+ self
156
+ end
157
+
158
+ # --- Lifecycle plumbing (used by the adapters) -------------------------------
159
+
160
+ # Opt-in expiry — false unless the host configured timeouts.
161
+ def sessions_expired?(now = Time.current)
162
+ config = Sessions.config
163
+ activity = last_active_at
164
+ return true if config.idle_timeout && activity && activity < now - config.idle_timeout
165
+ return true if config.max_session_lifetime && created_at && created_at < now - config.max_session_lifetime
166
+
167
+ false
168
+ end
169
+
170
+ # The throttled last-seen touch: at most one write per
171
+ # config.touch_every per session, issued as a single conditional UPDATE
172
+ # (hot-row-safe under concurrent requests, callback-free, and it also
173
+ # moves updated_at — which finally makes the Rails security guide's own
174
+ # `Session.sweep` recommendation implementable).
175
+ def touch_last_seen!(request = nil)
176
+ every = Sessions.config.touch_every
177
+ return false unless every
178
+ return false unless sessions_column?("last_seen_at")
179
+
180
+ now = Time.current
181
+ threshold = now - every
182
+ return false if last_seen_at && last_seen_at > threshold
183
+
184
+ updates = { last_seen_at: now, updated_at: now }
185
+ if sessions_column?("last_seen_ip") && request && (ip = Sessions::IpAddress.resolve(request))
186
+ updates[:last_seen_ip] = ip
187
+ end
188
+
189
+ updated = self.class.where(id: id)
190
+ .where("last_seen_at IS NULL OR last_seen_at <= ?", threshold)
191
+ .update_all(updates)
192
+
193
+ if updated.positive?
194
+ updates.each { |column, value| self[column] = value }
195
+ clear_attribute_changes(updates.keys)
196
+ true
197
+ else
198
+ # Another request won the race — refresh our throttle window so this
199
+ # instance doesn't retry.
200
+ self[:last_seen_at] = now
201
+ clear_attribute_changes([:last_seen_at])
202
+ false
203
+ end
204
+ end
205
+
206
+ # Constant-time token check (Devise mode). Omakase rows store no token
207
+ # (the signed cookie is the credential) and never match.
208
+ def sessions_token_matches?(token)
209
+ digest = try(:token_digest)
210
+ return false if digest.blank? || token.blank?
211
+
212
+ ActiveSupport::SecurityUtils.secure_compare(digest, Sessions.token_digest(token))
213
+ end
214
+
215
+ private
216
+
217
+ # --- before_create: enrichment ------------------------------------------------
218
+
219
+ def sessions_enrich
220
+ Sessions.safely("enrich") do
221
+ request = Sessions::Current.request
222
+
223
+ sessions_enrich_ip(request)
224
+ sessions_enrich_device(request)
225
+ sessions_enrich_device_id(request)
226
+ sessions_enrich_auth(request)
227
+ sessions_enrich_geo(request)
228
+ sessions_clamp_oversized_strings
229
+ end
230
+
231
+ true # never halt the host's save chain
232
+ end
233
+
234
+ # Rails 8's authentication generator creates `user_agent` as a plain
235
+ # string — VARCHAR(255) on MySQL — and real native UAs (app prefix +
236
+ # WebView UA + Hotwire markers) routinely overflow it, turning a login
237
+ # into ActiveRecord::ValueTooLong under MySQL's strict mode. The gem's
238
+ # own tables use text, but on ADOPTED tables we clamp every string
239
+ # column to its limit AFTER parsing (the parsers saw the full value;
240
+ # only storage is bounded). Tracking never breaks login — and here,
241
+ # login itself would have broken without us.
242
+ def sessions_clamp_oversized_strings
243
+ self.class.columns_hash.each do |name, column|
244
+ next unless column.type == :string && column.limit
245
+ next unless (value = self[name]).is_a?(String) && value.length > column.limit
246
+
247
+ self[name] = value[0, column.limit]
248
+ end
249
+ end
250
+
251
+ def sessions_enrich_ip(request)
252
+ return unless sessions_column?("ip_address")
253
+
254
+ # Normalize (and truncate, per config.ip_mode) whatever the host
255
+ # captured; resolve from the request when nothing was set (Devise
256
+ # mode sets it explicitly; omakase's start_new_session_for already
257
+ # did). Garbage that doesn't parse as an IP is dropped.
258
+ self.ip_address = Sessions::IpAddress.normalize(ip_address) if ip_address.present?
259
+ self.ip_address ||= Sessions::IpAddress.resolve(request) if request
260
+ end
261
+
262
+ def sessions_enrich_device(request)
263
+ ua = (user_agent.presence if sessions_column?("user_agent")) || request&.user_agent
264
+ headers = Sessions::Device.headers_from(request)
265
+
266
+ device = Sessions::Device.parse(ua, headers: headers)
267
+ sessions_assign(device.to_h)
268
+ sessions_assign(client_hints: headers) if headers.any?
269
+ end
270
+
271
+ # Browser continuity: a signed, long-lived, random cookie identifying
272
+ # the BROWSER INSTALL (never the user — it carries no identity and is
273
+ # worthless as a credential; it only lets two logins from the same
274
+ # browser collapse into one device row). Minted ONLY at login — no
275
+ # pre-login tracking cookie ever. Cookie unavailable (bare rack stacks,
276
+ # tests without key material)? The row simply has no device_id and
277
+ # nothing dedupes — degraded, never broken.
278
+ def sessions_enrich_device_id(request)
279
+ return unless sessions_column?("device_id")
280
+ return if device_id.present?
281
+ return unless request.respond_to?(:cookie_jar)
282
+
283
+ jar = request.cookie_jar.signed
284
+ continuity = jar[Sessions::DEVICE_COOKIE]
285
+ if continuity.blank?
286
+ continuity = SecureRandom.uuid
287
+ jar[Sessions::DEVICE_COOKIE] = {
288
+ value: continuity,
289
+ expires: 5.years,
290
+ httponly: true,
291
+ same_site: :lax
292
+ }
293
+ end
294
+
295
+ self.device_id = continuity.to_s[0, 36]
296
+ rescue StandardError => e
297
+ Sessions.warn("device continuity cookie unavailable: #{e.class}: #{e.message}")
298
+ end
299
+
300
+ def sessions_enrich_auth(request)
301
+ auth = Sessions::Classifier.classify(request)
302
+ sessions_assign(auth_method: auth[:method], auth_provider: auth[:provider])
303
+ sessions_assign(auth_detail: auth[:detail]) if auth[:detail].present?
304
+ end
305
+
306
+ def sessions_enrich_geo(request)
307
+ return unless sessions_column?("country_code")
308
+ return if country_code.present? || ip_address.blank?
309
+ # Synchronous only when it's free (Cloudflare already answered in
310
+ # request headers); otherwise the GeolocateJob enriches after commit.
311
+ return unless Sessions::Geolocation.cloudflare_headers?(request)
312
+
313
+ sessions_assign(Sessions::Geolocation.locate(ip_address, request: request))
314
+ end
315
+
316
+ # Tolerant assignment: only columns that exist, never overwriting
317
+ # host-set values — hosts can drop columns without gem releases.
318
+ def sessions_assign(attributes)
319
+ attributes.each do |column, value|
320
+ name = column.to_s
321
+ next unless sessions_column?(name)
322
+ next if self[name].present?
323
+
324
+ self[name] = value
325
+ end
326
+ end
327
+
328
+ def sessions_column?(name)
329
+ self.class.column_names.include?(name.to_s)
330
+ rescue StandardError
331
+ false
332
+ end
333
+
334
+ # --- after_create_commit: the login event ------------------------------------
335
+
336
+ def sessions_record_login
337
+ Sessions.safely("record_login") do
338
+ next if sessions_suppress_login_event
339
+
340
+ # Same browser signing in again (abandoned session, expired
341
+ # remember-me, browser update — anything) replaces its old row
342
+ # instead of stacking a duplicate device. Runs BEFORE new-device
343
+ # detection on purpose: the trail (which survives the superseded
344
+ # row) is what remembers known devices, so dedup never causes
345
+ # false "new device" alerts.
346
+ Sessions.safely("supersede") { sessions_supersede_previous_rows! }
347
+
348
+ new_device = Sessions.safely("new_device") { sessions_new_device? } || false
349
+
350
+ event = Sessions::Event.record!(
351
+ sessions_event_identity_attributes.merge(
352
+ event: "login",
353
+ auth_method: try(:auth_method),
354
+ auth_provider: try(:auth_provider),
355
+ auth_detail: try(:auth_detail).presence,
356
+ # The browser-continuity id rides the trail too: it's what lets
357
+ # Sessions.last_login answer "how did this browser last sign
358
+ # in" AFTER logout destroys the row (the "Last used" badge).
359
+ device_id: try(:device_id).presence,
360
+ metadata: new_device ? { "new_device" => true } : nil
361
+ )
362
+ )
363
+
364
+ Sessions::Geolocation.enqueue(self) if try(:country_code).blank?
365
+ sessions_enforce_cap!
366
+
367
+ if new_device && event
368
+ Sessions.safely("on_new_device hook") do
369
+ Sessions.config.on_new_device.call(user: user, session: self, event: event)
370
+ end
371
+ end
372
+ end
373
+ end
374
+
375
+ # A login is a NEW DEVICE when no prior session or login event for this
376
+ # user matches on (device_type, os_name, browser/app identity) —
377
+ # deliberately coarse, server-observed-only matching; never
378
+ # fingerprinting. A user's very first login is NOT a new device (nobody
379
+ # wants a "was this you?" email on signup).
380
+ def sessions_new_device?
381
+ return false unless user
382
+ return false if try(:device_type).blank? || try(:device_type) == "unknown"
383
+
384
+ match = { device_type: try(:device_type), os_name: try(:os_name),
385
+ browser_name: try(:browser_name), app_name: try(:app_name) }
386
+
387
+ prior_sessions = self.class.where(user: user).where.not(id: id)
388
+ prior_events = Sessions::Event.logins.where(authenticatable: user)
389
+ return false unless prior_sessions.exists? || prior_events.exists?
390
+
391
+ !prior_sessions.where(match).exists? && !prior_events.where(match).exists?
392
+ end
393
+
394
+ # The dedup half of browser continuity: prior live rows for the SAME
395
+ # user on the SAME browser install are superseded by this login. A
396
+ # quiet destroy on purpose — no on_session_revoked hook, no
397
+ # remember-me rotation (this is housekeeping, not a security event;
398
+ # the trail records it as revoked/:superseded). Scoped to the user:
399
+ # a shared computer with two accounts keeps both rows.
400
+ def sessions_supersede_previous_rows!
401
+ return if try(:device_id).blank?
402
+
403
+ self.class.where(user: user, device_id: device_id).where.not(id: id).each do |row|
404
+ row.revocation_reason = :superseded
405
+ row.destroy
406
+ end
407
+ end
408
+
409
+ # GitLab-style per-user cap: evict the OLDEST sessions beyond
410
+ # config.max_sessions_per_user (the freshly created row is the newest,
411
+ # so it always survives).
412
+ def sessions_enforce_cap!
413
+ cap = Sessions.config.max_sessions_per_user
414
+ return unless cap
415
+
416
+ siblings = self.class.where(user: user)
417
+ overflow = siblings.count - cap
418
+ return unless overflow.positive?
419
+
420
+ siblings.where.not(id: id).order(created_at: :asc).limit(overflow).each do |session|
421
+ session.revoke!(reason: :pruned)
422
+ end
423
+ end
424
+
425
+ # --- after_destroy_commit: the end-of-session event ---------------------------
426
+
427
+ def sessions_record_end
428
+ Sessions.safely("record_end") do
429
+ # Account deletion: dependent-destroyed rows of a destroyed owner
430
+ # write no events (their trail is erased with them — GDPR default).
431
+ next if user.nil? || user.destroyed?
432
+
433
+ reason = revocation_reason&.to_sym
434
+ event_name = case reason
435
+ when :logout then "logout"
436
+ when :expired then "expired"
437
+ else "revoked"
438
+ end
439
+
440
+ Sessions::Event.record!(
441
+ sessions_event_identity_attributes.merge(
442
+ event: event_name,
443
+ revoked_reason: (event_name == "revoked" ? (reason || :unknown) : nil),
444
+ metadata: sessions_revoked_by_metadata
445
+ )
446
+ )
447
+ end
448
+ end
449
+
450
+ def sessions_revoked_by_metadata
451
+ return nil unless revoked_by
452
+
453
+ label = if revoked_by.respond_to?(:id) && revoked_by.class.respond_to?(:name)
454
+ "#{revoked_by.class.name}##{revoked_by.id}"
455
+ else
456
+ revoked_by.to_s
457
+ end
458
+ { "revoked_by" => label }
459
+ end
460
+
461
+ # The row's identity, copied onto every event it produces — the trail
462
+ # must describe the device even after the row is gone.
463
+ def sessions_event_identity_attributes
464
+ {
465
+ session_id: id,
466
+ authenticatable: user,
467
+ scope: try(:scope),
468
+ ip_address: (ip_address if sessions_column?("ip_address")),
469
+ user_agent: (user_agent if sessions_column?("user_agent")),
470
+ client_hints: try(:client_hints).presence,
471
+ browser_name: try(:browser_name),
472
+ browser_version: try(:browser_version),
473
+ os_name: try(:os_name),
474
+ os_version: try(:os_version),
475
+ device_type: try(:device_type),
476
+ device_model: try(:device_model),
477
+ app_name: try(:app_name),
478
+ app_version: try(:app_version),
479
+ country_code: try(:country_code),
480
+ country_name: try(:country_name),
481
+ city: try(:city),
482
+ region: try(:region),
483
+ request_id: sessions_request_id,
484
+ context: sessions_request_context
485
+ }
486
+ end
487
+
488
+ # Plain-Warden stacks hand us a Rack::Request (Devise upgrades it to
489
+ # ActionDispatch); neither request_id nor path_parameters can be assumed.
490
+ def sessions_request_id
491
+ request = Sessions::Current.request
492
+ request.request_id if request.respond_to?(:request_id)
493
+ rescue StandardError
494
+ nil
495
+ end
496
+
497
+ def sessions_request_context
498
+ request = Sessions::Current.request
499
+ return nil unless request.respond_to?(:path_parameters)
500
+
501
+ params = request.path_parameters
502
+ return nil unless params && params[:controller]
503
+
504
+ "#{params[:controller]}##{params[:action]}"
505
+ rescue StandardError
506
+ nil
507
+ end
508
+
509
+ def sessions_forget_remember_me!
510
+ user.forget_me! if user.respond_to?(:forget_me!)
511
+ end
512
+ end
513
+ end