sessions 0.1.3 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -2
  3. data/README.md +26 -16
  4. data/app/controllers/sessions/application_controller.rb +4 -3
  5. data/app/helpers/sessions/engine_helper.rb +23 -0
  6. data/app/views/sessions/_devices.html.erb +2 -2
  7. data/app/views/sessions/_history.html.erb +2 -2
  8. data/docs/PRD.md +52 -52
  9. data/docs/research/{01-carhey.md → 01-host-app.md} +23 -23
  10. data/docs/research/02-ecosystem.md +1 -1
  11. data/docs/research/07-device-detection.md +13 -13
  12. data/lib/generators/sessions/install_generator.rb +1 -1
  13. data/lib/generators/sessions/madmin_generator.rb +23 -7
  14. data/lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb +37 -0
  15. data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +28 -0
  16. data/lib/generators/sessions/templates/create_sessions.rb.erb +20 -5
  17. data/lib/generators/sessions/templates/create_sessions_events.rb.erb +4 -4
  18. data/lib/generators/sessions/templates/initializer.rb +9 -8
  19. data/lib/generators/sessions/templates/madmin/session_resource.rb +24 -13
  20. data/lib/generators/sessions/templates/madmin/sessions_controller.rb +11 -7
  21. data/lib/generators/sessions/templates/madmin/user_panel/madmin_user_panel.rb +43 -0
  22. data/lib/generators/sessions/templates/madmin/user_panel/sessions.html.erb +133 -0
  23. data/lib/generators/sessions/upgrade_generator.rb +4 -1
  24. data/lib/sessions/adapters/omakase.rb +62 -18
  25. data/lib/sessions/adapters/warden.rb +172 -48
  26. data/lib/sessions/configuration.rb +14 -4
  27. data/lib/sessions/current.rb +4 -4
  28. data/lib/sessions/end_reason.rb +67 -0
  29. data/lib/sessions/models/concerns/has_sessions.rb +3 -3
  30. data/lib/sessions/models/concerns/model.rb +124 -59
  31. data/lib/sessions/models/event.rb +56 -21
  32. data/lib/sessions/version.rb +1 -1
  33. data/lib/sessions.rb +68 -14
  34. metadata +9 -5
@@ -27,6 +27,8 @@ module Sessions
27
27
  File.join(db_migrate_path, "add_sessions_adoption_key_to_#{table_name}.rb")
28
28
  migration_template "add_app_build_to_sessions_events.rb.erb",
29
29
  File.join(db_migrate_path, "add_sessions_app_build_to_sessions_events.rb")
30
+ migration_template "add_lifecycle_to_sessions.rb.erb",
31
+ File.join(db_migrate_path, "add_sessions_lifecycle_to_#{table_name}.rb")
30
32
  end
31
33
 
32
34
  def display_post_upgrade_message
@@ -34,7 +36,8 @@ module Sessions
34
36
  say "\nTo complete the upgrade:"
35
37
  say " 1. Review the generated migration."
36
38
  say " 2. Run 'rails db:migrate'."
37
- say " ⚠️ Deploy the migrations before relying on adoption hardening or app-build event columns.", :yellow
39
+ say " ⚠️ Deploy the migrations before relying on lifecycle-row revocation, " \
40
+ "adoption hardening, or app-build event columns.", :yellow
38
41
  end
39
42
 
40
43
  private
@@ -12,17 +12,16 @@ module Sessions
12
12
  # through 8.1.3 to main — → docs/research/03-rails-core.md):
13
13
  #
14
14
  # 1. The Session MODEL gets Sessions::Model included. Its callbacks
15
- # observe 100% of the generated lifecycle: `start_new_session_for`
16
- # uses create!, `terminate_session` uses destroy, and 8.1's
17
- # password reset uses destroy_all (which instantiates and runs
18
- # callbacks) so logins and revocations are captured with zero
19
- # controller coupling.
15
+ # observe `start_new_session_for` (create!) for logins. Normal
16
+ # logout is intercepted by ControllerHooks and ends the row in
17
+ # place; host-side destroy_all (Rails password reset/account erasure)
18
+ # is still tolerated by the model's destroy compatibility hook.
20
19
  #
