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
data/lib/sessions.rb ADDED
@@ -0,0 +1,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "securerandom"
5
+
6
+ require "active_support"
7
+ require "active_support/core_ext/integer/time"
8
+ require "active_support/core_ext/object/blank"
9
+ require "active_support/core_ext/object/try"
10
+ require "active_support/core_ext/enumerable"
11
+ require "active_support/security_utils"
12
+
13
+ require_relative "sessions/version"
14
+ require_relative "sessions/errors"
15
+ require_relative "sessions/configuration"
16
+ require_relative "sessions/current"
17
+ require_relative "sessions/ip_address"
18
+ require_relative "sessions/device"
19
+ require_relative "sessions/classifier"
20
+ require_relative "sessions/geolocation"
21
+ require_relative "sessions/middleware"
22
+ require_relative "sessions/macros"
23
+ require_relative "sessions/adapters/omakase"
24
+ require_relative "sessions/adapters/warden"
25
+ require_relative "sessions/adapters/omniauth"
26
+
27
+ require_relative "sessions/engine" if defined?(::Rails::Engine)
28
+
29
+ # == Sessions
30
+ #
31
+ # Every session, every device, every login — tracked, revocable, visible.
32
+ # The missing session layer for Rails.
33
+ #
34
+ # The public surface is intentionally tiny:
35
+ #
36
+ # Sessions.configure { |config| ... } # one block, in an initializer
37
+ # has_sessions # on your auth model
38
+ #
39
+ # current_user.sessions.active # live devices
40
+ # session.device_name # => "Chrome on macOS"
41
+ # session.revoke! # remote logout, effective next request
42
+ # current_user.revoke_other_sessions! # GitHub's "sign out everywhere else"
43
+ # current_user.session_history.failed_logins # the trail, identity-matched failures included
44
+ #
45
+ # Plus a handful of request-side seams for flows that can't self-identify:
46
+ #
47
+ # Sessions.tag(request, method: :passkey) # label the upcoming login
48
+ # Sessions.skip!(request) # "neither a login nor a failure" (2FA handoffs)
49
+ # Sessions.current(request) # this request's session row
50
+ # Sessions.last_login(request) # how this browser last signed in ("Last used" badge)
51
+ # Sessions.record_failed_attempt(request, identity: params[:email], reason: :invalid_password)
52
+ # Sessions.track_login(user, request, method: :sso)
53
+ #
54
+ # Everything else (adapters, the devices page, the sweep) ships with the
55
+ # engine and stays out of your way. One rule above all: tracking NEVER
56
+ # breaks authentication — every recording path in this gem is
57
+ # error-isolated (see Sessions.safely).
58
+ module Sessions
59
+ # The signed browser-continuity cookie (see Sessions::Model — minted at
60
+ # login, identifies the browser install so repeat logins replace their
61
+ # old device row instead of stacking duplicates).
62
+ DEVICE_COOKIE = :sessions_device_id
63
+
64
+ # The rack env flag `Sessions.skip!` sets — every recording seam checks
65
+ # it before writing anything for the request.
66
+ SKIP_ENV_KEY = "sessions.skip"
67
+
68
+ class << self
69
+ # --- Configuration --------------------------------------------------------
70
+
71
+ def config
72
+ @config ||= Configuration.new
73
+ end
74
+
75
+ alias configuration config
76
+
77
+ def configure
78
+ yield config if block_given?
79
+ config.validate!
80
+ config
81
+ end
82
+
83
+ # Reset all global state. Used by the test suite to keep examples
84
+ # isolated; also handy in a console when experimenting.
85
+ def reset!
86
+ @config = Configuration.new
87
+ self
88
+ end
89
+
90
+ # The host's session-of-record model (`Session` on both supported
91
+ # stacks; `config.session_class` is the escape hatch).
92
+ def session_model
93
+ config.session_model
94
+ end
95
+
96
+ # --- Request-side API -----------------------------------------------------
97
+
98
+ # Label the login that's about to happen on this request — for flows
99
+ # that can't self-identify at the session-row level (Google One Tap,
100
+ # passkeys, magic links, custom SSO). Call it BEFORE signing the user
101
+ # in; the classification pipeline gives explicit tags top priority.
102
+ #
103
+ # Sessions.tag(request, method: :google_one_tap, detail: { select_by: params[:select_by] })
104
+ # Sessions.tag(request, method: :passkey, detail: { user_verified: true })
105
+ def tag(request, method:, provider: nil, detail: {})
106
+ return unless request
107
+
108
+ request.env[Classifier::TAG_ENV_KEY] = { method: method, provider: provider, detail: detail }
109
+ request
110
+ end
111
+
112
+ # Silence tracking for THIS request — the escape hatch for flows that
113
+ # intentionally end with neither a session nor a failure. The canonical
114
+ # case is the password phase of a two-phase 2FA challenge
115
+ # (authentication-zero's --two-factor, hand-rolled TOTP gates): the
116
+ # password was RIGHT, the controller redirects to the challenge, and
117
+ # recording a failed_login there would be a lie:
118
+ #
119
+ # if user.otp_required_for_sign_in?
120
+ # Sessions.skip!(request)
121
+ # session[:challenge_token] = user.signed_id(...)
122
+ # redirect_to new_two_factor_authentication_challenge_totp_path
123
+ # end
124
+ #
125
+ # Honored by every recording seam (both adapters, the failed-login
126
+ # heuristics). One request only — the challenge completion records
127
+ # normally.
128
+ def skip!(request)
129
+ return unless request
130
+
131
+ request.env[SKIP_ENV_KEY] = true
132
+ request
133
+ end
134
+
135
+ # The registry row for this request — works on both adapters:
136
+ # omakase (Current.session / the signed session cookie) and
137
+ # Devise/Warden (the per-scope token stashed in the warden session).
138
+ # Returns nil when the request carries no live tracked session.
139
+ #
140
+ # Multi-scope Devise apps (user + admin signed in on one rack session)
141
+ # carry one tracked row per scope; pass `scope:` to pick — without it
142
+ # you get the first live row found, which is unambiguous for the
143
+ # single-scope majority:
144
+ #
145
+ # Sessions.current(request, scope: :admin_user)
146
+ def current(request = Sessions::Current.request, scope: nil)
147
+ return nil unless request
148
+
149
+ safely("current") do
150
+ if scope
151
+ # An explicit scope is a Warden concept — answering it from
152
+ # Current.session (the omakase shortcut) would ignore it.
153
+ warden_current(request, scope: scope)
154
+ else
155
+ omakase_current(request) || warden_current(request) || cookie_current(request)
156
+ end
157
+ end
158
+ end
159
+
160
+ # The most recent login EVENT from THIS BROWSER — works on the login
161
+ # page, signed out, because the browser-continuity cookie (the same one
162
+ # that deduplicates devices) survives logout by design. This is the
163
+ # one-lookup answer behind the "Last used" badge next to your sign-in
164
+ # buttons:
165
+ #
166
+ # <% if (last = Sessions.last_login(request))&.auth_provider == "google" %>
167
+ # <span class="badge">Last used</span>
168
+ # <% end %>
169
+ #
170
+ # The event carries auth_method / auth_provider / auth_method_label /
171
+ # occurred_at ("last used 2 days ago"). Device-scoped, not
172
+ # account-scoped: it reflects whoever last signed in from this browser
173
+ # — exactly what a login page can honestly know. Returns nil for
174
+ # browsers that never signed in, cleared cookies, or tampered values
175
+ # (the cookie is signed). Read-only: never mints the cookie.
176
+ def last_login(request)
177
+ return nil unless request.respond_to?(:cookie_jar)
178
+
179
+ safely("last_login") do
180
+ device_id = request.cookie_jar.signed[DEVICE_COOKIE]
181
+ next nil if device_id.blank?
182
+
183
+ Sessions::Event.logins.where(device_id: device_id.to_s[0, 36])
184
+ .order(occurred_at: :desc).first
185
+ end
186
+ end
187
+
188
+ # Record a failed login attempt from a custom controller — the manual
189
+ # seam for flows outside Warden's failure app and the omakase
190
+ # SessionsController (a native-app sign-in branch, passkey
191
+ # verification rescues, One Tap token errors…).
192
+ #
193
+ # Sessions.record_failed_attempt(request, scope: :user,
194
+ # identity: params[:email],
195
+ # reason: :invalid_password)
196
+ #
197
+ # Never raises; returns the Sessions::Event or nil.
198
+ def record_failed_attempt(request, scope: nil, identity: nil, reason: nil,
199
+ method: nil, provider: nil, detail: {}, metadata: {})
200
+ return nil unless config.track_failed_logins
201
+
202
+ safely("record_failed_attempt") do
203
+ tag(request, method: method, provider: provider, detail: detail) if method
204
+
205
+ Sessions::Event.record_failure(
206
+ request,
207
+ scope: scope,
208
+ identity: identity,
209
+ reason: reason,
210
+ metadata: metadata
211
+ )
212
+ end
213
+ end
214
+
215
+ # Fully manual integration: create (and classify, parse, geolocate) a
216
+ # registry row + login event for +user+ outside any adapter. The host
217
+ # owns linking the returned row to its own session mechanism and
218
+ # enforcing revocation. Never raises; returns the session row or nil.
219
+ def track_login(user, request, method: nil, provider: nil, detail: {})
220
+ safely("track_login") do
221
+ tag(request, method: method, provider: provider, detail: detail) if method
222
+
223
+ with_request(request) do
224
+ session_model.create!(
225
+ user: user,
226
+ ip_address: IpAddress.resolve(request),
227
+ user_agent: request&.user_agent
228
+ )
229
+ end
230
+ end
231
+ end
232
+
233
+ # --- Lifecycle ------------------------------------------------------------
234
+
235
+ # The maintenance pass the generated SessionsSweepJob runs on a
236
+ # schedule: expire idle/over-age sessions (only when timeouts are
237
+ # configured), evict per-user overflow beyond the session cap, and
238
+ # purge trail rows past retention. Each part is independently
239
+ # error-isolated. Returns a Hash of counts.
240
+ def sweep!
241
+ {
242
+ expired: safely("sweep.expired") { sweep_expired_sessions! } || 0,
243
+ pruned: safely("sweep.pruned") { sweep_session_overflow! } || 0,
244
+ purged_events: safely("sweep.events") { sweep_stale_events! } || 0
245
+ }
246
+ end
247
+
248
+ # Right-to-erasure helper: destroy every live session, delete the trail,
249
+ # and null the typed identity on any retained failure rows that match
250
+ # +user+'s email — so honoring a GDPR deletion request is one call.
251
+ def forget(user, identity: nil)
252
+ safely("forget") do
253
+ session_model.where(user: user).destroy_all if session_model_table?
254
+ Sessions::Event.where(authenticatable: user).delete_all
255
+
256
+ typed = identity || user.try(:email_address) || user.try(:email)
257
+ Sessions::Event.where(identity: Sessions::Event.normalize_identity(typed)).update_all(identity: nil) if typed
258
+
259
+ true
260
+ end
261
+ end
262
+
263
+ # --- Internals (used by the adapters; stable but undocumented) -------------
264
+
265
+ # The error-isolation chokepoint: this gem sits on the authentication
266
+ # hot path, where a tracking bug may lose a log row but must NEVER 500 a
267
+ # sign-in (authtrail's `safely` pattern, ecosystem rule). Everything the
268
+ # adapters and model callbacks do goes through here.
269
+ def safely(context = nil)
270
+ yield
271
+ rescue StandardError => e
272
+ warn("#{context}: #{e.class}: #{e.message}")
273
+ nil
274
+ end
275
+
276
+ def warn(message)
277
+ logger&.warn("[sessions] #{message}")
278
+ nil
279
+ end
280
+
281
+ def logger
282
+ defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
283
+ end
284
+
285
+ # SHA-256 of a session token. High-entropy random input ⇒ a plain
286
+ # digest suffices (no pepper KDF theater); the raw token only ever
287
+ # lives in the user's own Rack session (OWASP: never persist raw
288
+ # session identifiers).
289
+ def token_digest(token)
290
+ OpenSSL::Digest::SHA256.hexdigest(token.to_s)
291
+ end
292
+
293
+ def generate_token
294
+ SecureRandom.hex(32)
295
+ end
296
+
297
+ # Run a block with Sessions::Current.request temporarily set — lets
298
+ # explicit APIs reuse the same model-callback pipeline the adapters use.
299
+ def with_request(request)
300
+ previous = Sessions::Current.request
301
+ Sessions::Current.request = request
302
+ yield
303
+ ensure
304
+ Sessions::Current.request = previous
305
+ end
306
+
307
+ # The catch-all `config.events` tee, error-isolated.
308
+ def notify_event(event)
309
+ safely("events hook") { config.events.call(event) }
310
+ end
311
+
312
+ private
313
+
314
+ def omakase_current(_request)
315
+ return nil unless defined?(::Current) && ::Current.respond_to?(:session)
316
+
317
+ session = ::Current.session
318
+ session if session.is_a?(session_model)
319
+ rescue StandardError
320
+ nil
321
+ end
322
+
323
+ def warden_current(request, scope: nil)
324
+ return nil unless request.env["warden"]
325
+
326
+ pattern = scope ? /\Awarden\.user\.#{Regexp.escape(scope.to_s)}\.session\z/ : /\Awarden\.user\..+\.session\z/
327
+ request.session.to_hash.each do |key, value|
328
+ next unless key.to_s.match?(pattern) && value.is_a?(Hash)
329
+
330
+ id, token = value[Adapters::Warden::SESSION_KEY]
331
+ next unless id && token
332
+
333
+ row = session_model.find_by(id: id)
334
+ return row if row.respond_to?(:sessions_token_matches?) && row&.sessions_token_matches?(token)
335
+ end
336
+ nil
337
+ rescue StandardError
338
+ nil
339
+ end
340
+
341
+ def cookie_current(request)
342
+ return nil unless request.respond_to?(:cookie_jar)
343
+
344
+ id = request.cookie_jar.signed[:session_id]
345
+ session_model.find_by(id: id) if id
346
+ rescue StandardError
347
+ nil
348
+ end
349
+
350
+ def session_model_table?
351
+ session_model.table_exists?
352
+ rescue StandardError
353
+ false
354
+ end
355
+
356
+ # --- Sweep internals --------------------------------------------------------
357
+
358
+ def sweep_expired_sessions!
359
+ return 0 unless config.idle_timeout || config.max_session_lifetime
360
+ return 0 unless session_model_table?
361
+
362
+ count = 0
363
+ expired_sessions_scope.find_each do |session|
364
+ session.revoke!(reason: :expired)
365
+ count += 1
366
+ end
367
+ count
368
+ end
369
+
370
+ def expired_sessions_scope
371
+ scopes = []
372
+
373
+ if (idle = config.idle_timeout)
374
+ threshold = idle.ago
375
+ # A session's last activity is its throttled touch when present,
376
+ # else its creation.
377
+ scopes << session_model.where(last_seen_at: ...threshold)
378
+ scopes << session_model.where(last_seen_at: nil).where(created_at: ...threshold)
379
+ end
380
+
381
+ if (lifetime = config.max_session_lifetime)
382
+ scopes << session_model.where(created_at: ...lifetime.ago)
383
+ end
384
+
385
+ # NOTE: reduce(:or), never `none.or(...)` — a NullRelation stays null
386
+ # through #or and would silently sweep nothing.
387
+ scopes.empty? ? session_model.none : scopes.reduce(:or)
388
+ end
389
+
390
+ def sweep_session_overflow!
391
+ cap = config.max_sessions_per_user
392
+ return 0 unless cap
393
+ return 0 unless session_model_table?
394
+
395
+ # Polymorphic installs (--polymorphic) key the owner by TYPE AND id —
396
+ # grouping by user_id alone would treat User#42 and Organization#42
397
+ # as one owner and evict across them.
398
+ owner_keys = session_model.column_names.include?("user_type") ? %i[user_type user_id] : %i[user_id]
399
+
400
+ count = 0
401
+ session_model.group(*owner_keys).count.each do |owner_key, sessions_count|
402
+ next if sessions_count <= cap
403
+
404
+ session_model.where(owner_keys.zip(Array(owner_key)).to_h)
405
+ .order(created_at: :asc)
406
+ .limit(sessions_count - cap)
407
+ .each do |session|
408
+ session.revoke!(reason: :pruned)
409
+ count += 1
410
+ end
411
+ end
412
+ count
413
+ end
414
+
415
+ def sweep_stale_events!
416
+ retention = config.events_retention
417
+ return 0 unless retention
418
+ return 0 unless Sessions::Event.table_exists?
419
+
420
+ Sessions::Event.where(occurred_at: ...retention.ago).delete_all
421
+ end
422
+ end
423
+ end
metadata ADDED
@@ -0,0 +1,225 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sessions
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - rameerez
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 2026-06-12 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionpack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 7.1.0
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 7.1.0
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: activerecord
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: 7.1.0
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 7.1.0
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: activesupport
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: 7.1.0
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '9.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 7.1.0
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '9.0'
72
+ - !ruby/object:Gem::Dependency
73
+ name: browser
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '6.0'
79
+ type: :runtime
80
+ prerelease: false
81
+ version_requirements: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '6.0'
86
+ - !ruby/object:Gem::Dependency
87
+ name: railties
88
+ requirement: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: 7.1.0
93
+ - - "<"
94
+ - !ruby/object:Gem::Version
95
+ version: '9.0'
96
+ type: :runtime
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 7.1.0
103
+ - - "<"
104
+ - !ruby/object:Gem::Version
105
+ version: '9.0'
106
+ description: 'sessions gives any Rails 8+ app a GitHub-style "your devices" page (list
107
+ every active session, log out of one device, sign out everywhere else) plus an admin-grade,
108
+ append-only trail of every login attempt — successful and failed — with parsed device
109
+ intelligence ("Chrome on macOS", "MyApp 2.4.1 on Pixel 8 (Android 16)"), IP geolocation
110
+ (via the trackdown gem, soft dependency), and the auth method that started each
111
+ session (password, OAuth provider, passkey, magic link…). It decorates the session
112
+ storage your app already has instead of replacing it: on Rails 8 omakase auth (`rails
113
+ generate authentication`) it enriches the generated sessions table with zero app-code
114
+ changes, and on Devise it generalizes the proven session_limitable mechanism into
115
+ true per-device remote revocation via Warden hooks. It detects Hotwire Native apps
116
+ (platform, OS version, app version, device model), never breaks login (every tracking
117
+ path is error-isolated), ships privacy-first defaults (bounded retention with a
118
+ sweep job, optional IP truncation, no browser fingerprinting or invasive client-side
119
+ probing — device continuity is one signed first-party cookie, minted only at login),
120
+ and includes a mountable, i18n''d devices page you can restyle or eject view-by-view
121
+ like Devise.'
122
+ email:
123
+ - rubygems@rameerez.com
124
+ executables: []
125
+ extensions: []
126
+ extra_rdoc_files: []
127
+ files:
128
+ - ".rubocop.yml"
129
+ - ".simplecov"
130
+ - AGENTS.md
131
+ - Appraisals
132
+ - CHANGELOG.md
133
+ - CLAUDE.md
134
+ - LICENSE.txt
135
+ - README.md
136
+ - Rakefile
137
+ - app/assets/stylesheets/sessions.css
138
+ - app/controllers/sessions/application_controller.rb
139
+ - app/controllers/sessions/devices_controller.rb
140
+ - app/helpers/sessions/engine_helper.rb
141
+ - app/views/sessions/_device.html.erb
142
+ - app/views/sessions/_devices.html.erb
143
+ - app/views/sessions/_event.html.erb
144
+ - app/views/sessions/_history.html.erb
145
+ - app/views/sessions/devices/history.html.erb
146
+ - app/views/sessions/devices/index.html.erb
147
+ - config/locales/en.yml
148
+ - config/locales/es.yml
149
+ - config/routes.rb
150
+ - docs/PRD.md
151
+ - docs/research/01-carhey.md
152
+ - docs/research/02-ecosystem.md
153
+ - docs/research/03-rails-core.md
154
+ - docs/research/04-devise-warden.md
155
+ - docs/research/05-oauth.md
156
+ - docs/research/06-prior-art.md
157
+ - docs/research/07-device-detection.md
158
+ - docs/research/08-rails8-landscape.md
159
+ - docs/research/09-market-security.md
160
+ - gemfiles/rails_7.1.gemfile
161
+ - gemfiles/rails_7.2.gemfile
162
+ - gemfiles/rails_8.0.gemfile
163
+ - gemfiles/rails_8.1.gemfile
164
+ - lib/generators/sessions/install_generator.rb
165
+ - lib/generators/sessions/madmin_generator.rb
166
+ - lib/generators/sessions/templates/add_sessions_columns.rb.erb
167
+ - lib/generators/sessions/templates/create_sessions.rb.erb
168
+ - lib/generators/sessions/templates/create_sessions_events.rb.erb
169
+ - lib/generators/sessions/templates/initializer.rb
170
+ - lib/generators/sessions/templates/madmin/event_resource.rb
171
+ - lib/generators/sessions/templates/madmin/session_events_controller.rb
172
+ - lib/generators/sessions/templates/madmin/session_resource.rb
173
+ - lib/generators/sessions/templates/madmin/sessions_controller.rb
174
+ - lib/generators/sessions/templates/session.rb.erb
175
+ - lib/generators/sessions/templates/sessions_sweep_job.rb
176
+ - lib/generators/sessions/views_generator.rb
177
+ - lib/sessions.rb
178
+ - lib/sessions/adapters/omakase.rb
179
+ - lib/sessions/adapters/omniauth.rb
180
+ - lib/sessions/adapters/warden.rb
181
+ - lib/sessions/classifier.rb
182
+ - lib/sessions/configuration.rb
183
+ - lib/sessions/current.rb
184
+ - lib/sessions/device.rb
185
+ - lib/sessions/engine.rb
186
+ - lib/sessions/errors.rb
187
+ - lib/sessions/geolocation.rb
188
+ - lib/sessions/ip_address.rb
189
+ - lib/sessions/jobs/geolocate_job.rb
190
+ - lib/sessions/macros.rb
191
+ - lib/sessions/middleware.rb
192
+ - lib/sessions/models/concerns/device_display.rb
193
+ - lib/sessions/models/concerns/has_sessions.rb
194
+ - lib/sessions/models/concerns/model.rb
195
+ - lib/sessions/models/event.rb
196
+ - lib/sessions/version.rb
197
+ homepage: https://github.com/rameerez/sessions
198
+ licenses:
199
+ - MIT
200
+ metadata:
201
+ allowed_push_host: https://rubygems.org
202
+ source_code_uri: https://github.com/rameerez/sessions
203
+ changelog_uri: https://github.com/rameerez/sessions/blob/main/CHANGELOG.md
204
+ bug_tracker_uri: https://github.com/rameerez/sessions/issues
205
+ documentation_uri: https://github.com/rameerez/sessions#readme
206
+ rubygems_mfa_required: 'true'
207
+ rdoc_options: []
208
+ require_paths:
209
+ - lib
210
+ required_ruby_version: !ruby/object:Gem::Requirement
211
+ requirements:
212
+ - - ">="
213
+ - !ruby/object:Gem::Version
214
+ version: 3.2.0
215
+ required_rubygems_version: !ruby/object:Gem::Requirement
216
+ requirements:
217
+ - - ">="
218
+ - !ruby/object:Gem::Version
219
+ version: '0'
220
+ requirements: []
221
+ rubygems_version: 3.6.2
222
+ specification_version: 4
223
+ summary: Session & login-activity tracking, device management, and remote revocation
224
+ for Rails
225
+ test_files: []