sessions 0.1.1 → 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7a11c0aa8a096b0cc19e25a62967da2414cdf8732a2587cdd9a23ed7d989fd67
4
- data.tar.gz: ea2e6a1fa8a96771886c86e74d6dfa391fffa4138e825006511222dd221652a4
3
+ metadata.gz: f088a81ac84fec5df0bfd521c3d4012fa6a91a4a84e22e997e22d18ef1658b1b
4
+ data.tar.gz: d50021247992ad1bef65b9a4d5f678146e774501b6adaa0af87840ec1e8ce988
5
5
  SHA512:
6
- metadata.gz: d4aa37c48143f477dc820e85b8a44158682f4f40991411bb382af74fefd20d0001050dd2e7324830cea73e4edc459e252f3762aaea034bd4386bacda488f088c
7
- data.tar.gz: d29228d847cb0acd7e6b348a872de1ae988afdbe5e70c69509f24a41aa18992109f57ba2964cddc66a08a6f09802cbdfe9a55d1a8d9fc4e6d01caeb92d9db6a8
6
+ metadata.gz: ab013f9e59218a51e6f580deaa8d1bf01e45a4fd22eded95d91e1d4e2ba434d433bceedddf06d349dcfdfa63fd2b3678c8aaa9742729353da439e6e01778115e
7
+ data.tar.gz: 10eabd4758d01e1cf0ed44df351f8182dfe27c80f4fec7ee2024f0500da199a1ca356c77c72d444efface0c56f091f9d1312c9d46a2c2bd80ea77a6115fe94a9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2 (2026-06-16)
4
+
5
+ Production-found hardening for Devise/Warden adoption, the path that turns already-authenticated pre-gem sessions into registry rows.
6
+
7
+ - **Adoption is now keyed by owner + scope, not user agent or time.** One pre-existing authenticated session marker is enough; Hotwire Native devices legitimately present both a WebView UA and a native HTTP-client UA, and clients that drop `Set-Cookie` responses must not mint one adopted row per UA or per day.
8
+ - **Concurrent adoption bursts are now atomic when the upgrade migration has run.** Adopted rows get a nullable `adoption_key` with a plain unique index. Normal sessions keep it `NULL`, so the index behaves like a portable partial unique index across PostgreSQL, MySQL and SQLite. Racing requests either create the one keyed row or catch the uniqueness collision, re-select it, and touch it — still inside `Sessions.safely`, so tracking never breaks login.
9
+ - **Existing installs have an explicit upgrade path.** Run `rails generate sessions:upgrade` and `rails db:migrate` to add `sessions.adoption_key`. Fresh installs get the column from `sessions:install`.
10
+ - **The adapter degrades gracefully before the migration.** If `adoption_key` is absent, it still reuses any existing adopted row for the same owner + scope, eliminating the UA split and 24-hour leak while the schema upgrade is pending; the database-level race guarantee starts once the migration is deployed.
11
+
3
12
  ## 0.1.1 (2026-06-12)
4
13
 
5
14
  Production-found fix, hours after 0.1.0: a client that forwards cookies **read-only** — the canonical case is a native app's HTTP layer that attaches the WebView's cookie but drops `Set-Cookie` responses — re-enters the Devise-mode *adoption* path on every request, because the session token the gem writes never persists client-side. Each pass minted a fresh adopted row (and adoption also skipped the per-user cap), so one phone on a polling screen accumulated hundreds of "live devices" in an hour.
data/README.md CHANGED
@@ -96,6 +96,17 @@ That's it. Every sign-in from now on lands on the devices page and in the trail
96
96
  >
97
97
  > **API-only app?** The gem boots and tracks fine (use the model APIs and `Sessions.track_login`), but the devices page is HTML — mounting the engine needs an `ActionController::Base`-derived `config.parent_controller`, which API-only apps don't have.
98
98
 
99
+ ### Upgrading
100
+
101
+ Existing apps upgrading from 0.1.0 or 0.1.1 should copy the upgrade migration and run it:
102
+
103
+ ```bash
104
+ rails generate sessions:upgrade
105
+ rails db:migrate
106
+ ```
107
+
108
+ This adds `sessions.adoption_key`, the portable unique guard that makes pre-gem session adoption atomic under concurrent native-app request bursts. Fresh installs already get it from `sessions:install`.
109
+
99
110
  ## What `sessions` does (and doesn't) do