21
20
  # 2. ApplicationController gets ControllerHooks PREPENDED — the
22
21
  # prepend sits in front of the included Authentication concern in
23
22
  # the ancestor chain, so `super`-wrapping `resume_session`
24
23
  # (throttled touch + opt-in expiry) and `terminate_session`
25
- # (labeling the destroy as a logout) is clean.
24
+ # (labeling the lifecycle end as a logout) is clean.
26
25
  #
27
26
  # 3. The generated SessionsController#create gets FailedLoginHooks
28
27
  # prepended: `authenticate_by` is deliberately silent on failure
@@ -129,32 +128,77 @@ module Sessions
129
128
  module ControllerHooks
130
129
  private
131
130
 
132
- # After the host resolves the session row, enforce opt-in expiry and
133
- # apply the throttled last_seen_at touch. Both are error-isolated;
134
- # an expired session is destroyed (instant revocation semantics) and
135
- # the request proceeds unauthenticated.
131
+ # After the host resolves the session row, refuse ended lifecycle rows,
132
+ # enforce opt-in expiry and apply the throttled last_seen_at touch.
133
+ # In Rails 8 auth this row is the session of record, so an ended row
134
+ # must be treated as unauthenticated even though it still exists for
135
+ # audit/history.
136
136
  def resume_session
137
137
  session = super
138
138
  return session unless session.respond_to?(:sessions_expired?)
139
139
 
140
- if session.sessions_expired?
141
- Sessions.safely("omakase.expire") { session.revoke!(reason: :expired) }
140
+ if session.ended?
141
+ Sessions.safely("omakase.ended") { sessions_clear_omakase_session_cookie }
142
142
  ::Current.session = nil if defined?(::Current) && ::Current.respond_to?(:session=)
143
143
  nil
144
+ elsif session.sessions_expired?
145
+ # The lifecycle row is the server-side liveness source of truth.
146
+ # If the end transition rolls back (for example, the audit event
147
+ # cannot be written), do not clear the Rails auth cookie: that
148
+ # would log the user out while leaving a stale `.live` row behind.
149
+ # Source: Warden's session_limitable pattern also kicks only after
150
+ # a durable server-side state change:
151
+ # https://github.com/devise-security/devise-security/blob/v0.18.0/lib/devise-security/hooks/session_limitable.rb
152
+ if sessions_end_omakase_session(session, reason: :expired, context: "omakase.expire")
153
+ Sessions.safely("omakase.expire.cookie") { sessions_clear_omakase_session_cookie }
154
+ ::Current.session = nil if defined?(::Current) && ::Current.respond_to?(:session=)
155
+ nil
156
+ else
157
+ session
158
+ end
144
159
  else
145
160
  Sessions.safely("omakase.touch") { session.touch_last_seen!(request) }
146
161
  session
147
162
  end
148
163
  end
149
164
 
150
- # Label the upcoming destroy as a user sign-out so the trail says
151
- # "logout", not "revoked".
165
+ # Rails' generated auth calls `Current.session.destroy` here:
166
+ # https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/authentication/templates/app/controllers/concerns/authentication.rb.tt
167
+ # v0.2 preserves the row and marks it ended instead, because the row is
168
+ # now the lifecycle source of truth. We still delete the signed cookie
169
+ # exactly like the generated method.
152
170
  def terminate_session
153
- Sessions.safely("omakase.terminate") do
154
- session = defined?(::Current) ? ::Current.try(:session) : nil
155
- session.revocation_reason = :logout if session.respond_to?(:revocation_reason=)
171
+ session = defined?(::Current) ? ::Current.try(:session) : nil
172
+ if session.respond_to?(:end!)
173
+ sessions_end_omakase_session!(session, reason: :logout, context: "omakase.terminate")
174
+ ::Current.session = nil if defined?(::Current) && ::Current.respond_to?(:session=)
175
+ sessions_clear_omakase_session_cookie
176
+ else
177
+ super
156
178
  end
