sessions 0.1.2 → 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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -1
  3. data/README.md +10 -10
  4. data/app/controllers/sessions/application_controller.rb +2 -2
  5. data/app/views/sessions/_devices.html.erb +1 -1
  6. data/docs/PRD.md +52 -52
  7. data/docs/research/{01-carhey.md → 01-host-app.md} +23 -23
  8. data/docs/research/02-ecosystem.md +1 -1
  9. data/docs/research/07-device-detection.md +13 -13
  10. data/lib/generators/sessions/install_generator.rb +1 -1
  11. data/lib/generators/sessions/madmin_generator.rb +5 -5
  12. data/lib/generators/sessions/templates/add_app_build_to_sessions_events.rb.erb +9 -0
  13. data/lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb +38 -0
  14. data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +26 -0
  15. data/lib/generators/sessions/templates/create_sessions.rb.erb +17 -5
  16. data/lib/generators/sessions/templates/create_sessions_events.rb.erb +5 -4
  17. data/lib/generators/sessions/templates/initializer.rb +2 -2
  18. data/lib/generators/sessions/templates/madmin/session_resource.rb +16 -8
  19. data/lib/generators/sessions/templates/madmin/sessions_controller.rb +4 -4
  20. data/lib/generators/sessions/upgrade_generator.rb +6 -1
  21. data/lib/sessions/adapters/omakase.rb +62 -18
  22. data/lib/sessions/adapters/warden.rb +323 -47
  23. data/lib/sessions/classifier.rb +18 -5
  24. data/lib/sessions/configuration.rb +4 -3
  25. data/lib/sessions/current.rb +4 -4
  26. data/lib/sessions/end_reason.rb +67 -0
  27. data/lib/sessions/geolocation.rb +18 -8
  28. data/lib/sessions/models/concerns/has_sessions.rb +3 -3
  29. data/lib/sessions/models/concerns/model.rb +136 -68
  30. data/lib/sessions/models/event.rb +24 -17
  31. data/lib/sessions/version.rb +1 -1
  32. data/lib/sessions.rb +13 -12
  33. metadata +6 -3
@@ -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,20 +13,32 @@ 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
 
37
+ # Rememberable can restore a user on background/native JSON requests
38
+ # before the browser/WebView has actually navigated. Defer the row
39
+ # until a document request can name the user-visible device.
40
+ PENDING_LOGIN_KEY = "sessions.pending_login"
41
+
30
42
  # Sticky per-scope flag: a login recorded with `sessions_skip: true`
31
43
  # must not be kicked by the fetch validation later (session_limitable's
32
44
  # third skip layer).
@@ -104,11 +116,23 @@ module Sessions
104
116
 
105
117
  next unless row_accepts?(record)
106
118
 
119
+ auth = Sessions::Classifier.classify(warden.request)
120
+ if deferred_login_request?(warden.request, auth)
121
+ stash_pending_login(warden, scope, auth)
122
+ next
123
+ end
124
+
125
+ if (row = remembered_existing_row(record, warden, scope, auth))
126
+ attach_existing_row(row, warden, scope)
127
+ next
128
+ end
129
+
130
+ warden.session(scope).delete(PENDING_LOGIN_KEY)
107
131
  create_row_for(record, warden, scope)
108
132
  end
109
133
  end
110
134
 
111
- def create_row_for(record, warden, scope, suppress_login_event: false, attributes: {})
135
+ def create_row_for(record, warden, scope, suppress_login_event: false, skip_supersede: false, attributes: {})
112
136
  token = Sessions.generate_token
113
137
  request = warden.request
114
138
  model = Sessions.session_model
@@ -125,9 +149,10 @@ module Sessions
125
149
  end
126
150
  end
127
151
  row.sessions_suppress_login_event = suppress_login_event
152
+ row.sessions_skip_supersede = skip_supersede
128
153
  Sessions.with_request(request) { row.save! }
129
154
 
130
- warden.session(scope)[SESSION_KEY] = [row.id, token]
155
+ warden.session(scope)[SESSION_KEY] = tracking_state(row, token: token, mode: "credential")
131
156
  row
132
157
  end
133
158
 
@@ -143,40 +168,83 @@ module Sessions
143
168
  session_data = warden.session(scope)
144
169
  next :skip if session_data[SKIP_SESSION_KEY]
145
170
 