100
111
 
101
112
  **Does:**
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ # v0.1.2 upgrade: adopted rows are keyed by owner+scope so clients that never
4
+ # persist Set-Cookie responses cannot create unbounded duplicate sessions.
5
+ # Existing adopted rows keep NULL keys; the adapter claims one lazily on the
6
+ # next request. Normal, non-adopted sessions also keep NULL, so this plain
7
+ # unique index behaves like a portable partial unique index across PostgreSQL,
8
+ # MySQL and SQLite.
9
+ class AddSessionsAdoptionKeyTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
10
+ def change
11
+ add_column :<%= table_name %>, :adoption_key, :string unless column_exists?(:<%= table_name %>, :adoption_key)
12
+ add_index :<%= table_name %>, :adoption_key, unique: true unless index_exists?(:<%= table_name %>, :adoption_key)
13
+ end
14
+ end
@@ -17,6 +17,12 @@ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%
17
17
  # The Warden scope ("user") — multi-scope Devise apps.
18
18
  t.string :scope
19
19
 
20
+ # Adopted sessions (pre-gem logins that had no sessions token yet)
21
+ # get one deterministic owner+scope key. Normal sessions keep this
22
+ # NULL, so the unique index below constrains only adopted rows while
23
+ # staying portable across PostgreSQL, MySQL and SQLite.
24
+ t.string :adoption_key
25
+
20
26
  # How this session started: password / oauth / google_one_tap /
21
27
  # passkey / magic_link / otp / sso / token / unknown — plus the
22
28
  # provider ("google", "apple", "github") and per-method extras
@@ -62,6 +68,7 @@ class AddSessionsColumnsTo<%= table_name.camelize %> < ActiveRecord::Migration<%
62
68
 
63
69
  add_index :<%= table_name %>, :device_id
64
70
  add_index :<%= table_name %>, :token_digest, unique: true
71
+ add_index :<%= table_name %>, :adoption_key, unique: true
65
72
  add_index :<%= table_name %>, :auth_method
66
73
  add_index :<%= table_name %>, :auth_provider
67
74
  add_index :<%= table_name %>, :country_code
@@ -30,6 +30,12 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
30
30
  # The Warden scope ("user") — multi-scope Devise apps.
31
31
  t.string :scope
32
32
 
33
+ # Adopted sessions (pre-gem logins that had no sessions token yet)
34
+ # get one deterministic owner+scope key. Normal sessions keep this
35
+ # NULL, so the unique index below constrains only adopted rows while
36
+ # staying portable across PostgreSQL, MySQL and SQLite.
37
+ t.string :adoption_key
38
+
33
39
  # How this session started: password / oauth / google_one_tap /
34
40
  # passkey / magic_link / otp / sso / token / unknown — plus the
35
41
  # provider and per-method extras.
@@ -71,6 +77,7 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
71
77
 
72
78
  add_index :<%= table_name %>, :device_id
73
79
  add_index :<%= table_name %>, :token_digest, unique: true
80
+ add_index :<%= table_name %>, :adoption_key, unique: true
74
81
  add_index :<%= table_name %>, :auth_method
75
82
  add_index :<%= table_name %>, :auth_provider
76
83
  add_index :<%= table_name %>, :country_code
@@ -36,6 +36,7 @@ class SessionResource < Madmin::Resource
36
36
  # NEVER rendered: the token digest is a credential hash, and the raw
37
37
  # header blobs are noise next to the parsed columns above.
38
38
  attribute :token_digest, show: false, form: false
39
+ attribute :adoption_key, show: false, form: false
39
40
  attribute :auth_detail, show: false, form: false
40
41
  attribute :client_hints, show: false, form: false