157
- super
179
+ end
180
+
181
+ def sessions_clear_omakase_session_cookie
182
+ cookies.delete(:session_id)
183
+ end
184
+
185
+ def sessions_end_omakase_session(session, reason:, context:)
186
+ session.end!(reason: reason)
187
+ true
188
+ rescue StandardError => e
189
+ Sessions.warn("#{context} failed open: #{e.class}: #{e.message}")
190
+ false
191
+ end
192
+
193
+ def sessions_end_omakase_session!(session, reason:, context:)
194
+ session.end!(reason: reason)
195
+ true
196
+ rescue StandardError => e
197
+ # Explicit logout must either persist its lifecycle transition or
198
+ # abort before deleting the cookie. Otherwise the tracking layer
199
+ # silently changes auth state while the row still says "live".
200
+ Sessions.warn("#{context} aborted auth teardown: #{e.class}: #{e.message}")
201
+ raise
158
202
  end
159
203
  end
160
204
 
@@ -13,18 +13,25 @@ module Sessions
13
13
  # to a sessions-table ROW, turning "exactly one session" into "N devices,
14
14
  # each individually revocable" (→ docs/research/04-devise-warden.md §5).
15
15
  #
16
- # login — mint a random token, store [row_id, raw_token] in the
16
+ # login — mint a random token, store structured tracking state in the
17
17
  # per-scope warden session (it survives Warden's :renew SID
18
18
  # rotation and is deleted by Warden itself on logout; we
19
19
  # never key on the Rack SID), persist only the SHA-256
20
20
  # digest on the row.
21
21
  # fetch — per-request liveness check: row exists + digest matches
22
- # (constant-time) → throttled touch; row gone (revoked!) →
23
- # the proven session_limitable kick: clear, logout, throw.
22
+ # (constant-time) + row is live → throttled touch; row ended
23
+ # for an explicit reason → the proven session_limitable kick:
24
+ # clear, logout, throw. Missing/mismatched tracking fails open
25
+ # unless a legacy v0.1.x event tombstone proves a pre-lifecycle
26
+ # remote revocation.
24
27
  # failure — record the failed attempt with the typed identity.
25
- # logout — destroy the row, labeled as a logout.
28
+ # logout — mark the row ended, labeled as a logout.
26
29
  module Warden
27
- # Key inside `warden.session(scope)` holding [row_id, raw_token].
30
+ # Key inside `warden.session(scope)` holding the sessions gem tracking
31
+ # state. v0.2 writes a small Hash (`id`, `token`, `mode`) instead of the
32
+ # old `[row_id, raw_token]` tuple so a nil token can never accidentally
33
+ # read like an auth credential. The parser still accepts arrays because
34
+ # production users may carry v0.1.x cookies during deploy.
28
35
  SESSION_KEY = "sessions"
29
36
 
30
37
  # Rememberable can restore a user on background/native JSON requests
@@ -145,7 +152,7 @@ module Sessions
145
152
  row.sessions_skip_supersede = skip_supersede
146
153
  Sessions.with_request(request) { row.save! }
147
154
 
148
- warden.session(scope)[SESSION_KEY] = [row.id, token]
155
+ warden.session(scope)[SESSION_KEY] = tracking_state(row, token: token, mode: "credential")
149
156
  row
150
157
  end
151
158
 
@@ -168,8 +175,8 @@ module Sessions
168
175
  end
169
176
  return if data == :skip
170
177
 
171
- session_token = data && data[:login]
172
- if session_token.nil?
178
+ tracking = parse_tracking_state(data && data[:login])
179
+ if tracking.nil?
173
180
  if data && data[:pending_login]
174
181
  activate_pending_login(record, warden, scope, data[:pending_login])
