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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +11 -0
- data/lib/generators/sessions/templates/add_adoption_key_to_sessions.rb.erb +14 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +7 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +7 -0
- data/lib/generators/sessions/templates/madmin/session_resource.rb +1 -0
- data/lib/generators/sessions/upgrade_generator.rb +53 -0
- data/lib/sessions/adapters/warden.rb +70 -20
- data/lib/sessions/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f088a81ac84fec5df0bfd521c3d4012fa6a91a4a84e22e997e22d18ef1658b1b
|
|
4
|
+
data.tar.gz: d50021247992ad1bef65b9a4d5f678146e774501b6adaa0af87840ec1e8ce988
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
data/lib/sessions/version.rb
CHANGED
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.
|
|
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-
|
|
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
|