sessions 0.1.0 → 0.1.1

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: 342cda70d0a4f3ed6b760d8dee9ff8dea68c97151d932e77c478474e216fe9ef
4
- data.tar.gz: 6885d70e1cc6cbb5ebd9850c0b3f5d015ce22d1aeae5ba2968b0fd5bb33412e0
3
+ metadata.gz: 7a11c0aa8a096b0cc19e25a62967da2414cdf8732a2587cdd9a23ed7d989fd67
4
+ data.tar.gz: ea2e6a1fa8a96771886c86e74d6dfa391fffa4138e825006511222dd221652a4
5
5
  SHA512:
6
- metadata.gz: a15d061c3b9397265aa2e7581ed5e06ce64ae50943a54aa71b45539dfe528153004422b9c782d1dcd5cd2b62a55edef8612ed26c4f8a55ee8d91c0379f3d9133
7
- data.tar.gz: 4ecef8ccd7f56454a31feb6623245c38585dbae7601fdb681daa6192e764964a4c47300dc3b8aaaa6dbe2811246a66c304210d202e5a6f9e46c40085d34a246f
6
+ metadata.gz: d4aa37c48143f477dc820e85b8a44158682f4f40991411bb382af74fefd20d0001050dd2e7324830cea73e4edc459e252f3762aaea034bd4386bacda488f088c
7
+ data.tar.gz: d29228d847cb0acd7e6b348a872de1ae988afdbe5e70c69509f24a41aa18992109f57ba2964cddc66a08a6f09802cbdfe9a55d1a8d9fc4e6d01caeb92d9db6a8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1 (2026-06-12)
4
+
5
+ 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.
6
+
7
+ - **Adoption is now idempotent**: a re-entering client matches its recent adopted row (same user, scope, and user agent within 24h) and just touches it — no new row, no token rotation (rotating would kick a sibling client validly holding that row's token, e.g. the app's WebView next to its native HTTP stack).
8
+ - **The per-user session cap now applies to suppressed (adopted) writes too** — it's the hard limit on live rows, whatever path creates them.
9
+
10
+ Plus a full-codebase audit pass:
11
+
12
+ - **The remote-revocation kick now ships its own flash copy.** The Warden adapter throws `message: :session_revoked`, and Devise's failure app resolves it via `devise.failure.session_revoked` — a key nothing shipped, so revoked devices saw a literal "Translation missing: en.devise.failure.user.session_revoked" at the exact moment the flagship feature fired. The gem now provides the key in English and Spanish (override it like any I18n key), pinned by a test that mirrors Devise's exact lookup (`I18n.t(:"#{scope}.#{message}", scope: "devise.failure", default: [message])`).
13
+ - **`config.strategy_methods` now truly overrides built-ins on a shared key.** The mapping merge let the built-in value win a duplicate key (e.g. remapping `"Rememberable"`), contradicting the documented host-wins contract; host entries now win both the iteration order and key conflicts.
14
+ - **Failed-login identity capture also reads `email_address`** — the omakase-era key, and what Devise apps with `authentication_keys = [:email_address]` post. Those failures used to record `identity: nil`, quietly gutting ATO triage and `repeated_failed_logins` for that setup.
15
+ - **The installer now validates the Devise auth model fit.** Default (non-polymorphic) mode assumes a `User` class — the same assumption `rails generate authentication` makes. A Devise app on `Member`/`Account` used to pass detection and then break at `db:migrate` (foreign key to a missing `users` table); the installer now reads `Devise.mappings` and stops with the fix (`--polymorphic`) before writing anything, and warns (without blocking) when extra scopes ride alongside `User`, since those stay silently untracked in default mode. Unreadable mappings stay permissive. README gained the matching callout.
16
+ - **API-only hosts boot.** The engine controller's class body used `helper`/`helper_method`/`layout` unconditionally — none of which exist on `ActionController::API` — and production eager-loads engine controllers whether or not the engine is mounted, so an API-only app bundling the gem for the model/trail APIs failed to boot. The view-layer DSL is now capability-guarded (mounting the HTML page still requires a `Base`-derived parent, as documented).
17
+ - **Generated migrations honor exotic primary-key types.** `primary_key_type: :string` (ULID-style apps) now flows through to the events table's PK and the `session_id` linkage column (previously coerced to `bigint`, breaking the trail↔registry join), and the serial pseudo-types map to their plain integer column equivalents for reference columns — a `t.bigserial` reference would mint its own sequence.
18
+ - `sessions_format_date`/`sessions_format_time` tolerate `nil` (custom views passing a nullable column used to hit `nil.strftime` *inside* the fallback rescue).
19
+
3
20
  ## 0.1.0 (2026-06-12)