175
182
  else
@@ -179,34 +186,74 @@ module Sessions
179
186
  end
180
187
 
181
188
  # The lookup is NOT wrapped in `safely`: an ERRORED lookup and a
182
- # MISSING row must be distinguishable. A row that's genuinely gone
183
- # (or a token that doesn't match) means revocation → kick. A raised
184
- # lookup the sessions table unreachable, a timeout, a migration
185
- # mid-deploy means the TRACKING layer is down, and tracking must
186
- # never break authentication: fail OPEN, let the request through
187
- # untracked, try again next request.
189
+ # MISSING row must be distinguishable from a raised lookup, but a
190
+ # missing/mismatched tracking row is still not automatically auth
191
+ # state. In v0.2, only a matching row whose own ended_reason is
192
+ # explicit may kick. The event lookup below is legacy-only for v0.1.x
193
+ # cookies whose rows were already destroyed before lifecycle columns
194
+ # existed.
195
+ # A raised lookup — the sessions table unreachable, a timeout, a
196
+ # migration mid-deploy — means the TRACKING layer is down, and
197
+ # tracking must never break authentication: fail OPEN, let the request
198
+ # through untracked, try again next request.
188
199
  begin
189
- id, token = session_token
190
- found = Sessions.session_model.find_by(id: id)
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
200
+ found = Sessions.session_model.find_by(id: tracking[:id])
201
+ if tracking[:mode] == "hint"
202
+ # v0.1.3 intentionally reattached remember-me restores to an
203
+ # existing device row without writing another login event, storing
204
+ # [row_id, nil] in Warden. That is fine as a tracking hint, but it
205
+ # must never become an auth/liveness check FROM ABSENCE: missing
206
+ # rows, mismatched cookies and quiet housekeeping all fail open.
207
+ # An EXPLICIT ending is different — when the authenticated owner,
208
+ # the signed browser-continuity cookie and the row all agree this
209
+ # is the same device, and that row was deliberately ended
210
+ # (admin/user revoke, sign-out-everywhere), the revocation must
211
+ # mean what the UI promised: signed out on its next request.
212
+ # Housekeeping reasons (superseded) never kick and fall through
213
+ # to the live-replacement reattach below.
214
+ # Source: https://github.com/rameerez/sessions/blob/v0.1.3/CHANGELOG.md
215
+ same_device = existing_row_session?(found, record, scope, warden.request)
216
+ if same_device && found.live?
217
+ Sessions.safely("warden.remembered_existing.touch") { found.touch_last_seen!(warden.request) }
218
+ elsif same_device && found.sessions_kicks_on_resume?
219
+ kick!(warden, scope)
220
+ elsif (replacement = live_replacement_for(found, record, scope, warden.request))
221
+ attach_existing_row(replacement, warden, scope)
222
+ else
223
+ clear_tracking_key(warden, scope)
224
+ end
225
+ return
226
+ end
227
+
228
+ row = found if found&.sessions_token_matches?(tracking[:token])
196
229
  rescue StandardError => e
197
230
  Sessions.warn("warden.fetch failed open: #{e.class}: #{e.message}")
198
231
  return
199
232
  end
200
233
 
201
234
  if row.nil?
202
- # Revoked (the row is gone) or tampered (digest mismatch): the
203
- # proven session_limitable sequence log the scope out and hand
204
- # control to the failure app. NOT wrapped in `safely`: the throw
205
- # is control flow, not an error.
206
- kick!(warden, scope)
235
+ if legacy_explicitly_ended_session?(tracking[:id])
236
+ # Explicit remote revocation/expiry is the one intentional place
237
+ # where a legacy v0.1.x destroyed row may still end a Devise
238
+ # session. v0.2 rows should be present and ended in place.
239
+ kick!(warden, scope)
240
+ else
241
+ clear_tracking_key(warden, scope)
242
+ end
243
+ elsif row.ended?
244
+ if row.sessions_kicks_on_resume?
245
+ kick!(warden, scope)
246
+ elsif (replacement = live_replacement_for(row, record, scope, warden.request))
247
+ attach_existing_row(replacement, warden, scope)
248
+ else
249
+ clear_tracking_key(warden, scope)
250
+ end
207
251
  elsif Sessions.safely("warden.expired?") { row.sessions_expired? }