146
- session_data[SESSION_KEY]
171
+ {
172
+ login: session_data[SESSION_KEY],
173
+ pending_login: session_data[PENDING_LOGIN_KEY]
174
+ }
147
175
  end
148
176
  return if data == :skip
149
177
 
150
- if data.nil?
151
- adopt_preexisting_session(record, warden, scope)
178
+ tracking = parse_tracking_state(data && data[:login])
179
+ if tracking.nil?
180
+ if data && data[:pending_login]
181
+ activate_pending_login(record, warden, scope, data[:pending_login])
182
+ else
183
+ adopt_preexisting_session(record, warden, scope)
184
+ end
152
185
  return
153
186
  end
154
187
 
155
188
  # The lookup is NOT wrapped in `safely`: an ERRORED lookup and a
156
- # MISSING row must be distinguishable. A row that's genuinely gone
157
- # (or a token that doesn't match) means revocation → kick. A raised
158
- # lookup the sessions table unreachable, a timeout, a migration
159
- # mid-deploy means the TRACKING layer is down, and tracking must
160
- # never break authentication: fail OPEN, let the request through
161
- # 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.
162
199
  begin
163
- id, token = data
164
- found = Sessions.session_model.find_by(id: id)
165
- row = found if found&.sessions_token_matches?(token)
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])
166
220
  rescue StandardError => e
167
221
  Sessions.warn("warden.fetch failed open: #{e.class}: #{e.message}")
168
222
  return
169
223
  end
170
224
 
171
225
  if row.nil?
172
- # Revoked (the row is gone) or tampered (digest mismatch): the
173
- # proven session_limitable sequence log the scope out and hand
174
- # control to the failure app. NOT wrapped in `safely`: the throw
175
- # is control flow, not an error.
176
- 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
177
242
  elsif Sessions.safely("warden.expired?") { row.sessions_expired? }
178
- Sessions.safely("warden.expire") { row.revoke!(reason: :expired) }
179
- 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")
180
248
  else
181
249
  Sessions.safely("warden.touch") { row.touch_last_seen!(warden.request) }
182
250
  end
@@ -190,6 +258,7 @@ module Sessions
190
258
  def adopt_preexisting_session(record, warden, scope)
191
259
  Sessions.safely("warden.adopt") do
192
260
  next unless row_accepts?(record)
261
+ next unless document_request?(warden.request)
193
262
 
194
263
  # IDEMPOTENT, because a client that can't persist cookies re-enters
195
264
  # adoption on EVERY request: the SESSION_KEY we write rides a
@@ -212,11 +281,154 @@ module Sessions
212
281
  end
213
282
  end
214
283
 