41
42
 
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Sessions
7
+ module Generators
8
+ # `rails generate sessions:upgrade` — copies version-to-version
9
+ # migrations for apps that installed a prior release. The install
10
+ # generator remains the source for fresh apps; this generator gives
11
+ # existing hosts an explicit, reviewable upgrade path.
12
+ class UpgradeGenerator < Rails::Generators::Base
13
+ include ActiveRecord::Generators::Migration
14
+
15
+ source_root File.expand_path("templates", __dir__)
16
+ desc "Install sessions upgrade migrations"
17
+
18
+ class_option :model, type: :string, default: "Session",
19
+ desc: "Session model name (escape hatch for apps with a custom session class)"
20
+
21
+ def self.next_migration_number(dir)
22
+ ActiveRecord::Generators::Base.next_migration_number(dir)
23
+ end
24
+
25
+ def create_upgrade_migrations
26
+ migration_template "add_adoption_key_to_sessions.rb.erb",
27
+ File.join(db_migrate_path, "add_sessions_adoption_key_to_#{table_name}.rb")
28
+ end
29
+
30
+ def display_post_upgrade_message
31
+ say "\n🔐 sessions upgrade migrations have been installed.", :green
32
+ say "\nTo complete the upgrade:"
33
+ say " 1. Review the generated migration."
34
+ say " 2. Run 'rails db:migrate'."
35
+ say " ⚠️ Deploy the migration before relying on adoption hardening.", :yellow
36
+ end
37
+
38
+ private
39
+
40
+ def migration_version
41
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
42
+ end
43
+
44
+ def model_name
45
+ options[:model].presence || "Session"
46
+ end
47
+
48
+ def table_name
49
+ model_name.underscore.pluralize.tr("/", "_")
50
+ end
51
+ end
52
+ end
53
+ end
@@ -108,17 +108,22 @@ module Sessions
108
108
  end
109
109
  end
110
110
 
111
- def create_row_for(record, warden, scope, suppress_login_event: false)
111
+ def create_row_for(record, warden, scope, suppress_login_event: false, attributes: {})
112
112
  token = Sessions.generate_token
113
113
  request = warden.request
114
+ model = Sessions.session_model
114
115
 
115
- row = Sessions.session_model.new(
116
+ row = model.new(
116
117
  user: record,
117
118
  scope: scope.to_s,
118
119
  ip_address: Sessions::IpAddress.resolve(request),
119
120
  user_agent: request.user_agent,
120
121
  token_digest: Sessions.token_digest(token)
121
- )
122
+ ).tap do |session|
123
+ attributes.each do |column, value|
124
+ session[column] = value if model.column_names.include?(column.to_s)
125
+ end
126
+ end
122
127
  row.sessions_suppress_login_event = suppress_login_event
123
128
  Sessions.with_request(request) { row.save! }
124
129
 
@@ -188,34 +193,79 @@ module Sessions
188
193
 
189
194
  # IDEMPOTENT, because a client that can't persist cookies re-enters
190
195
  # adoption on EVERY request: the SESSION_KEY we write rides a
191
- # Set-Cookie the client drops, so the next request adopts again
192
- # unbounded rows (production-found: a native HTTP layer that
193
- # forwarded cookies read-only minted one adopted row per location
194
- # ping, hundreds per ride). Same user, same scope, same UA,
195
- # recently adopted that's this same client: touch it, mint
196
- # nothing. No token rotation either a sibling client sharing the
197
- # cookie jar (the app's WebView next to its native HTTP stack) may
198
- # hold a VALID key to this row, and rotating would kick it.
199
- if (row = recent_adopted_row(record, warden, scope))
196
+ # Set-Cookie the client drops, so the next request adopts again.
197
+ #
198
+ # Adoption is intentionally coarse: it is a low-fidelity marker for
199
+ # "this owner already had an authenticated session when the gem
200
+ # arrived", not a real login. One owner+scope marker is enough. Do
201
+ # not key it on UA (Hotwire Native devices legitimately use WebView
202
+ # and native-client UAs) or time (cookie-dropping clients would mint
203
+ # one per day forever). When the adoption_key column is present, the
204
+ # unique index makes the first concurrent burst atomic.
205
+ adoption_key = adoption_key_for(record, scope)
206
+ if (row = adopted_row(record, scope, adoption_key: adoption_key))
200
207
  Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
201
208
  next
202
209
  end
203
210
 