208
- Sessions.safely("warden.expire") { row.revoke!(reason: :expired) }
209
- kick!(warden, scope)
252
+ # Expiry is gem-initiated, so it must be durable before we touch
253
+ # Warden auth state. If the lifecycle write rolls back, fail open:
254
+ # the user stays authenticated and the next request can retry the
255
+ # expiry instead of leaving an orphan `.live` row.
256
+ kick!(warden, scope) if end_row_for_auth_teardown(row, reason: :expired, context: "warden.expire")
210
257
  else
211
258
  Sessions.safely("warden.touch") { row.touch_last_seen!(warden.request) }
212
259
  end
@@ -300,7 +347,7 @@ module Sessions
300
347
  device_id = device_id_from_request(warden.request)
301
348
  return if device_id.blank?
302
349
 
303
- rows = Sessions.session_model.where(user: record, device_id: device_id)
350
+ rows = Sessions.session_model.live.where(user: record, device_id: device_id)
304
351
  rows = rows.where(scope: scope.to_s) if Sessions.session_model.column_names.include?("scope")
305
352
  rows.order(created_at: :desc).first
306
353
  rescue StandardError
@@ -308,7 +355,7 @@ module Sessions
308
355
  end
309
356
 
310
357
  def attach_existing_row(row, warden, scope)
311
- warden.session(scope)[SESSION_KEY] = [row.id, nil]
358
+ warden.session(scope)[SESSION_KEY] = tracking_state(row, mode: "hint")
312
359
  Sessions.safely("warden.remembered_existing.touch") { row.touch_last_seen!(warden.request) }
313
360
  row
314
361
  end
@@ -327,6 +374,19 @@ module Sessions
327
374
  false
328
375
  end
329
376
 
377
+ def live_replacement_for(row, record, scope, request)
378
+ device_id = row&.try(:device_id).presence || device_id_from_request(request)
379
+ return if device_id.blank?
380
+
381
+ model = Sessions.session_model
382
+ rows = model.live.where(user: record, device_id: device_id)
383
+ rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
384
+ rows = rows.where.not(id: row.id) if row
385
+ rows.order(created_at: :desc).first
386
+ rescue StandardError
387
+ nil
388
+ end
389
+
330
390
  def device_id_from_request(request)
331
391
  return unless request.respond_to?(:cookie_jar)
332
392
 
@@ -386,10 +446,10 @@ module Sessions
386
446
 
387
447
  def adopted_row(record, scope, adoption_key:)
388
448
  model = Sessions.session_model
389
- rows = model.where(user: record)
449
+ rows = model.live.where(user: record)
390
450
  rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
391
451
 
392
- row = model.find_by(adoption_key: adoption_key) if adoption_key_column?(model) && adoption_key.present?
452
+ row = model.live.find_by(adoption_key: adoption_key) if adoption_key_column?(model) && adoption_key.present?
393
453
  row = nil unless adopted_row?(row)
394
454
  row ||= rows.order(created_at: :desc).detect { |candidate| adopted_row?(candidate) }
395
455
 
@@ -429,13 +489,60 @@ module Sessions
429
489
  model.column_names.include?("adoption_key")
430
490
  end
431
491
 