4
21
 
5
22
  First release — the missing session layer for Rails. 🔐
data/README.md CHANGED
@@ -91,6 +91,11 @@ end
91
91
 
92
92
  That's it. Every sign-in from now on lands on the devices page and in the trail — on Rails 8 auth there is literally nothing else to wire (the gem decorates the generated `Session` model automatically; your app code stays untouched).
93
93
 
94
+ > [!NOTE]
95
+ > **Auth model isn't `User`?** (Devise on `Member`/`Account`, or several `devise_for` scopes): install with `rails generate sessions:install --polymorphic` — the session owner becomes polymorphic and every scope/model gets tracked; put `has_sessions` on each of them. The default install assumes a `User` class (the same assumption `rails generate authentication` makes), and the installer stops with exactly this advice when your Devise mappings don't fit it.
96
+ >
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
+
94
99
  ## What `sessions` does (and doesn't) do
95
100
 
96
101
  **Does:**
@@ -33,12 +33,19 @@ module Sessions
33
33
 
34
34
  before_action :sessions_authenticate!
35
35
 
36
- helper Sessions::EngineHelper
37
- helper_method :sessions_current_user, :sessions_current_session
36
+ # The view-layer DSL (`helper`, `helper_method`, `layout`) doesn't exist
37
+ # on ActionController::API — and an API-only host that bundles the gem
38
+ # purely for the model/trail APIs still EAGER LOADS this class in
39
+ # production, mounted or not. Guarding keeps such hosts bootable; the
40
+ # HTML devices page itself still requires a Base-derived
41
+ # config.parent_controller (the default), which is the only setup it's
42
+ # rendered under.
43
+ helper Sessions::EngineHelper if respond_to?(:helper)
44
+ helper_method :sessions_current_user, :sessions_current_session if respond_to?(:helper_method)
38
45
 
39
46
  # nil falls through to the parent controller's regular layout
40
47
  # resolution, so by default the page looks like the rest of the host.
41
- layout :sessions_layout
48
+ layout :sessions_layout if respond_to?(:layout)
42
49
 
43
50
  private
44
51
 
@@ -102,14 +102,21 @@ module Sessions
102
102
 
103
103
  # "Signed in May 2, 2026" — localized when the host bundles date
104
104
  # formats (rails-i18n or its own locale files), with a safe fallback so
105
- # a bare host never 500s over a missing `date.formats.long`.
105
+ # a bare host never 500s over a missing `date.formats.long`. nil-safe:
106
+ # without the guard, I18n.l(nil) raises I18n::ArgumentError and the
107
+ # rescue would then call nil.strftime — a trap for custom views passing
108
+ # a nullable column.
106
109
  def sessions_format_date(date)
110
+ return nil unless date
111
+
107
112
  I18n.l(date, format: :long)
108
113
  rescue I18n::MissingTranslationData, I18n::ArgumentError
109
114
  date.strftime("%Y-%m-%d")
110
115
  end
111
116
 
112
117
  def sessions_format_time(time)
118
+ return nil unless time
119
+
113
120
  I18n.l(time, format: :short)
114
121
  rescue I18n::MissingTranslationData, I18n::ArgumentError
115
122
  time.strftime("%Y-%m-%d %H:%M")
@@ -57,3 +57,14 @@ en:
57
57
  composite: "%{client} on %{platform}"