284
+ def activate_pending_login(record, warden, scope, pending_login)
285
+ Sessions.safely("warden.pending_login") do
286
+ next unless row_accepts?(record)
287
+ next unless document_request?(warden.request)
288
+
289
+ if (row = pending_existing_row(record, warden, scope, pending_login))
290
+ attach_existing_row(row, warden, scope)
291
+ warden.session(scope).delete(PENDING_LOGIN_KEY)
292
+ next row
293
+ end
294
+
295
+ row = create_row_for(record, warden, scope, attributes: pending_login_attributes(pending_login))
296
+ warden.session(scope).delete(PENDING_LOGIN_KEY)
297
+ row
298
+ end
299
+ end
300
+
301
+ def deferred_login_request?(request, auth)
302
+ remembered_login?(auth) && !document_request?(request)
303
+ end
304
+
305
+ def remembered_login?(auth)
306
+ detail = auth[:detail].to_h
307
+ detail["remembered"] || detail[:remembered]
308
+ rescue StandardError
309
+ false
310
+ end
311
+
312
+ def stash_pending_login(warden, scope, auth)
313
+ warden.session(scope)[PENDING_LOGIN_KEY] = login_auth_attributes(auth).transform_keys(&:to_s)
314
+ end
315
+
316
+ def login_auth_attributes(auth)
317
+ {
318
+ auth_method: auth[:method],
319
+ auth_provider: auth[:provider],
320
+ auth_detail: auth[:detail].presence
321
+ }.compact
322
+ end
323
+
324
+ def pending_login_attributes(attributes)
325
+ attributes.to_h.slice("auth_method", "auth_provider", "auth_detail")
326
+ rescue StandardError
327
+ {}
328
+ end
329
+
330
+ def pending_existing_row(record, warden, scope, pending_login)
331
+ auth = { detail: pending_login.to_h["auth_detail"] || {} }
332
+ remembered_existing_row(record, warden, scope, auth)
333
+ end
334
+
335
+ def remembered_existing_row(record, warden, scope, auth)
336
+ return unless remembered_login?(auth)
337
+
338
+ device_id = device_id_from_request(warden.request)
339
+ return if device_id.blank?
340
+
341
+ rows = Sessions.session_model.live.where(user: record, device_id: device_id)
342
+ rows = rows.where(scope: scope.to_s) if Sessions.session_model.column_names.include?("scope")
343
+ rows.order(created_at: :desc).first
344
+ rescue StandardError
345
+ nil
346
+ end
347
+
348
+ def attach_existing_row(row, warden, scope)
349
+ warden.session(scope)[SESSION_KEY] = tracking_state(row, mode: "hint")
350
+ Sessions.safely("warden.remembered_existing.touch") { row.touch_last_seen!(warden.request) }
351
+ row
352
+ end
353
+
354
+ def existing_row_session?(row, record, scope, request)
355
+ return false unless row
356
+
357
+ device_id = device_id_from_request(request)
358
+ return false if device_id.blank?
359
+ return false unless row.try(:device_id) == device_id
360
+ return false unless row.user == record
361
+ return false if row.respond_to?(:scope) && row.scope.present? && row.scope != scope.to_s
362
+
363
+ true
364
+ rescue StandardError
365
+ false
366
+ end
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
+
381
+ def device_id_from_request(request)
382
+ return unless request.respond_to?(:cookie_jar)
383
+
384
+ request.cookie_jar.signed[Sessions::DEVICE_COOKIE].presence
385
+ rescue StandardError
386
+ nil
387
+ end
388
+
389
+ def document_request?(request)
390
+ return true unless request
391
+ return false if non_document_path?(request)
392
+
393
+ accept = request_header(request, "HTTP_ACCEPT").to_s
394
+ return true if accept.empty? || accept == "*/*"
395
+ return true if accept.match?(%r{\btext/html\b|\bapplication/xhtml\+xml\b|\btext/vnd\.turbo-stream\.html\b})
396
+ return false if accept.match?(%r{\b(?:application|text)/(?:[\w.+-]+\+)?json\b})
397
+ return false if request_header(request, "HTTP_X_REQUESTED_WITH").to_s.casecmp("XMLHttpRequest").zero?
398
+
399
+ if request.respond_to?(:format)
400
+ format = request.format
401
+ return true if format.respond_to?(:html?) && format.html?
402
+ return false if format.respond_to?(:json?) && format.json?
403
+ end
404
+
405
+ true
406
+ rescue StandardError
407
+ true
408
+ end
409
+
410
+ def non_document_path?(request)
411
+ path = if request.respond_to?(:path)
412
+ request.path
413
+ elsif request.respond_to?(:path_info)
414
+ request.path_info
415
+ end
416
+ File.extname(path.to_s).delete(".").casecmp("json").zero?
417
+ end
418
+
419
+ def request_header(request, key)
420
+ if request.respond_to?(:get_header)
421
+ request.get_header(key)
422
+ elsif request.respond_to?(:env)
423
+ request.env[key]
424
+ end
425
+ end
426
+
215
427
  def create_adopted_row(record, warden, scope, adoption_key:)
216
428
  attributes = { auth_detail: { "adopted" => true } }
217
429
  attributes[:adoption_key] = adoption_key if adoption_key_column?
218
430
 
219
- create_row_for(record, warden, scope, suppress_login_event: true, attributes: attributes)
431
+ create_row_for(record, warden, scope, suppress_login_event: true, skip_supersede: true, attributes: attributes)
220
432
  rescue ActiveRecord::RecordNotUnique
221
433
  adopted_row(record, scope, adoption_key: adoption_key)&.tap do |row|
222
434
  Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
@@ -225,10 +437,10 @@ module Sessions
225
437
 
226
438
  def adopted_row(record, scope, adoption_key:)
227
439
  model = Sessions.session_model
228
- rows = model.where(user: record)
440
+ rows = model.live.where(user: record)
229
441
  rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
230
442
 
231
- 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?
232
444
  row = nil unless adopted_row?(row)