492
+ def clear_tracking_key(warden, scope)
493
+ warden.session(scope).delete(SESSION_KEY)
494
+ rescue StandardError
495
+ nil
496
+ end
497
+
498
+ def tracking_state(row, mode:, token: nil)
499
+ {
500
+ "v" => 2,
501
+ "id" => row.id,
502
+ "token" => token,
503
+ "mode" => mode
504
+ }
505
+ end
506
+
507
+ def parse_tracking_state(value)
508
+ case value
509
+ when Hash
510
+ id = value["id"] || value[:id]
511
+ token = value["token"] || value[:token]
512
+ mode = (value["mode"] || value[:mode]).presence
513
+ when Array
514
+ id, token = value
515
+ mode = token.present? ? "credential" : "hint"
516
+ else
517
+ return nil
518
+ end
519
+
520
+ return nil if id.blank?
521
+
522
+ mode = token.present? ? "credential" : (mode || "hint")
523
+ { id: id, token: token, mode: mode }
524
+ rescue StandardError
525
+ nil
526
+ end
527
+
528
+ def legacy_explicitly_ended_session?(id)
529
+ return false if id.blank?
530
+
531
+ events = Sessions::Event.where(session_id: id)
532
+ events.expirations.exists? ||
533
+ events.revocations.where.not(revoked_reason: "superseded").exists?
534
+ rescue StandardError => e
535
+ Sessions.warn("warden.fetch legacy end-event lookup failed open: #{e.class}: #{e.message}")
536
+ false
537
+ end
538
+
432
539
  # SCOPE-PRECISE teardown: only this scope's warden entries go (the
433
540
  # serialized user key and our token stash) — an admin scope riding
434
541
  # the same rack session, and unrelated host session data (carts,
435
542
  # locale, return-to paths), survive a user-scope kick. Deleting the
436
543
  # keys BEFORE logout matters: our before_logout hook then finds no
437
- # token and records nothing (a kick is not a logout — the revocation
438
- # event was already written by whoever destroyed the row).
544
+ # token and records nothing (a kick is not a logout — the lifecycle
545
+ # reason/event were already written by the explicit ending).
439
546
  def kick!(warden, scope)
440
547
  warden.raw_session.delete("warden.user.#{scope}.key")
441
548
  warden.raw_session.delete("warden.user.#{scope}.session")
@@ -483,9 +590,8 @@ module Sessions
483
590
  # --- Hook 4: logout ---------------------------------------------------------
484
591
 
485
592
  # Fires once per scope (including forced logouts: timeout, lockout,
486
- # our own revocation kick). If the row is already gone revoked from
487
- # another device — there's nothing to do; the `revoked` event was
488
- # written by whoever destroyed it.
593
+ # our own revocation kick). If the row is already ended, there's nothing
594
+ # to do; lifecycle state is idempotent.
489
595
  #
490
596
  # CRITICAL: read the RAW session here, never `warden.session(scope)`.
491
597
  # Warden's logout deletes `@users[scope]` BEFORE running before_logout
@@ -494,19 +600,29 @@ module Sessions
494
600
  # logout came from a hook that logs out and throws (Devise's
495
601
  # activatable on unconfirmed/locked accounts, timeoutable) that loops:
496
602
  # activatable → logout → us → re-auth → activatable → … SystemStackError.
497
- def record_logout(_record, warden, opts)
498
- Sessions.safely("warden.logout") do
499
- scope = opts[:scope]
500
- data = warden.raw_session["warden.user.#{scope}.session"]&.dig(SESSION_KEY)
501
- next unless data
603
+ def record_logout(record, warden, opts)
604
+ scope = opts[:scope]
605
+ tracking = parse_tracking_state(warden.raw_session["warden.user.#{scope}.session"]&.dig(SESSION_KEY))
606
+ return unless tracking
502
607
 
503
- id, token = data
504
- row = Sessions.session_model.find_by(id: id)
505
- next unless row&.sessions_token_matches?(token)
608
+ row = Sessions.session_model.find_by(id: tracking[:id])
609
+ return unless row
610
+ return unless row.live?
506
611
 