58
58
  unknown: "Unknown device"
59
59
  bot: "Bot (%{name})"
60
+ # The flash a remotely-revoked device sees on its next request, rendered by
61
+ # Devise's failure app (the Warden adapter kicks with
62
+ # `throw :warden, message: :session_revoked`). Devise's lookup is
63
+ # `I18n.t(:"#{scope}.#{message}", scope: "devise.failure", default: [message])`
64
+ # — see Devise::FailureApp#i18n_message (devise/lib/devise/failure_app.rb).
65
+ # Without this key the flash literally reads
66
+ # "Translation missing: en.devise.failure.user.session_revoked".
67
+ # Hosts override it like any I18n key (app locale files load after engines).
68
+ devise:
69
+ failure:
70
+ session_revoked: "That session was signed out remotely. Please sign in again."
@@ -57,3 +57,10 @@ es:
57
57
  composite: "%{client} en %{platform}"
58
58
  unknown: "Dispositivo desconocido"
59
59
  bot: "Bot (%{name})"
60
+ # El aviso que ve un dispositivo cuya sesión fue revocada en remoto, en su
61
+ # siguiente petición (lo muestra la failure app de Devise; el adaptador de
62
+ # Warden expulsa con `throw :warden, message: :session_revoked`). Sin esta
63
+ # clave, el flash mostraría literalmente "Translation missing: …".
64
+ devise:
65
+ failure:
66
+ session_revoked: "Esa sesión se cerró de forma remota. Vuelve a iniciar sesión."
@@ -78,6 +78,48 @@ module Sessions
78
78
  MSG
79
79
  end
80
80
 
81
+ # Default (non-polymorphic) mode assumes a `User` class — the same
82
+ # assumption `rails generate authentication` makes: `belongs_to :user`
83
+ # and a foreign key to `users`. A Devise app whose auth model is
84
+ # Member/Account would pass detection and then break at db:migrate
85
+ # (FK to a missing `users` table) or at runtime (`belongs_to :user`
86
+ # constantizing a class that doesn't exist) — catch it HERE, with the
87
+ # fix in the error message. `--polymorphic` works with any model(s).
88
+ def check_devise_auth_model_fit!
89
+ return if polymorphic?
90
+ return unless devise_detected?
91
+ # An adopted omakase table already proves whatever owner shape the
92
+ # host made work — nothing for us to second-guess.
93
+ return if adopt_existing_table?
94
+
95
+ classes = devise_auth_class_names
96
+ return if classes.empty? # mappings unreadable — stay permissive
97
+ return if classes == ["User"] # the default assumption holds
98
+
99
+ if classes.include?("User")
100
+ # User plus other scopes: default mode tracks User and SILENTLY
101
+ # skips the rest (the runtime adapter's row_accepts? guard) —
102
+ # surface that tradeoff at install time, but proceed.
103
+ say "⚠️ Multiple Devise models detected (#{classes.join(", ")}).", :yellow
104
+ say " The default install tracks only User — sessions for the other models", :yellow
105
+ say " stay untracked. Re-run with --polymorphic to track them all.", :yellow
106
+ return
107
+ end
108
+
109
+ raise Thor::Error, <<~MSG
110
+ ❌ Your Devise model is #{classes.join(", ")} — not User. The default install
111
+ assumes a `User` class (`belongs_to :user`, foreign key to `users`, the
112
+ same assumption `rails generate authentication` makes) and would break
113
+ at migrate/runtime.
114
+
115
+ Re-run with the polymorphic owner, which works with any model(s):
116
+
117
+ rails generate sessions:install --polymorphic
118
+
119
+ …then declare `has_sessions` on #{classes.join(" and ")}.
120
+ MSG
121
+ end
122
+
81
123
  def create_migration_files
82
124
  if adopt_existing_table?
83
125
  migration_template "add_sessions_columns.rb.erb",
@@ -191,6 +233,19 @@ module Sessions
191
233
  defined?(::Devise) ? true : false