204
- row = create_row_for(record, warden, scope, suppress_login_event: true)
205
- row&.update_columns(auth_detail: { "adopted" => true })
211
+ create_adopted_row(record, warden, scope, adoption_key: adoption_key)
212
+ end
213
+ end
214
+
215
+ def create_adopted_row(record, warden, scope, adoption_key:)
216
+ attributes = { auth_detail: { "adopted" => true } }
217
+ attributes[:adoption_key] = adoption_key if adoption_key_column?
218
+
219
+ create_row_for(record, warden, scope, suppress_login_event: true, attributes: attributes)
220
+ rescue ActiveRecord::RecordNotUnique
221
+ adopted_row(record, scope, adoption_key: adoption_key)&.tap do |row|
222
+ Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
206
223
  end
207
224
  end
208
225
 
209
- def recent_adopted_row(record, warden, scope)
226
+ def adopted_row(record, scope, adoption_key:)
210
227
  model = Sessions.session_model
211
228
  rows = model.where(user: record)
212
229
  rows = rows.where(scope: scope.to_s) if model.column_names.include?("scope")
213
- rows = rows.where(user_agent: warden.request.user_agent) if model.column_names.include?("user_agent")
214
230
 
215
- rows.where(model.arel_table[:created_at].gt(24.hours.ago))
216
- .order(created_at: :desc)
217
- .limit(10)
218
- .detect { |row| row.try(:auth_detail).to_h["adopted"] }
231
+ row = model.find_by(adoption_key: adoption_key) if adoption_key_column?(model) && adoption_key.present?
232
+ row = nil unless adopted_row?(row)
233
+ row ||= rows.order(created_at: :desc).detect { |candidate| adopted_row?(candidate) }
234
+
235
+ claim_adoption_key(row, adoption_key)
236
+ end
237
+
238
+ def adopted_row?(row)
239
+ row && row.try(:auth_detail).to_h["adopted"]
240
+ end
241
+
242
+ def claim_adoption_key(row, adoption_key)
243
+ return row unless row
244
+ return row unless adoption_key_column?(row.class)
245
+ return row if adoption_key.blank? || row.try(:adoption_key).present?
246
+
247
+ row.update_columns(adoption_key: adoption_key)
248
+ row.adoption_key = adoption_key
249
+ row
250
+ rescue ActiveRecord::RecordNotUnique
251
+ row.class.find_by(adoption_key: adoption_key) || row
252
+ end
253
+
254
+ def adoption_key_for(record, scope)
255
+ owner_id = record.respond_to?(:to_key) ? Array(record.to_key).join("/") : record.try(:id)
256
+ return if owner_id.blank?
257
+
258
+ owner_type = if record.class.respond_to?(:polymorphic_name)
259
+ record.class.polymorphic_name
260
+ else
261
+ record.class.name
262
+ end
263
+
264
+ "adopt:#{Sessions.token_digest([owner_type, owner_id, scope.to_s].join("\0"))}"
265
+ end
266
+
267
+ def adoption_key_column?(model = Sessions.session_model)
268
+ model.column_names.include?("adoption_key")
219
269
  end
220
270
 
221
271
  # SCOPE-PRECISE teardown: only this scope's warden entries go (the
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sessions
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sessions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-12 00:00:00.000000000 Z
10
+ date: 2026-06-16 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: actionpack
@@ -163,6 +163,7 @@ files:
163
163
  - gemfiles/rails_8.1.gemfile
164
164
  - lib/generators/sessions/install_generator.rb
165
165
  - lib/generators/sessions/madmin_generator.rb
166
+ - lib/generators/sessions/templates/add_adoption_key_to_sessions.rb.erb
166
167
  - lib/generators/sessions/templates/add_sessions_columns.rb.erb
167
168
  - lib/generators/sessions/templates/create_sessions.rb.erb
168
169
  - lib/generators/sessions/templates/create_sessions_events.rb.erb
@@ -173,6 +174,7 @@ files:
173
174
  - lib/generators/sessions/templates/madmin/sessions_controller.rb
174
175
  - lib/generators/sessions/templates/session.rb.erb
175
176
  - lib/generators/sessions/templates/sessions_sweep_job.rb
177
+ - lib/generators/sessions/upgrade_generator.rb
176
178
  - lib/generators/sessions/views_generator.rb
177
179
  - lib/sessions.rb
178
180
  - lib/sessions/adapters/omakase.rb