507
- row.revocation_reason ||= :logout
508
- row.destroy
509
- end
612
+ token_backed = tracking[:mode] == "credential" && row.sessions_token_matches?(tracking[:token])
613
+ tokenless_known_device = tracking[:mode] == "hint" &&
614
+ existing_row_session?(row, record, scope, warden.request)
615
+ return unless token_backed || tokenless_known_device
616
+
617
+ # Warden 1.2.9 runs before_logout callbacks before deleting the
618
+ # serialized session keys (proxy.rb#logout). Raising here aborts the
619
+ # auth teardown, so an audit/lifecycle persistence failure cannot log
620
+ # the user out while leaving the row live.
621
+ # Source: https://github.com/wardencommunity/warden/blob/v1.2.9/lib/warden/proxy.rb#L266-L279
622
+ row.end!(reason: :logout)
623
+ rescue StandardError => e
624
+ Sessions.warn("warden.logout aborted auth teardown: #{e.class}: #{e.message}")
625
+ raise
510
626
  end
511
627
 
512
628
  def warden_default_scope(env)
@@ -529,6 +645,14 @@ module Sessions
529
645
  rescue StandardError
530
646
  false
531
647
  end
648
+
649
+ def end_row_for_auth_teardown(row, reason:, context:)
650
+ row.end!(reason: reason)
651
+ true
652
+ rescue StandardError => e
653
+ Sessions.warn("#{context} failed open: #{e.class}: #{e.message}")
654
+ false
655
+ end
532
656
  end
533
657
  end
534
658
  end
@@ -55,9 +55,10 @@ module Sessions
55
55
 
56
56
  # Terminate other sessions when the user's password changes (ASVS 3.3.3
57
57
  # / 7.4.3; Laravel's logoutOtherDevices and Phoenix's token nuke are the
58
- # cross-framework precedent; Rails 8.1's own password reset already
59
- # destroy_alls). Wired by `has_sessions` via an after_update on the
60
- # password digest column, so it works on both auth stacks.
58
+ # cross-framework precedent; Rails 8.1's generated password reset uses
59
+ # `destroy_all`, which our direct-delete compatibility hook still labels
60
+ # honestly). Wired by `has_sessions` via an after_update on the password
61
+ # digest column, so it works on both auth stacks.
61
62
  attr_accessor :revoke_on_password_change
62
63
 
63
64
  # Devise mode only: revoking a session also rotates the user's
@@ -180,7 +181,7 @@ module Sessions
180
181
  # whatever layout the parent controller resolves — usually the host's
181
182
  # `application` layout. Set it when your signed-in surfaces render with
182
183
  # a different one (e.g. "app").
183
- attr_accessor :layout
184
+ attr_reader :layout
184
185
 
185
186
  # ->(controller) — optional sudo gate run before destructive actions on
186
187
  # the devices page (ASVS 3.3.4's "having re-entered login credentials").
@@ -376,6 +377,15 @@ module Sessions
376
377
  @parent_controller = name
377
378
  end
378
379
 
380
+ def layout=(value)
381
+ if value.nil? || value.respond_to?(:call) || value.is_a?(String) || value.is_a?(Symbol)
382
+ @layout = value
383
+ return
384
+ end
385
+
386
+ raise ConfigurationError, "layout must be a String, Symbol, callable, or nil, got #{value.inspect}"
387
+ end
388
+
379
389
  def require_reauthentication=(value)
380
390
  if value.nil?
381
391
  @require_reauthentication = nil
@@ -9,10 +9,10 @@ module Sessions
9
9
  #
10
10
  # Why it exists: the omakase adapter records logins from MODEL callbacks
11
11
  # (Session#after_create_commit — the only seam that captures 100% of the
12
- # generated lifecycle, including 8.1's password-reset destroy_all), and
13
- # model callbacks have no request. This carries the request reference
14
- # across that gap. Background jobs and console code simply see nil and the
15
- # pipeline degrades gracefully (rows parse from their own stored columns).
12
+ # generated login lifecycle), and model callbacks have no request. This
13
+ # carries the request reference across that gap. Background jobs and
14
+ # console code simply see nil and the pipeline degrades gracefully (rows
15
+ # parse from their own stored columns).
16
16
  class Current < ActiveSupport::CurrentAttributes