192
234
  end
193
235
 
236
+ # The class names behind the host's `devise_for` scopes, as strings
237
+ # (never constantized — the generator must not boot-order-couple to
238
+ # app models). On Rails 8, `Devise.mappings` itself force-loads the
239
+ # lazy routes (Devise 5.x, lib/devise.rb), so an empty hash genuinely
240
+ # means "no devise_for drawn yet" — nothing to validate against.
241
+ def devise_auth_class_names
242
+ return [] unless devise_detected?
243
+
244
+ ::Devise.mappings.values.map { |mapping| mapping.class_name.to_s }.uniq.sort
245
+ rescue StandardError
246
+ []
247
+ end
248
+
194
249
  def adopt_existing_table?
195
250
  return @adopt_existing_table if defined?(@adopt_existing_table)
196
251
 
@@ -79,16 +79,26 @@ class Create<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_ve
79
79
 
80
80
  private
81
81
 
82
- # Honor the host's configured primary key type (uuid vs bigint). Reads the
83
- # same setting `rails g model` uses, so an app generated with
82
+ # Honor the host's configured primary key type (uuid, string/ULID, bigint).
83
+ # Reads the same setting `rails g model` uses, so an app generated with
84
84
  # `config.generators { |g| g.orm :active_record, primary_key_type: :uuid }`
85
85
  # gets uuid sessions tables and uuid foreign keys, automatically.
86
86
  def primary_and_foreign_key_types
87
87
  config = Rails.configuration.generators
88
88
  setting = config.options[config.orm][:primary_key_type]
89
- primary_key_type = setting || :primary_key
90
- foreign_key_type = setting || :bigint
91
- [ primary_key_type, foreign_key_type ]
89
+ [ setting || :primary_key, reference_column_type(setting) ]
90
+ end
91
+
92
+ # Reference columns hold VALUES of the PK type, so the serial pseudo-types
93
+ # map to their plain integer equivalents — a `t.bigserial` reference would
94
+ # mint its own auto-increment sequence, which is exactly wrong for a
95
+ # foreign key. Everything else (uuid, string ULIDs, …) passes through.
96
+ def reference_column_type(setting)
97
+ case setting
98
+ when nil, :bigserial then :bigint
99
+ when :serial then :integer
100
+ else setting
101
+ end
92
102
  end
93
103
 
94
104
  # match? (not equality): PostGIS apps report adapter_name "PostGIS" and
@@ -85,17 +85,26 @@ class CreateSessionsEvents < ActiveRecord::Migration<%= migration_version %>
85
85
  def primary_and_foreign_key_types
86
86
  config = Rails.configuration.generators
87
87
  setting = config.options[config.orm][:primary_key_type]
88
- primary_key_type = setting || :primary_key
89
- foreign_key_type = setting || :bigint
90
- [ primary_key_type, foreign_key_type ]
88
+ [ setting || :primary_key, reference_column_type(setting) ]
91
89
  end
92
90
 
93
91
  # session_id must match the sessions table's PRIMARY KEY type (uuid hosts
94
- # store uuid linkage; bigint hosts store bigint).
92
+ # store uuid linkage, string/ULID hosts string, bigint hosts bigint).
95
93
  def session_id_column_type
96
94
  config = Rails.configuration.generators
97
- setting = config.options[config.orm][:primary_key_type]
98
- setting == :uuid ? :uuid : :bigint
95
+ reference_column_type(config.options[config.orm][:primary_key_type])
96
+ end
97
+
98
+ # Reference columns hold VALUES of the PK type, so the serial pseudo-types
99
+ # map to their plain integer equivalents — a `t.bigserial` reference would
100
+ # mint its own auto-increment sequence, which is exactly wrong for a
101
+ # foreign key. Everything else (uuid, string ULIDs, …) passes through.
102
+ def reference_column_type(setting)
103
+ case setting
104
+ when nil, :bigserial then :bigint
105
+ when :serial then :integer
106
+ else setting
107
+ end
99
108
  end
