sessions 0.1.3 → 0.2.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.
@@ -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,65 @@ 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. Touch when the signed
206
+ # browser-continuity cookie still agrees; otherwise clear only the
207
+ # gem's tracking key and let Devise/Rails keep owning auth.
208
+ # Source: https://github.com/rameerez/sessions/blob/v0.1.3/CHANGELOG.md
209
+ if existing_row_session?(found, record, scope, warden.request) && found.live?
210
+ Sessions.safely("warden.remembered_existing.touch") { found.touch_last_seen!(warden.request) }
211
+ elsif (replacement = live_replacement_for(found, record, scope, warden.request))
212
+ attach_existing_row(replacement, warden, scope)
213
+ else
214
+ clear_tracking_key(warden, scope)
215
+ end
216
+ return
217
+ end
218
+
219
+ row = found if found&.sessions_token_matches?(tracking[:token])
196
220
  rescue StandardError => e
197
221
  Sessions.warn("warden.fetch failed open: #{e.class}: #{e.message}")
198
222
  return
199
223
  end
200
224
 
201
225
  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)
226
+ if legacy_explicitly_ended_session?(tracking[:id])
227
+ # Explicit remote revocation/expiry is the one intentional place
228
+ # where a legacy v0.1.x destroyed row may still end a Devise
229
+ # session. v0.2 rows should be present and ended in place.
230
+ kick!(warden, scope)
231
+ else
232
+ clear_tracking_key(warden, scope)
233
+ end
234
+ elsif row.ended?
235
+ if row.sessions_kicks_on_resume?
236
+ kick!(warden, scope)
237
+ elsif (replacement = live_replacement_for(row, record, scope, warden.request))
238
+ attach_existing_row(replacement, warden, scope)
239
+ else
240
+ clear_tracking_key(warden, scope)
241
+ end
207
242
  elsif Sessions.safely("warden.expired?") { row.sessions_expired? }
208
- Sessions.safely("warden.expire") { row.revoke!(reason: :expired) }
209
- kick!(warden, scope)
243
+ # Expiry is gem-initiated, so it must be durable before we touch
244
+ # Warden auth state. If the lifecycle write rolls back, fail open:
245
+ # the user stays authenticated and the next request can retry the
246
+ # expiry instead of leaving an orphan `.live` row.
247
+ kick!(warden, scope) if end_row_for_auth_teardown(row, reason: :expired, context: "warden.expire")
210
248
  else
211
249
  Sessions.safely("warden.touch") { row.touch_last_seen!(warden.request) }
212
250
  end
@@ -300,7 +338,7 @@ module Sessions
300
338
  device_id = device_id_from_request(warden.request)
301
339
  return if device_id.blank?
302
340
 
303
- rows = Sessions.session_model.where(user: record, device_id: device_id)
341
+ rows = Sessions.session_model.live.where(user: record, device_id: device_id)
304
342
  rows = rows.where(scope: scope.to_s) if Sessions.session_model.column_names.include?("scope")
305
343
  rows.order(created_at: :desc).first
306
344
  rescue StandardError
@@ -308,7 +346,7 @@ module Sessions
308
346
  end
309
347
 
310
348
  def attach_existing_row(row, warden, scope)
311
- warden.session(scope)[SESSION_KEY] = [row.id, nil]
349
+ warden.session(scope)[SESSION_KEY] = tracking_state(row, mode: "hint")
312
350
  Sessions.safely("warden.remembered_existing.touch") { row.touch_last_seen!(warden.request) }
313
351
  row
314
352
  end
@@ -327,6 +365,19 @@ module Sessions
327
365
  false
328
366
  end
329
367
 
368
+ def live_replacement_for(row, record, scope, request)
369
+ device_id = row&.try(:device_id).presence || device_id_from_request(request)
370
+ return if device_id.blank?
371
+
372
+ model = Sessions.session_model
373
+ rows = model.live.where(user: record, device_id: device_id)
374
+ rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
375
+ rows = rows.where.not(id: row.id) if row
376
+ rows.order(created_at: :desc).first
377
+ rescue StandardError
378
+ nil
379
+ end
380
+
330
381
  def device_id_from_request(request)
331
382
  return unless request.respond_to?(:cookie_jar)
332
383
 
@@ -386,10 +437,10 @@ module Sessions
386
437
 
387
438
  def adopted_row(record, scope, adoption_key:)
388
439
  model = Sessions.session_model
389
- rows = model.where(user: record)
440
+ rows = model.live.where(user: record)
390
441
  rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
391
442
 
392
- row = model.find_by(adoption_key: adoption_key) if adoption_key_column?(model) && adoption_key.present?
443
+ row = model.live.find_by(adoption_key: adoption_key) if adoption_key_column?(model) && adoption_key.present?
393
444
  row = nil unless adopted_row?(row)
394
445
  row ||= rows.order(created_at: :desc).detect { |candidate| adopted_row?(candidate) }
395
446
 
@@ -429,13 +480,60 @@ module Sessions
429
480
  model.column_names.include?("adoption_key")