17
17
  # The ActionDispatch::Request being served, if any.
18
18
  attribute :request
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # Canonical lifecycle vocabulary for registry rows.
5
+ #
6
+ # v0.1.x used "row missing + event tombstone" as the revocation signal. That
7
+ # made Warden infer security intent from absence, which is exactly how quiet
8
+ # housekeeping ended up looking too much like a real logout. v0.2 moves the
9
+ # source of truth onto the session row itself: events are audit trail, not
10
+ # liveness state.
11
+ #
12
+ # External precedents:
13
+ # - Rails 8 generated auth resolves a Session row on every request:
14
+ # https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/authentication/templates/app/controllers/concerns/authentication.rb.tt
15
+ # - Rodauth's active_sessions feature keeps explicit active-session state
16
+ # separate from audit logging:
17
+ # https://github.com/jeremyevans/rodauth/blob/master/lib/rodauth/features/active_sessions.rb
18
+ module EndReason
19
+ LOGOUT = "logout"
20
+ EXPIRED = "expired"
21
+ SUPERSEDED = "superseded"
22
+
23
+ REVOKED = %w[
24
+ user_revoked
25
+ admin_revoked
26
+ password_change
27
+ logout_everywhere
28
+ pruned
29
+ unknown
30
+ ].freeze
31
+
32
+ INTERNAL = [SUPERSEDED].freeze
33
+ EVENTS = {
34
+ LOGOUT => "logout",
35
+ EXPIRED => "expired"
36
+ }.freeze
37
+
38
+ KICKING = ([LOGOUT, EXPIRED] + REVOKED).freeze
39
+
40
+ module_function
41
+
42
+ def normalize(reason)
43
+ value = reason.to_s.presence || "unknown"
44
+ value == "revoked" ? "user_revoked" : value
45
+ end
46
+
47
+ def internal?(reason)
48
+ INTERNAL.include?(normalize(reason))
49
+ end
50
+
51
+ def kicks_on_resume?(reason)
52
+ KICKING.include?(normalize(reason))
53
+ end
54
+
55
+ def event_for(reason)
56
+ normalized = normalize(reason)
57
+ return nil if internal?(normalized)
58
+
59
+ EVENTS.fetch(normalized, "revoked")
60
+ end
61
+
62
+ def revoked_reason_for(reason)
63
+ normalized = normalize(reason)
64
+ event_for(normalized) == "revoked" ? normalized : nil
65
+ end
66
+ end
67
+ end
@@ -7,7 +7,7 @@ module Sessions
7
7
  # here. Either way the model gains the events trail and the revocation
8
8
  # verbs:
9
9
  #
10
- # current_user.sessions.active
10
+ # current_user.sessions.live
11
11
  # current_user.session_events.failed_logins.last_24_hours
12
12
  # current_user.revoke_other_sessions! # "sign out everywhere else"
13
13
  # current_user.revoke_all_sessions! # the account-takeover hammer
@@ -75,7 +75,7 @@ module Sessions
75
75
  def revoke_other_sessions!(current: nil, by: nil, reason: :logout_everywhere)
76
76
  current = Sessions.current if current.nil?
77
77
 
78
- scope = sessions
78
+ scope = sessions.live
79
79
  scope = scope.where.not(id: current.id) if current.respond_to?(:id)
80
80
  scope.each { |session| session.revoke!(reason: reason, by: by || self) }
81
81
  true
@@ -84,7 +84,7 @@ module Sessions
84
84
  # The admin hammer — the account-takeover response. Revokes EVERYTHING,
85
85
  # including the session serving this request if it belongs to this user.
86
86
  def revoke_all_sessions!(by: nil, reason: :admin_revoked)
87
- sessions.each { |session| session.revoke!(reason: reason, by: by) }
87
+ sessions.live.each { |session| session.revoke!(reason: reason, by: by) }
88
88
  true
89
89
  end
90
90