233
445
  row ||= rows.order(created_at: :desc).detect { |candidate| adopted_row?(candidate) }
234
446
 
@@ -268,13 +480,60 @@ module Sessions
268
480
  model.column_names.include?("adoption_key")
269
481
  end
270
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
+
271
530
  # SCOPE-PRECISE teardown: only this scope's warden entries go (the
272
531
  # serialized user key and our token stash) — an admin scope riding
273
532
  # the same rack session, and unrelated host session data (carts,
274
533
  # locale, return-to paths), survive a user-scope kick. Deleting the
275
534
  # keys BEFORE logout matters: our before_logout hook then finds no
276
- # token and records nothing (a kick is not a logout — the revocation
277
- # 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).
278
537
  def kick!(warden, scope)
279
538
  warden.raw_session.delete("warden.user.#{scope}.key")
280
539
  warden.raw_session.delete("warden.user.#{scope}.session")
@@ -322,9 +581,8 @@ module Sessions
322
581
  # --- Hook 4: logout ---------------------------------------------------------
323
582
 
324
583
  # Fires once per scope (including forced logouts: timeout, lockout,
325
- # our own revocation kick). If the row is already gone revoked from
326
- # another device — there's nothing to do; the `revoked` event was
327
- # 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.
328
586
  #
329
587
  # CRITICAL: read the RAW session here, never `warden.session(scope)`.
330
588
  # Warden's logout deletes `@users[scope]` BEFORE running before_logout
@@ -333,19 +591,29 @@ module Sessions
333
591
  # logout came from a hook that logs out and throws (Devise's
334
592
  # activatable on unconfirmed/locked accounts, timeoutable) that loops:
335
593
  # activatable → logout → us → re-auth → activatable → … SystemStackError.
336
- def record_logout(_record, warden, opts)
337
- Sessions.safely("warden.logout") do
338
- scope = opts[:scope]
339
- data = warden.raw_session["warden.user.#{scope}.session"]&.dig(SESSION_KEY)
340
- next unless data
341
-
342
- id, token = data
343
- row = Sessions.session_model.find_by(id: id)
344
- next unless row&.sessions_token_matches?(token)
345
-
346
- row.revocation_reason ||= :logout
347
- row.destroy
348
- end
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
598
+
599
+ row = Sessions.session_model.find_by(id: tracking[:id])
600
+ return unless row
601
+ return unless row.live?
602
+
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
349
617
  end
350
618
 
351
619
  def warden_default_scope(env)
@@ -368,6 +636,14 @@ module Sessions
368
636
  rescue StandardError
369
637
  false
370
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
371
647
  end
372
648
  end
373
649
  end
@@ -77,7 +77,7 @@ module Sessions
77
77
  from_omniauth(request) ||
78
78
  from_warden(request) ||
79
79
  from_google_sign_in(request) ||
80
- from_password_post(request) ||
80
+ from_password_request(request) ||
81
81
  blank
82
82
  rescue StandardError => e
83
83
  Sessions.warn("auth classification failed: #{e.class}: #{e.message}")
@@ -176,10 +176,11 @@ module Sessions
176
176
  nil
177
177
  end
178
178
 
179
- # A POST that exchanged a password for a session IS a password login —
180
- # covers the omakase SessionsController and hand-rolled password forms.
181
- def from_password_post(request)
182
- return unless request.respond_to?(:post?) && request.post?
179
+ # A non-idempotent request that exchanged a password for a session IS a
180
+ # password login — covers the omakase SessionsController, hand-rolled
181
+ # password forms, and Devise password-reset PATCHes that sign the user in.
182
+ def from_password_request(request)
183
+ return unless credential_request?(request)
183
184
 
184
185
  params = request.params
185
186
  return unless params.is_a?(Hash) || params.respond_to?(:[])
@@ -190,6 +191,18 @@ module Sessions
190
191
  nil
191
192
  end
192
193
 
194
+ def credential_request?(request)
195
+ method = if request.respond_to?(:request_method)
196
+ request.request_method
197
+ elsif request.respond_to?(:method)
198
+ request.method
199
+ end
200
+
201
+ !%w[GET HEAD OPTIONS].include?(method.to_s.upcase)
202
+ rescue StandardError
203
+ false
204
+ end
205
+
193
206
  def password_param?(params)
194
207
  return true if params["password"].present?
195
208
 
@@ -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