430
481
  end
431
482
 
483
+ def clear_tracking_key(warden, scope)
484
+ warden.session(scope).delete(SESSION_KEY)
485
+ rescue StandardError
486
+ nil
487
+ end
488
+
489
+ def tracking_state(row, mode:, token: nil)
490
+ {
491
+ "v" => 2,
492
+ "id" => row.id,
493
+ "token" => token,
494
+ "mode" => mode
495
+ }
496
+ end
497
+
498
+ def parse_tracking_state(value)
499
+ case value
500
+ when Hash
501
+ id = value["id"] || value[:id]
502
+ token = value["token"] || value[:token]
503
+ mode = (value["mode"] || value[:mode]).presence
504
+ when Array
505
+ id, token = value
506
+ mode = token.present? ? "credential" : "hint"
507
+ else
508
+ return nil
509
+ end
510
+
511
+ return nil if id.blank?
512
+
513
+ mode = token.present? ? "credential" : (mode || "hint")
514
+ { id: id, token: token, mode: mode }
515
+ rescue StandardError
516
+ nil
517
+ end
518
+
519
+ def legacy_explicitly_ended_session?(id)
520
+ return false if id.blank?
521
+
522
+ events = Sessions::Event.where(session_id: id)
523
+ events.expirations.exists? ||
524
+ events.revocations.where.not(revoked_reason: "superseded").exists?
525
+ rescue StandardError => e
526
+ Sessions.warn("warden.fetch legacy end-event lookup failed open: #{e.class}: #{e.message}")
527
+ false
528
+ end
529
+
432
530
  # SCOPE-PRECISE teardown: only this scope's warden entries go (the
433
531
  # serialized user key and our token stash) — an admin scope riding
434
532
  # the same rack session, and unrelated host session data (carts,
435
533
  # locale, return-to paths), survive a user-scope kick. Deleting the
436
534
  # 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).
535
+ # token and records nothing (a kick is not a logout — the lifecycle
536
+ # reason/event were already written by the explicit ending).
439
537
  def kick!(warden, scope)
440
538
  warden.raw_session.delete("warden.user.#{scope}.key")
441
539
  warden.raw_session.delete("warden.user.#{scope}.session")
@@ -483,9 +581,8 @@ module Sessions
483
581
  # --- Hook 4: logout ---------------------------------------------------------
484
582
 
485
583
  # 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.
584
+ # our own revocation kick). If the row is already ended, there's nothing
585
+ # to do; lifecycle state is idempotent.
489
586
  #
490
587
  # CRITICAL: read the RAW session here, never `warden.session(scope)`.
491
588
  # Warden's logout deletes `@users[scope]` BEFORE running before_logout
@@ -494,19 +591,29 @@ module Sessions
494
591
  # logout came from a hook that logs out and throws (Devise's
495
592
  # activatable on unconfirmed/locked accounts, timeoutable) that loops:
496
593
  # 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
594
+ def record_logout(record, warden, opts)
595
+ scope = opts[:scope]
596
+ tracking = parse_tracking_state(warden.raw_session["warden.user.#{scope}.session"]&.dig(SESSION_KEY))
597
+ return unless tracking
502
598
 
503
- id, token = data
504
- row = Sessions.session_model.find_by(id: id)
505
- next unless row&.sessions_token_matches?(token)
599
+ row = Sessions.session_model.find_by(id: tracking[:id])
600
+ return unless row
601
+ return unless row.live?
506
602
 
507
- row.revocation_reason ||= :logout
508
- row.destroy
509
- end
603
+ token_backed = tracking[:mode] == "credential" && row.sessions_token_matches?(tracking[:token])
604
+ tokenless_known_device = tracking[:mode] == "hint" &&
605
+ existing_row_session?(row, record, scope, warden.request)
606
+ return unless token_backed || tokenless_known_device
607
+
608
+ # Warden 1.2.9 runs before_logout callbacks before deleting the
609
+ # serialized session keys (proxy.rb#logout). Raising here aborts the
610
+ # auth teardown, so an audit/lifecycle persistence failure cannot log
611
+ # the user out while leaving the row live.
612
+ # Source: https://github.com/wardencommunity/warden/blob/v1.2.9/lib/warden/proxy.rb#L266-L279
613
+ row.end!(reason: :logout)
614
+ rescue StandardError => e
615
+ Sessions.warn("warden.logout aborted auth teardown: #{e.class}: #{e.message}")
616
+ raise
510
617
  end
511
618
 
512
619
  def warden_default_scope(env)
@@ -529,6 +636,14 @@ module Sessions
529
636
  rescue StandardError
530
637
  false
531
638
  end
639
+
640
+ def end_row_for_auth_teardown(row, reason:, context:)
641
+ row.end!(reason: reason)
642
+ true
643
+ rescue StandardError => e
644
+ Sessions.warn("#{context} failed open: #{e.class}: #{e.message}")
645
+ false
646
+ end
532
647
  end
533
648
  end
534
649
  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
@@ -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