100
109
 
101
110
  # match? (not equality): PostGIS apps report adapter_name "PostGIS" and
@@ -36,8 +36,9 @@ module Sessions
36
36
  SKIP_ENV_KEY = "sessions.skip" # = Sessions::SKIP_ENV_KEY (set by Sessions.skip!)
37
37
 
38
38
  # The `throw :warden` message on revoked sessions — Devise's failure
39
- # app surfaces it like :timeout/:session_limited (add a
40
- # `devise.failure.session_revoked` translation for custom copy).
39
+ # app surfaces it like :timeout/:session_limited. The gem SHIPS the
40
+ # `devise.failure.session_revoked` copy (en + es, config/locales/);
41
+ # hosts override that key for custom wording.
41
42
  THROW_MESSAGE = :session_revoked
42
43
 
43
44
  module_function
@@ -185,11 +186,38 @@ module Sessions
185
186
  Sessions.safely("warden.adopt") do
186
187
  next unless row_accepts?(record)
187
188
 
189
+ # IDEMPOTENT, because a client that can't persist cookies re-enters
190
+ # 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))
200
+ Sessions.safely("warden.adopt.touch") { row.touch_last_seen!(warden.request) }
201
+ next
202
+ end
203
+
188
204
  row = create_row_for(record, warden, scope, suppress_login_event: true)
189
205
  row&.update_columns(auth_detail: { "adopted" => true })
190
206
  end
191
207
  end
192
208
 
209
+ def recent_adopted_row(record, warden, scope)
210
+ model = Sessions.session_model
211
+ rows = model.where(user: record)
212
+ 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
+
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"] }
219
+ end
220
+
193
221
  # SCOPE-PRECISE teardown: only this scope's warden entries go (the
194
222
  # serialized user key and our token stash) — an admin scope riding
195
223
  # the same rack session, and unrelated host session data (carts,
@@ -225,7 +253,9 @@ module Sessions
225
253
  credentials = request.params[scope.to_s]
226
254
  next unless credentials.is_a?(Hash)
227
255
 
228
- identity = credentials.values_at("email", "login", "username", "phone").compact.first
256
+ # `email_address` included: it's the omakase-era key, and Devise
257
+ # apps configure `authentication_keys = [:email_address]` too.
258
+ identity = credentials.values_at("email", "email_address", "login", "username", "phone").compact.first
229
259
 
230
260
  Sessions::Event.record_failure(
231
261
  request,
@@ -153,7 +153,13 @@ module Sessions
153
153
  end
154
154
 
155
155
  def method_for_strategy(strategy_name)
156
- Sessions.config.strategy_methods.merge(STRATEGY_METHODS).each do |substring, method|
156
+ # Host entries are consulted FIRST (Hash#merge keeps the receiver's
157
+ # keys in front, so config substrings win the iteration order) and the
158
+ # block makes them WIN on a shared key too — a bare `merge` would let
159
+ # the built-in value silently clobber a host override of, say,
160
+ # "Rememberable".
161
+ mappings = Sessions.config.strategy_methods.merge(STRATEGY_METHODS) { |_key, custom, _builtin| custom }
162
+ mappings.each do |substring, method|
157
163
  return method if strategy_name.include?(substring)
158
164
  end
159
165
  nil
@@ -335,7 +335,14 @@ module Sessions
335
335
 
336
336
  def sessions_record_login
337
337
  Sessions.safely("record_login") do
338
- next if sessions_suppress_login_event
338
+ if sessions_suppress_login_event
339
+ # Suppressed writes (adoption) skip the trail event, dedup and
340
+ # the new-device hook — but never the cap: it's the hard limit on
341
+ # LIVE rows, and a misbehaving client looping through adoption
342
+ # must hit it like everyone else.
343
+ sessions_enforce_cap!
344
+ next
345
+ end
339
346
 
340
347
  # Same browser signing in again (abandoned session, expired
341
348
  # remember-me, browser update — anything) replaces its old row
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sessions
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sessions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez