sessions 0.1.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 (71) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +61 -0
  3. data/.simplecov +54 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +26 -0
  6. data/CHANGELOG.md +26 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +454 -0
  10. data/Rakefile +32 -0
  11. data/app/assets/stylesheets/sessions.css +50 -0
  12. data/app/controllers/sessions/application_controller.rb +159 -0
  13. data/app/controllers/sessions/devices_controller.rb +48 -0
  14. data/app/helpers/sessions/engine_helper.rb +126 -0
  15. data/app/views/sessions/_device.html.erb +40 -0
  16. data/app/views/sessions/_devices.html.erb +34 -0
  17. data/app/views/sessions/_event.html.erb +13 -0
  18. data/app/views/sessions/_history.html.erb +20 -0
  19. data/app/views/sessions/devices/history.html.erb +5 -0
  20. data/app/views/sessions/devices/index.html.erb +15 -0
  21. data/config/locales/en.yml +59 -0
  22. data/config/locales/es.yml +59 -0
  23. data/config/routes.rb +17 -0
  24. data/docs/PRD.md +743 -0
  25. data/docs/research/01-carhey.md +250 -0
  26. data/docs/research/02-ecosystem.md +261 -0
  27. data/docs/research/03-rails-core.md +220 -0
  28. data/docs/research/04-devise-warden.md +249 -0
  29. data/docs/research/05-oauth.md +193 -0
  30. data/docs/research/06-prior-art.md +312 -0
  31. data/docs/research/07-device-detection.md +250 -0
  32. data/docs/research/08-rails8-landscape.md +216 -0
  33. data/docs/research/09-market-security.md +450 -0
  34. data/gemfiles/rails_7.1.gemfile +34 -0
  35. data/gemfiles/rails_7.2.gemfile +34 -0
  36. data/gemfiles/rails_8.0.gemfile +34 -0
  37. data/gemfiles/rails_8.1.gemfile +34 -0
  38. data/lib/generators/sessions/install_generator.rb +230 -0
  39. data/lib/generators/sessions/madmin_generator.rb +95 -0
  40. data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +81 -0
  41. data/lib/generators/sessions/templates/create_sessions.rb.erb +101 -0
  42. data/lib/generators/sessions/templates/create_sessions_events.rb.erb +108 -0
  43. data/lib/generators/sessions/templates/initializer.rb +201 -0
  44. data/lib/generators/sessions/templates/madmin/event_resource.rb +77 -0
  45. data/lib/generators/sessions/templates/madmin/session_events_controller.rb +21 -0
  46. data/lib/generators/sessions/templates/madmin/session_resource.rb +65 -0
  47. data/lib/generators/sessions/templates/madmin/sessions_controller.rb +30 -0
  48. data/lib/generators/sessions/templates/session.rb.erb +14 -0
  49. data/lib/generators/sessions/templates/sessions_sweep_job.rb +20 -0
  50. data/lib/generators/sessions/views_generator.rb +33 -0
  51. data/lib/sessions/adapters/omakase.rb +195 -0
  52. data/lib/sessions/adapters/omniauth.rb +64 -0
  53. data/lib/sessions/adapters/warden.rb +293 -0
  54. data/lib/sessions/classifier.rb +208 -0
  55. data/lib/sessions/configuration.rb +441 -0
  56. data/lib/sessions/current.rb +20 -0
  57. data/lib/sessions/device.rb +411 -0
  58. data/lib/sessions/engine.rb +120 -0
  59. data/lib/sessions/errors.rb +24 -0
  60. data/lib/sessions/geolocation.rb +111 -0
  61. data/lib/sessions/ip_address.rb +56 -0
  62. data/lib/sessions/jobs/geolocate_job.rb +58 -0
  63. data/lib/sessions/macros.rb +26 -0
  64. data/lib/sessions/middleware.rb +41 -0
  65. data/lib/sessions/models/concerns/device_display.rb +134 -0
  66. data/lib/sessions/models/concerns/has_sessions.rb +116 -0
  67. data/lib/sessions/models/concerns/model.rb +513 -0
  68. data/lib/sessions/models/event.rb +293 -0
  69. data/lib/sessions/version.rb +5 -0
  70. data/lib/sessions.rb +423 -0
  71. metadata +225 -0
@@ -0,0 +1,220 @@
1
+ # Rails 8.1+ native authentication: exact internals
2
+
3
+ Research date: 2026-06-11. Sources: two shallow clones, dissected file-by-file.
4
+
5
+ - **[S]** = released stable, tag `v8.1.3` (`RAILS_VERSION` = `8.1.3`, released 2026-03-24) at `/tmp/sessions-research/rails-stable`
6
+ - **[M]** = `main` (`RAILS_VERSION` = `8.2.0.alpha`) at `/tmp/sessions-research/rails`
7
+ - Latest 8.1.x tags from `git ls-remote`: v8.1.0 (2025-10-22) → v8.1.1 → v8.1.2 → v8.1.2.1 → v8.1.3. 8.0 series ends at v8.0.5. **Next version after 8.1 is 8.2** (not 9.0).
8
+
9
+ All paths repo-relative. Generator paths shortened: `GEN` = `railties/lib/rails/generators`.
10
+
11
+ ## Top findings
12
+
13
+ 1. **Auth rides a signed *permanent* cookie holding the Session row's DB id** — `cookies.signed.permanent[:session_id]`, 20-year expiry, `httponly: true`, `same_site: :lax` — NOT the Rack session. Every request does `Session.find_by(id: cookies.signed[:session_id])`. **Destroying the row is instant remote revocation** on next request.
14
+ 2. **The generated code NEVER updates a Session row after creation.** No `touch`, no last-active, nothing. `updated_at` stays equal to `created_at` forever. Verified by grep across every template ([S] exit 1) and by reading the full `resume_session` flow.
15
+ 3. **`ip_address` + `user_agent` are captured exactly once** — at login, inside `start_new_session_for` ([S] `GEN/rails/authentication/templates/app/controllers/concerns/authentication.rb.tt:42`). Never refreshed, never parsed.
16
+ 4. **The gap is total**: no failed-login logging, no session listing UI (`resource :session` is *singular* — no index route), no device parsing, no multi-session management beyond "destroy all on password reset", no expiry/sweep job, no `reset_session` call anywhere. The official security guide *recommends* an `updated_at`-based `Session.sweep` (security.md:428-434 [S]) **that the generated code can never satisfy because it never touches `updated_at`** — Rails' own docs point at the hole our gem fills.
17
+ 5. The **Authentication concern is byte-identical across 8.0.5 → 8.1.3 → main** (verified via `git diff v8.0.5 v8.1.3` and stable↔main diff: empty for concern + models). It is an extremely stable instrumentation target. 8.1's only behavioral additions: password reset now calls `@user.sessions.destroy_all`, and `PasswordsController#create` gained `rate_limit`.
18
+ 6. `Session` is a **2-line plain ActiveRecord model** (`belongs_to :user`). `start_new_session_for` uses `create!`, `terminate_session` uses `destroy`, reset uses `destroy_all` (instantiates + runs callbacks) → **model callbacks observe 100% of the generated lifecycle with zero app-code patching**.
19
+
20
+ ## 1. The authentication generator ([S] unless noted)
21
+
22
+ ### 1.1 Generator class — `GEN/rails/authentication/authentication_generator.rb`
23
+
24
+ - `class_option :api` (:10-11): "Generate API-only controllers and models, with no view templates". Its *only* effect is skipping the template-engine hook (:13-15): `hook_for :template_engine, as: :authentication do |template_engine| invoke template_engine unless options.api? end`. Same concern/controllers/models either way.
25
+ - Files created (:17-34): models session/user/current; sessions_controller, concerns/authentication, passwords_controller; `app/channels/application_cable/connection.rb` if ActionCable; mailer + 2 mailer views if ActionMailer.
26
+ - Injects into app (:36-38): `inject_into_class "app/controllers/application_controller.rb", "ApplicationController", " include Authentication\n"`.
27
+ - Routes (:40-43): `route "resources :passwords, param: :token"` and `route "resource :session"` — **singular resource: new/create/destroy only by convention, no index/show listing**. [M] :42-45 tightens to `resource :session, only: [:new, :create, :destroy]` and `resources :passwords, param: :token, only: [:new, :create, :edit, :update]`.
28
+ - Gemfile (:45-52): uncomments or `bundle add bcrypt`.
29
+ - Migrations (:54-57):
30
+ ```ruby
31
+ generate "migration", "CreateUsers", "email_address:string!:uniq password_digest:string!", "--force"
32
+ generate "migration", "CreateSessions", "user:references ip_address:string user_agent:string", "--force"
33
+ ```
34
+ - `hook_for :test_framework` (:59) → test_unit sub-generator (rspec prints `[not found]`, `railties/test/generators/authentication_generator_test.rb:124-128`).
35
+ - [M] additions: `@user_model_exists` guard skips `CreateUsers` migration when `app/models/user.rb` already exists ([M] :18, :55-59; CHANGELOG [M] `railties/CHANGELOG.md:31`).
36
+
37
+ ### 1.2 The Authentication concern — ENTIRE file, `GEN/rails/authentication/templates/app/controllers/concerns/authentication.rb.tt` (identical [S]:1-52 / [M], no ERB conditionals)
38
+
39
+ ```ruby
40
+ module Authentication
41
+ extend ActiveSupport::Concern
42
+
43
+ included do
44
+ before_action :require_authentication
45
+ helper_method :authenticated?
46
+ end
47
+
48
+ class_methods do
49
+ def allow_unauthenticated_access(**options)
50
+ skip_before_action :require_authentication, **options
51
+ end
52
+ end
53
+
54
+ private
55
+ def authenticated?
56
+ resume_session
57
+ end
58
+
59
+ def require_authentication
60
+ resume_session || request_authentication
61
+ end
62
+
63
+ def resume_session
64
+ Current.session ||= find_session_by_cookie
65
+ end
66
+
67
+ def find_session_by_cookie
68
+ Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
69
+ end
70
+
71
+ def request_authentication
72
+ session[:return_to_after_authenticating] = request.url
73
+ redirect_to new_session_path
74
+ end
75
+
76
+ def after_authentication_url
77
+ session.delete(:return_to_after_authenticating) || root_url
78
+ end
79
+
80
+ def start_new_session_for(user)
81
+ user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
82
+ Current.session = session
83
+ cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }
84
+ end
85
+ end
86
+
87
+ def terminate_session
88
+ Current.session.destroy
89
+ cookies.delete(:session_id)
90
+ end
91
+ end
92
+ ```
93
+
94
+ Notes: the Rack `session` is used *only* for `return_to_after_authenticating` (:33, :38). API quirk: `helper_method` (:6) is defined by `AbstractController::Helpers` (`actionpack/lib/abstract_controller/helpers.rb:128`), included in `ActionController::Base` (`actionpack/lib/action_controller/base.rb:235`) but **absent from `ActionController::API`'s MODULES** (`actionpack/lib/action_controller/api.rb:116-147`) — API-only apps must delete/guard that line themselves; `--api` doesn't.
95
+
96
+ ### 1.3 SessionsController — `GEN/.../templates/app/controllers/sessions_controller.rb.tt` ([S]:1-21, full)
97
+
98
+ ```ruby
99
+ class SessionsController < ApplicationController
100
+ allow_unauthenticated_access only: %i[ new create ]
101
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
102
+
103
+ def new
104
+ end
105
+
106
+ def create
107
+ if user = User.authenticate_by(params.permit(:email_address, :password))
108
+ start_new_session_for user
109
+ redirect_to after_authentication_url
110
+ else
111
+ redirect_to new_session_path, alert: "Try another email address or password."
112
+ end
113
+ end
114
+
115
+ def destroy
116
+ terminate_session
117
+ redirect_to new_session_path, status: :see_other
118
+ end
119
+ end
120
+ ```
121
+
122
+ Failed login = redirect + flash, nothing recorded (:12-14).
123
+
124
+ ### 1.4 PasswordsController — `GEN/.../templates/app/controllers/passwords_controller.rb.tt` ([S]:1-39)
125
+
126
+ - `allow_unauthenticated_access` (:2); `before_action :set_user_by_token, only: %i[ edit update ]` (:3).
127
+ - `rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }` (:5, wrapped in `<%- if defined?(ActionMailer::Railtie) -%>`).
128
+ - `create` (:12-18): `User.find_by(email_address:)` → `PasswordsMailer.reset(user).deliver_later`; always redirects with "Password reset instructions sent (if user with that email address exists)." (enumeration-safe).
129
+ - `update` (:24-31): on success **`@user.sessions.destroy_all`** (:26 — global revocation, new in 8.1) then redirect.
130
+ - `set_user_by_token` (:34-38): `User.find_by_password_reset_token!(params[:token])` rescuing `ActiveSupport::MessageVerifier::InvalidSignature`.
131
+
132
+ ### 1.5 Models ([S], all identical on [M])
133
+
134
+ - `session.rb.tt`: `class Session < ApplicationRecord` + `belongs_to :user`. That's the whole file (3 lines).
135
+ - `user.rb.tt`: `has_secure_password`; `has_many :sessions, dependent: :destroy`; `normalizes :email_address, with: ->(e) { e.strip.downcase }`.
136
+ - `current.rb.tt`: `class Current < ActiveSupport::CurrentAttributes` / `attribute :session` / `delegate :user, to: :session, allow_nil: true` → `Current.user` works everywhere per-request.
137
+
138
+ ### 1.6 Migrations → exact schema
139
+
140
+ Via the migration generator commands (§1.1): `string!` ⇒ `null: false` (`GEN/generated_attribute.rb:103-108` [S]); `references` ⇒ `null: false` + FK because `belongs_to_required_by_default` (`GEN/generated_attribute.rb:192-193`). Result:
141
+
142
+ - **users**: `email_address:string NOT NULL` + unique index, `password_digest:string NOT NULL`, timestamps.
143
+ - **sessions**: `user_id` (references, NOT NULL, FK, indexed), `ip_address:string` (nullable, plain string — not inet), `user_agent:string` (nullable, raw — `varchar(255)` on MySQL; long UAs can overflow there), `created_at`/`updated_at`.
144
+
145
+ ### 1.7 Views, mailer, ActionCable
146
+
147
+ - Views come from a hidden sub-generator `GEN/erb/authentication/authentication_generator.rb` (:11-15): exactly three — `sessions/new`, `passwords/new`, `passwords/edit`. `sessions/new.html.erb` is a bare `form_with url: session_path` with `email_field` (`autocomplete: "username"`) + `password_field` (`autocomplete: "current-password"`, `maxlength: 72`). No layout/UI framework. **No "your devices"/session list view exists.**
148
+ - `passwords_mailer.rb.tt`: `mail subject: "Reset your password", to: user.email_address`; view links `edit_password_url(@user.password_reset_token)`, "within the next 15 minutes".
149
+ - `application_cable/connection.rb.tt`: `identified_by :current_user`; `set_current_user` does `Session.find_by(id: cookies.signed[:session_id])` → same cookie powers ActionCable auth.
150
+
151
+ ### 1.8 Generated tests + SessionTestHelper (`GEN/test_unit/authentication/`)
152
+
153
+ - `templates/test/test_helpers/session_test_helper.rb.tt`: `sign_in_as(user)` does `Current.session = user.sessions.create!` then writes the signed cookie via `ActionDispatch::TestRequest.create.cookie_jar`; `sign_out` destroys + deletes; included via `ActiveSupport.on_load(:action_dispatch_integration_test)`. Note `sign_in_as` creates a session row with **nil ip/user_agent** — our gem must tolerate that.
154
+ - Controller tests for sessions (4 cases) + passwords (7 cases), `users.yml` fixtures with shared `BCrypt::Password.create("password")`, mailer preview. Injected `require_relative "test_helpers/session_test_helper"` into `test/test_helper.rb` (`authentication_generator.rb:26-28`).
155
+
156
+ ### 1.9 Session cookie — exact attributes
157
+
158
+ Write path: `cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }` (concern :44).
159
+
160
+ - **Signed, not encrypted**: `SignedKeyRotatingCookieJar` (`actionpack/lib/action_dispatch/middleware/cookies.rb:621-649` [S]) — `ActiveSupport::MessageVerifier` with key from `request.key_generator.generate_key(request.signed_cookie_salt)` (:627-628). Payload is **readable** client-side (tamper-proof, not secret) → the integer Session id is visible in the cookie.
161
+ - **Permanent = 20 years**: `PermanentCookieJar#commit` sets `options[:expires] = 20.years.from_now` (cookies.rb:558-563). Fixed expiry from login; never re-issued ⇒ **no sliding window**.
162
+ - `httponly: true` explicit; `same_site: :lax` explicit (also the framework default: `cookies_same_site_protection = :lax`, `railties/lib/rails/application/configuration.rb:193` [S], applied at cookies.rb:459-461).
163
+ - `secure` not set; `write_cookie?` (cookies.rb:448-450) only enforces SSL when the flag is present — `Secure` comes from `config.force_ssl` (ActionDispatch::SSL) in default production.
164
+ - Purpose metadata binds value to cookie name: `metadata[:purpose] = "cookie.#{name}"` (cookies.rb:548-552) — can't replay another cookie's payload as `session_id`.
165
+
166
+ ## 2. Key mechanics & gap analysis (the product thesis, verified)
167
+
168
+ - **Capture point**: ip/UA written exactly at `start_new_session_for` (concern :42) from `request.user_agent` / `request.remote_ip`. Never again.
169
+ - **No row updates ever**: `resume_session` → `find_session_by_cookie` → `Session.find_by` (concern :24-30) is read-only; grep for `touch|update|save` across all auth templates matches only the password-reset `@user.update` ([S], grep exit 1 otherwise). **No last-active tracking exists.**
170
+ - **Remote revocation works**: cookie holds only the row id; row gone ⇒ `find_by` nil ⇒ `require_authentication` ⇒ `request_authentication` redirect (concern :20-35). Browser keeps a useless signed cookie for 20 years.
171
+ - **Absent (confirmed by exhaustive template read)**: failed-login persistence/logging; session index/listing routes or views; device/UA parsing; per-session management UI (only "log out current" + "nuke all on password reset"); session expiry/sweeping; `reset_session`/Rack-session rotation on login (grep exit 1); `Current.session` is the only runtime handle.
172
+ - **Rate limiting is the only abuse counter-measure**, and it lives in the cache store, not the DB: 10 req / 3 min / IP on `sessions#create` and `passwords#create`.
173
+
174
+ ## 3. Supporting Rails APIs (all [S])
175
+
176
+ - **ActiveSupport::CurrentAttributes** — `activesupport/lib/active_support/current_attributes.rb`: "thread-isolated attributes singleton, which resets automatically before and after each request" (:12); `attribute` :115; `resets`/`after_reset`/`before_reset` :145-153. Reset wiring: `app.executor.to_complete { ActiveSupport::CurrentAttributes.clear_all }` (`activesupport/lib/active_support/railtie.rb:60-64`) — jobs/background code outside the executor never get `Current.session`.
177
+ - **rate_limit** — `actionpack/lib/action_controller/metal/rate_limiting.rb:66-68`:
178
+ ```ruby
179
+ def rate_limit(to:, within:, by: -> { request.remote_ip }, with: -> { raise TooManyRequests }, store: cache_store, name: nil, scope: nil, **options)
180
+ ```
181
+ Implementation :72-90: cache key `["rate-limit", scope, name, by].compact.join(":")`, `store.increment(..., expires_in: within)`; **when exceeded** it instruments `rate_limit.action_controller` with payload `request,count,to,within,by,name,scope,cache_key` (:78-88) around the `with:` handler. Default 429 via `TooManyRequests` (doc :25-29). 8.1 added symbol `by:`/`with:`, `scope:`, the notification payload (actionpack CHANGELOG 8.1.0 section, :129-142, :239-252, :283-287). [M] adds: `by:` objects responding to `cache_key` use it ([M] actionpack/CHANGELOG.md:1-10) and **dynamic `to:`/`within:` accepting callables/symbols** ([M] :375-395).
182
+ - **authenticate_by** — `activerecord/lib/active_record/secure_password.rb:41-57`, doc :10-40: *"Regardless of whether a record is found, `authenticate_by` will cryptographically digest the given password attributes. This behavior helps mitigate timing-based enumeration attacks"*. Returns record or nil; nil/empty password short-circuits (:49); user-missing path still runs `new(passwords)` to burn bcrypt time (:53-55). **No hook/notification on failure** — failures are indistinguishable from the outside.
183
+ - **generates_token_for** — `activerecord/lib/active_record/token_for.rb:102-104` (def), doc :57-101: tokens signed via per-class `generated_token_verifier`; `expires_in` is baked into the purpose string (`TokenDefinition#full_purpose`, :14-17) so changing it invalidates old tokens; optional block payload re-evaluated at lookup — mismatch ⇒ invalid (:31-35). `find_by_token_for!` raises `ActiveSupport::MessageVerifier::InvalidSignature` (:50-53). **Stateless: no DB write, can't revoke a single token, only invalidate-by-state-change.**
184
+ - **has_secure_password extras** — `activemodel/lib/active_model/secure_password.rb`: signature `has_secure_password(attribute = :password, validations: true, reset_token: true)` (:125). `reset_token: true` auto-defines `generates_token_for :password_reset, expires_in: DEFAULT_RESET_TOKEN_EXPIRES_IN` (= `15.minutes`, :14) keyed on `password_salt&.last(10)` (:171-179) + `find_by_password_reset_token[!]` (:181-191) — so **password change invalidates outstanding reset tokens**. `password_challenge` validation re-checks `password_digest_was` (:149-157). 72-byte bcrypt max validation (:159-165). `password_salt` reader (:228-232).
185
+ - **normalizes** — moved to `activemodel/lib/active_model/attributes/normalization.rb:111` in 8.1 (was `active_record/normalization.rb` in 8.0). Applies on assignment + finder values.
186
+ - **MessageVerifier rotation** — `activesupport/lib/active_support/message_verifier.rb:90-109` (`rotate(old_secret, digest:, serializer:)` fallback stack). Cookie-level: `config.action_dispatch.cookies_rotations` consumed at cookies.rb:630-635; rotated-on-read cookies are transparently re-written (:638-642). App-level: `Rails.application.message_verifiers.rotate(...)` (`railties/lib/rails/application.rb:196-211`). **Changing `secret_key_base` without rotation kills every login cookie at once.**
187
+ - **RemoteIp / request.remote_ip** — `actionpack/lib/action_dispatch/middleware/remote_ip.rb`: picks "the last-set address that is not on the list of trusted IPs" (:11-13); `TRUSTED_PROXIES` = loopback + RFC1918 + link-local ranges (:40-49); custom list replaces (not extends) via `config.action_dispatch.trusted_proxies` wired with `ip_spoofing_check` at `railties/lib/rails/application/default_middleware_stack.rb:55`; spoof-check raises `IpSpoofAttackError` when Client-Ip vs X-Forwarded-For disagree (:34, :129-160). `request.remote_ip` reads env `"action_dispatch.remote_ip"` set by the middleware (`actionpack/lib/action_dispatch/http/request.rb:317-319`). **Our stored `ip_address` is only as truthful as the host app's trusted_proxies config** (Cloudflare etc. need explicit config or the edge IP gets stored).
188
+
189
+ ## 4. History & direction: 8.0 → 8.1 → edge
190
+
191
+ `git diff v8.0.5 v8.1.3 -- <generator trees>` ([S] clone, fetched tag):
192
+
193
+ - **8.1.0 (2025-10-22)** generator changes — all in [S] railties/CHANGELOG.md under the 8.1.0 header (line 46):
194
+ - Password reset destroys **all** the user's sessions (`@user.sessions.destroy_all` added to passwords_controller.tt).
195
+ - `rate_limit` added to `PasswordsController#create` ("Rate limit password resets… mitigate abuse", :178-182, Chris Oliver).
196
+ - `SessionTestHelper` with `sign_in_as`/`sign_out` (:173-176); sessions+passwords controller tests generated (:158-160).
197
+ - ActionMailer-less apps: mailer files/actions skipped (:135-137).
198
+ - `sessions#destroy` redirect gained `status: :see_other`; rate-limit lambda `new_session_url`→`new_session_path`.
199
+ - **Concern + all three models byte-identical 8.0→8.1** (empty diff).
200
+ - **8.1.0 actionpack**: rate_limit `scope:`, symbol `by:`/`with:`, notification payload (§3). Also `ActionDispatch::Session::CacheStore` got `check_collisions` (actionpack CHANGELOG :486-492 — Rack-session hardening, unrelated to the auth cookie).
201
+ - **main / 8.2.0.alpha**: generator = reuse existing User model (skip CreateUsers, [M] railties/CHANGELOG.md:31), routes restricted with `only:`, passwords test mailer-guarded, sessions test uses `@user`. rate_limit: `cache_key` on `by:` + dynamic `to:`/`within:`. **No passkeys/WebAuthn/OmniAuth/account-model work anywhere on main** (grep across railties+actionpack: zero hits); concern still byte-identical. Direction = polish, not expansion — the session-management whitespace stays open.
202
+
203
+ ## 5. Official guidance (guides/source/security.md [S]; identical file size on [M])
204
+
205
+ - Authentication section (:33-245) documents the generator; explicitly: *"you do need to implement your own sign up flow"* (:105-109); encourages reading generated code, "not treat authentication as a black box" (:238-240). Reset link "valid for 15 minutes by default" (:148-149).
206
+ - No dedicated `authentication.md` guide exists ([S]+[M] `guides/source/`); 8.1 added tutorial guides `sign_up_and_settings.md` (sign-up + rate limiting + roles atop the generator) and `wishlists.md`; `getting_started.md` covers the generator.
207
+ - Session fixation (:393-420): countermeasure = `reset_session` after login (:412-416) — **the generated code never does this** (it doesn't rotate the Rack session; its own cookie is server-issued so the auth layer itself isn't fixatable). Devise name-checked (:418). Also suggests verifying stored ip/user-agent per request, with proxy caveats (:420).
208
+ - Session expiry (:422-440): "Sessions that never expire extend the time-frame for attacks"; recommends DB-side expiry, sample `Session.sweep` using `where(updated_at: ...time.ago)` (:428-434) plus `created_at` cap for kept-alive sessions (:436-440). **The generated Session's `updated_at` is never touched, so this exact recommendation is unimplementable without our gem's last-active tracking.**
209
+ - Cookie rotation how-to (:332-375); changing `secret_key_base` expires all sessions (:330).
210
+
211
+ ## Implications for the sessions gem
212
+
213
+ 1. **Decorate the model, don't patch app code.** `Session < ApplicationRecord` with one association. All three lifecycle paths run callbacks: `create!` (login), `destroy` (logout), `destroy_all` (password reset — instantiates each record). So `Rails.application.config.to_prepare { Session.include Sessions::SessionExtensions if defined?(Session) && Session.column_names.include?("ip_address") }` + `after_create_commit` (login event, ip/UA already on the row) + `after_destroy_commit` (revocation event) captures everything. Must use `to_prepare` (app constant, reloader-safe), not `on_load`.
214
+ 2. **Wrap controller verbs via prepend, no monkey-patching.** `start_new_session_for` / `terminate_session` / `resume_session` are *name-stable since 8.0* private methods on ApplicationController (via the included Authentication concern). `to_prepare { ApplicationController.prepend(Sessions::ControllerHooks) if ApplicationController.private_method_defined?(:start_new_session_for) }` — prepend sits in front of the included concern in the ancestor chain, so `super`-wrapping works cleanly. Duck-detect with `private_method_defined?` for omakase-vs-Devise dispatch.
215
+ 3. **Last-active tracking is ours to add and the schema is ready**: `updated_at` exists and is dead weight today. Wrap `resume_session` (or an `after_action`) with a throttled `touch` (e.g. ≥ once/5 min) — note before_actions registered at `ActionController::Base` level run *before* the app's `require_authentication`, so `Current.session` is nil there; use prepend-wrap or `after_action`. This directly enables the security guide's own sweep recommendation (security.md:422-440).
216
+ 4. **Failed logins have no hook**: `authenticate_by` is silent, the controller just redirects. Options: (a) prepend `SessionsController#create` (name-stable), (b) prepend `User.authenticate_by` singleton (semantics stable, secure_password.rb:41-57), (c) subscribe `ActiveSupport::Notifications` `rate_limit.action_controller` for brute-force-exceeded events (payload: count/to/within/by/name/scope/cache_key). Recommend (a)+(c).
217
+ 5. **Cookie = row id, signed, readable**: any Rack/middleware/Hotwire Native layer can resolve the device's session via `request.cookie_jar.signed[:session_id]` — same trick the generated ActionCable Connection uses. Great for native-app device intelligence without touching controllers.
218
+ 6. **Revocation UX is free**: "log out this device" = `session.destroy`; "log out everywhere else" = `user.sessions.where.not(id: Current.session.id).destroy_all`. Rails provides the primitive but zero UI — our whole "your devices" layer is additive migration (device columns, last_active_at) + views on a table Rails already owns.
219
+ 7. **Naming hazard**: the host's model is literally `::Session` and table `sessions` — our gem (`sessions`) must isolate_namespace and never define a top-level `Session`; ride theirs when present, generate a compatible one when absent (Devise/OAuth modes).
220
+ 8. **Caveats to encode**: `ip_address` truthfulness depends on `trusted_proxies` (offer config + docs); UA `varchar(255)` truncation on MySQL; `sign_in_as` test helper creates nil-ip/UA rows; signed-not-encrypted cookie exposes sequential ids; 20-year cookie means *our* sweep + idle expiry is the only real expiry; API-mode apps lack `helper_method` so our helpers must be Base/API-aware.
@@ -0,0 +1,249 @@
1
+ # Devise & Warden internals: attachment surface for session tracking
2
+
3
+ Research date: 2026-06-11. Sources: shallow clones at `/tmp/sessions-research/{devise,warden,devise-security}`.
4
+ Path convention below: `devise/...` = heartcombo/devise @ `372b295` (2026-06-10), `warden/...` = wardencommunity/warden @ `810e520` (2025-09-02), `devise-security/...` = devise-security/devise-security @ `7cbe3fd` (2026-01-05).
5
+
6
+ ## Top findings
7
+
8
+ 1. **Warden hooks are the entire attachment surface and they are class-level + lazily evaluated.** Hook blocks are stored in arrays on `Warden::Manager` itself (`warden/lib/warden/hooks.rb:67-69`, `Manager` extends `Hooks` at `warden/lib/warden/manager.rb:12`) and consulted per-request via `manager._run_callbacks` → `self.class._run_callbacks` (`warden/lib/warden/manager.rb:51-53`). Registration any time before the first request is safe; Gemfile order is irrelevant if we register from a Railtie initializer.
9
+ 2. **Every "user becomes current" path funnels through `Proxy#set_user`, tagged with an `:event`** — `:authentication` (strategy won: form login AND remember-me cookie), `:fetch` (per-request session resume), `:set_user` (manual `sign_in`: post-signup, post-password-reset, OmniAuth default). `after_set_user except: :fetch` = "a login of any kind happened"; `only: :fetch` = "per-request resume". This is exactly how trackable and session_limitable split their work.
10
+ 3. **Session fixation protection is in Warden, not Devise, and it's `:renew`, not `reset_session`:** `Proxy#set_user` sets `env['rack.session.options'][:renew] = true` (`warden/lib/warden/proxy.rb:178-186`); the Rack/Rails session middleware rotates the SID at commit while *copying session data over*. Consequence: a token we stash in the warden session survives login rotation, but the Rack session **ID** captured during the login request is stale by response time — never key our session rows on Rack SID; store our own token like session_limitable does.
11
+ 4. **devise-security's session_limitable is a complete, proven 3-hook revocation template in 55 lines** (`devise-security/lib/devise-security/hooks/session_limitable.rb`): store token on login (`after_set_user except: :fetch`), compare per request (`after_set_user only: :fetch` → `warden.logout` + `throw :warden`), clear on logout (`before_logout`). Its only structural flaw for us: the token lives in a single column on the user row → exactly one valid session per user. Move the token to a sessions table row → N devices + selective remote revocation.
12
+ 5. **Failed logins:** strategy `fail!` only sets state (`warden/lib/warden/strategies/base.rb:137-141`); `authenticate!` throws (`warden/lib/warden/proxy.rb:134`); `Manager#call` catches and runs `before_failure` with `env["warden.options"] = {scope:, action:, message:, attempted_path:, recall:, locale:}` (`warden/lib/warden/manager.rb:136-147`). The attempted email is NOT in warden.options — extract `Rack::Request.new(env).params[scope.to_s]` (Devise posts `user[email]`). This is the authtrail mechanism, confirmed from source.
13
+ 6. **Devise is alive and Rails-8-ready:** v5.0.4 (2026-05-08), 5.0.0 (2026-01-23) dropped Rails <7/Ruby <2.7, added Rails 8 lazy-route support; repo HEAD committed 2026-06-10; two CVE patches in 2026. Warden is frozen at 1.2.9 (released 2020-08-31) — stable ABI, ideal attachment target.
14
+ 7. **Surprise:** devise-security registers a `:session_non_transferable` module (`devise-security/lib/devise-security.rb:108`) whose model file does not exist anywhere in the repo — a broken autoload at HEAD. Also `Devise::Hooks::Proxy` (`devise/lib/devise/hooks/proxy.rb`) is a tiny public-ish helper that gives hooks `sign_out`/`remember_me`/`cookies` powers — we should reuse the pattern, not the class.
15
+
16
+ ## 1. Warden lifecycle & hooks
17
+
18
+ `Warden::Manager` is Rack middleware; Devise inserts it via `config.app_middleware.use Warden::Manager { |config| Devise.warden_config = config }` (`devise/lib/devise/rails.rb:11-13`). Per request: `env['warden'] = Proxy.new(env, self)`, then `catch(:warden) { env['warden'].on_request; @app.call(env) }` (`warden/lib/warden/manager.rb:30-37`).
19
+
20
+ ### Hook signatures (`warden/lib/warden/hooks.rb`)
21
+
22
+ - **`after_set_user(options = {}, method = :push, &block)`** — hooks.rb:53-63. Block args `|user, auth, opts|`: user object, the `Warden::Proxy`, and the options passed to `set_user` *including `:scope` and `:event`* (hooks.rb:33-36). Fires "the first time one of those three events happens during a request: `:authentication`, `:fetch` (from session) and `:set_user` (when manually set)" (hooks.rb:19-21). Filtering options: `scope:`, `only:`, `except:` (hooks.rb:28-31). Event filtering is sugar:
23
+
24
+ ```ruby
25
+ if options.key?(:only)
26
+ options[:event] = options.delete(:only)
27
+ elsif options.key?(:except)
28
+ options[:event] = [:set_user, :authentication, :fetch] - Array(options.delete(:except))
29
+ end
30
+ ```
31
+ (hooks.rb:56-60). Condition matching: a callback runs unless any condition key mismatches `options` — arrays mean "include?" (`hooks.rb:7-17`, `_run_callbacks`).
32
+ - **`after_authentication`** — hooks.rb:76-78: literally `after_set_user(options.merge(:event => :authentication), ...)`.
33
+ - **`after_fetch`** — hooks.rb:85-87: `after_set_user(options.merge(:event => :fetch), ...)`.
34
+ - **`before_failure(options = {}, method = :push, &block)`** — hooks.rb:110-113. Block args `|env, opts|` (hooks.rb:98-100). Runs "just prior to the failure application being called", after PATH_INFO has been rewritten (hooks.rb:89-91).
35
+ - **`after_failed_fetch`** — hooks.rb:138-141. Block `|user, auth, opts|`; runs when no user could be deserialized from the session for a scope (hooks.rb:121). Fired from `Proxy#user` at `warden/lib/warden/proxy.rb:226`.
36
+ - **`before_logout`** — hooks.rb:166-169. Block `|user, auth, opts|`, runs "just prior to the logout of each scope" (hooks.rb:149); fired per scope from `Proxy#logout` (`proxy.rb:274`).
37
+ - **`on_request`** — hooks.rb:191-194. Block `|proxy|`; runs on *every* request right after the proxy is built (`proxy.rb:34-38`, called at `manager.rb:35`), before any authentication.
38
+ - **`prepend_<hook>`** variants exist for all of the above (hooks.rb:202-210) — they `unshift` instead of `push`; hooks run **in declaration order** (hooks.rb:22).
39
+
40
+ ### How scope is passed
41
+
42
+ Scope rides in the options hash: `set_user` defaults it (`opts[:scope] ||= @config.default_scope`, `proxy.rb:171`) and merges per-scope `scope_defaults` (`proxy.rb:174`; defaults installed by Devise at `devise/lib/devise.rb:493`). Hooks read `opts[:scope]` (e.g. trackable: `options[:scope]`). `before_logout` gets `:scope => scope` explicitly (`proxy.rb:274`). `before_failure` gets it inside `env['warden.options']`/second arg.
43
+
44
+ ### Login vs resume vs remember-me — the event/strategy matrix
45
+
46
+ `_perform_authentication` returns the session user first if present (`proxy.rb:334`); otherwise runs strategies and on success calls `set_user(winning_strategy.user, opts.merge!(:event => :authentication))` (`proxy.rb:337-340`). Session resume goes through `Proxy#user` → `set_user(user, opts.merge(:event => :fetch))` (`proxy.rb:229`) and skips re-serialization (`opts[:store] != false && opts[:event] != :fetch` guard at `proxy.rb:178`). Manual `set_user` defaults `opts[:event] ||= :set_user` (`proxy.rb:175`).
47
+
48
+ | Situation | event | `warden.winning_strategy` |
49
+ |---|---|---|
50
+ | Form login (`warden.authenticate!`) | `:authentication` | `Devise::Strategies::DatabaseAuthenticatable` |
51
+ | Remember-me cookie re-auth | `:authentication` | `Devise::Strategies::Rememberable` |
52
+ | Per-request session resume | `:fetch` | `nil` (no strategy ran) |
53
+ | `sign_in` after signup / password reset / OmniAuth default | `:set_user` | `nil` |
54
+ | OmniAuth with `event: :authentication` passed | `:authentication` | `nil` |
55
+
56
+ `winning_strategy` is a public accessor (`proxy.rb:10`). So: `opts[:event]` + `warden.winning_strategy&.class` fully disambiguates the login method.
57
+
58
+ ### Registration timing / load order
59
+
60
+ Hook arrays live on the `Warden::Manager` class (`hooks.rb:67-69`); the middleware instance just delegates to the class at callback time (`manager.rb:51-53`). Devise itself loads warden at require time — `require 'warden'` is `devise/lib/devise.rb:529` — and registers its own hooks even later: each `devise/lib/devise/hooks/*.rb` is `require`d from the corresponding model module (e.g. `devise/lib/devise/models/trackable.rb:3`), which is only autoloaded when an app model declares `devise :trackable` — i.e. at class-load/eager-load time, *after* initializers. Conclusion: registering hooks from an initializer is load-order safe and will typically place our hooks *ahead of* Devise's own model hooks in declaration order (see §8 and edge cases).
61
+
62
+ ## 2. Devise sign-in flow end-to-end
63
+
64
+ 1. **`Devise::SessionsController#create`** (`devise/app/controllers/devise/sessions_controller.rb:18-24`):
65
+ ```ruby
66
+ self.resource = warden.authenticate!(auth_options)
67
+ set_flash_message!(:notice, :signed_in)
68
+ sign_in(resource_name, resource)
69
+ ```
70
+ `auth_options = { scope: resource_name, recall: "#{controller_path}#new", locale: I18n.locale }` (sessions_controller.rb:47-49). Note `prepend_before_action ... { request.env["devise.skip_timeout"] = true }` for create/destroy (sessions_controller.rb:7) and `allow_params_authentication!` (line 5) which sets `env["devise.allow_params_authentication"]` checked by the strategy (`devise/lib/devise/strategies/authenticatable.rb:104-106`).
71
+ 2. **Strategy** — `Devise::Strategies::DatabaseAuthenticatable#authenticate!` (`devise/lib/devise/strategies/database_authenticatable.rb:9-26`):
72
+ ```ruby
73
+ resource = password.present? && mapping.to.find_for_database_authentication(authentication_hash)
74
+ if validate(resource){ hashed = true; resource.valid_password?(password) }
75
+ remember_me(resource)
76
+ resource.after_database_authentication
77
+ success!(resource)
78
+ end
79
+ mapping.to.new.password = password if !hashed && Devise.paranoid # enumeration defense, line 22
80
+ unless resource
81
+ Devise.paranoid ? fail(:invalid) : fail(:not_found_in_database) # lines 23-25
82
+ end
83
+ ```
84
+ Registered via `Warden::Strategies.add(:database_authenticatable, ...)` (line 31). `validate` calls `resource.valid_for_authentication?` and on falsy result `fail!(resource.unauthenticated_message)` (`devise/lib/devise/strategies/authenticatable.rb:37-48`) — this is where lockable/confirmable rejections surface. `success!`/`fail!` just set `@result`/`@user`/`@message` and halt (`warden/lib/warden/strategies/base.rb:126-141`).
85
+ 3. **Warden sets the user**: `set_user(..., event: :authentication)` (`warden/lib/warden/proxy.rb:339`) → stores to session via serializer (`proxy.rb:187`) → runs `after_set_user` callbacks (`proxy.rb:190-191`).
86
+ 4. **`sign_in` helper** (`devise/lib/devise/controllers/sign_in_out.rb:33-46`): deletes all `devise.*` session keys (`expire_data_after_sign_in!`, lines 38, 99-101), then — key subtlety — **no-ops if `warden.user(scope) == resource`** (lines 40-42). After `warden.authenticate!` the user is already set, so the controller's `sign_in` call does nothing; all hooks fired during step 3. For signup/password-reset flows the user is *not* set yet, so it proceeds to `warden.set_user(resource, options.merge!(scope: scope))` (line 44) → hooks fire with event `:set_user`.
87
+
88
+ ### Session fixation: the exact lines
89
+
90
+ Devise never calls `reset_session` on sign-in. Warden's `set_user` does this (`warden/lib/warden/proxy.rb:178-187`):
91
+
92
+ ```ruby
93
+ if opts[:store] != false && opts[:event] != :fetch
94
+ options = env[ENV_SESSION_OPTIONS] # 'rack.session.options', proxy.rb:20
95
+ if options
96
+ if options.frozen?
97
+ env[ENV_SESSION_OPTIONS] = options.merge(:renew => true).freeze
98
+ else
99
+ options[:renew] = true
100
+ end
101
+ end
102
+ session_serializer.store(user, scope)
103
+ end
104
+ ```
105
+
106
+ `:renew` makes the Rack/Rails session middleware generate a fresh SID at commit while keeping the session contents. Complementarily, the CSRF token is rotated by Devise's `csrf_cleaner` hook: `Warden::Manager.after_authentication` → `warden.request.reset_csrf_token` on Rails 7.1+ (`devise/lib/devise/hooks/csrf_cleaner.rb:3-14`), gated by the winning strategy's `clean_up_csrf?` (true for database auth, `devise/lib/devise/strategies/authenticatable.rb:24-26`; false for rememberable, `devise/lib/devise/strategies/rememberable.rb:41-43`). A **full** session wipe happens only on logout-of-all-scopes: `Proxy#logout` sets `reset_session = true` when called with no scopes (`proxy.rb:267-270`) → `reset_session!` (`proxy.rb:280`), which Devise overrides to `request.reset_session` (`devise/lib/devise/rails/warden_compat.rb:8-10`).
107
+
108
+ ### Session serialization: exact keys and shapes
109
+
110
+ - Key: `"warden.user.#{scope}.key"` (`warden/lib/warden/session_serializer.rb:11-13`), e.g. `"warden.user.user.key"`.
111
+ - Value: Devise wires per-scope serializers at `Devise.configure_warden!` (`devise/lib/devise.rb:495-501`), delegating to the model: `serialize_into_session(record)` → **`[record.to_key, record.authenticatable_salt]`** (`devise/lib/devise/models/authenticatable.rb:225-227`), i.e. `[[42], "$2a$12$WCi3OqAFFsR3UN8y..."]` — `authenticatable_salt` is `encrypted_password[0,29]` (`devise/lib/devise/models/database_authenticatable.rb:158-160`). Deserialization re-checks the salt (`authenticatable.rb:229-232`) so password changes invalidate all cookie sessions.
112
+ - Scoped per-user session data: `warden.session(scope)` ⇒ `raw_session["warden.user.#{scope}.session"] ||= {}` (`warden/lib/warden/proxy.rb:244-247`); raises `NotAuthenticated` if not logged in (proxy.rb:245). Deleted on logout (`proxy.rb:276`). This is where timeoutable's `last_request_at` and session_limitable's `unique_session_id` live — and where ours should live.
113
+ - `configure_warden!` runs at routes finalization (`devise/lib/devise/rails/routes.rb:19`, inside `Devise::RouteSet#finalize!` lines 8-24) because mappings are declared by `devise_for` in routes.
114
+ - `bypass_sign_in` writes the serializer directly with **no callbacks** (`devise/lib/devise/controllers/sign_in_out.rb:56-60`).
115
+ - Inside hooks, `warden.request` is a full `ActionDispatch::Request` (Devise monkeypatches `Warden::Mixins::Common#request`, `devise/lib/devise/rails/warden_compat.rb:4-6`) → `remote_ip`, `user_agent`, `params` all available.
116
+
117
+ ## 3. Failed logins
118
+
119
+ Flow: strategy `fail!(message)` sets `@result = :failure` + halts, **does not throw** (`warden/lib/warden/strategies/base.rb:133-141`); `Proxy#authenticate!` does `throw(:warden, opts) unless user` (`warden/lib/warden/proxy.rb:132-136`); `Manager#call`'s `catch(:warden)` receives the hash (`warden/lib/warden/manager.rb:34-44`) → `process_unauthenticated` (manager.rb:112-131; `options[:action] ||= 'unauthenticated'` at 113-116, `options[:message] ||= proxy.message` at 128) → `call_failure_app` (`warden/lib/warden/manager.rb:136-147`):
120
+
121
+ ```ruby
122
+ options.merge!(:attempted_path => ::Rack::Request.new(env).fullpath)
123
+ env["PATH_INFO"] = "/#{options[:action]}"
124
+ env["warden.options"] = options
125
+ _run_callbacks(:before_failure, env, options)
126
+ config.failure_app.call(env).to_a
127
+ ```
128
+
129
+ So in `before_failure`, `env['warden.options']` (== second block arg) contains: **`:scope`**, **`:action`**, **`:message`** (symbol like `:invalid`, `:not_found_in_database`, `:timeout`, `:locked`, `:unconfirmed`, `:session_limited`), **`:attempted_path`** (e.g. `/users/sign_in`), plus whatever `authenticate!` was called with — for Devise: **`:recall`** (`"devise/sessions#new"`) and `:locale` (sessions_controller.rb:47-49).
130
+
131
+ **Attempted identity**: not in warden.options. Recover it from the request — `Rack::Request.new(env)` (or in a Devise app `ActionDispatch::Request.new(env)`) → `params[opts[:scope].to_s]` is the credentials hash (`{"email" => "...", "password" => "..."}`), because Devise's strategy pulls credentials from `params[scope]` (`devise/lib/devise/strategies/authenticatable.rb:93-95`). This is exactly how authtrail captures failed-login identities. **Filter caveat:** `before_failure` fires for *every* warden failure including plain unauthenticated page hits and timeouts; a real credential failure is distinguished by `request.post? && params[scope].is_a?(Hash)` (and/or `opts[:recall]` presence). Devise's FailureApp then re-dispatches to `sessions#new` via `recall` with the configured error status (`devise/lib/devise/failure_app.rb:45-46, 59-83`; reads `warden.options` at 231-233, `attempted_path` at 247-249).
132
+
133
+ OmniAuth failures do **not** pass through warden's failure path — they hit `Devise::OmniauthCallbacksController#failure` reading `omniauth.error.*` env keys (`devise/app/controllers/devise/omniauth_callbacks_controller.rb:10-27`). Separate capture needed for OAuth failures.
134
+
135
+ ## 4. Relevant Devise modules
136
+
137
+ ### trackable
138
+ Hook (`devise/lib/devise/hooks/trackable.rb:7-11`):
139
+ ```ruby
140
+ Warden::Manager.after_set_user except: :fetch do |record, warden, options|
141
+ if record.respond_to?(:update_tracked_fields!) && warden.authenticated?(options[:scope]) && !warden.request.env['devise.skip_trackable']
142
+ record.update_tracked_fields!(warden.request)
143
+ end
144
+ end
145
+ ```
146
+ Columns: `sign_in_count`, `current_sign_in_at`, `last_sign_in_at`, `current_sign_in_ip`, `last_sign_in_ip` (`devise/lib/devise/models/trackable.rb:16-18`); shift-and-set logic at 20-31; `update_tracked_fields!` skips new records and does `save(validate: false)` (33-41); IP = `request.remote_ip` (45-47). **Skip switch: `env['devise.skip_trackable']`** — our gem supersedes trackable and should document coexistence (we must not double-count; we read the same request object).
147
+
148
+ ### rememberable
149
+ - Cookie name **`remember_#{scope}_token`** (`devise/lib/devise/strategies/rememberable.rb:55-57`; same default in `devise/lib/devise/controllers/rememberable.rb:51-53`), a **signed** cookie (`cookies.signed`, strategy line 60, controller line 26), `httponly: true` (controller 42-49).
150
+ - Shape: **`[record.to_key, record.rememberable_value, Time.now.utc.to_f.to_s]`** (`devise/lib/devise/models/rememberable.rb:134-136`); `rememberable_value` = `remember_token` column or `authenticatable_salt` fallback (73-84).
151
+ - Validation `remember_me?(token, generated_at)` (`models/rememberable.rb:103-120`): freshness vs `remember_for`, `generated_at > remember_created_at`, `Devise.secure_compare(rememberable_value, token)`.
152
+ - Strategy (`devise/lib/devise/strategies/rememberable.rb:21-34`): `valid?` iff cookie present (13-16); deserializes, deletes cookie + `pass`es if stale (24-27), else `resource.after_remembered; success!(resource)` (31-32). Registered at line 67. **A remember-me re-login is therefore a full warden `:authentication` event** with `winning_strategy` = `Devise::Strategies::Rememberable` — it creates a *new* Rack session mid-GET-request (renew applies), fires trackable/lockable/our hooks, but does **not** clean CSRF (`clean_up_csrf?` false, 41-43).
153
+ - Cookie is (re)written on login by the hook `after_set_user except: :fetch` when `record.remember_me` truthy (`devise/lib/devise/hooks/rememberable.rb:3-9`); cleared at `before_logout` via forgetable (`devise/lib/devise/hooks/forgetable.rb:7-11`) → `forget_me!` nils `remember_token`/`remember_created_at` (`models/rememberable.rb:58-63`). `after_remembered` model callback (`models/rememberable.rb:100-101`) is our chance-free zone — prefer the warden event.
154
+
155
+ ### timeoutable
156
+ Hook runs on **all** events including `:fetch` (`devise/lib/devise/hooks/timeoutable.rb:8`), guarded by `options[:store] != false && !env['devise.skip_timeoutable']` (line 13). Reads **`warden.session(scope)['last_request_at']`** (line 14) — i.e. inside `raw_session["warden.user.#{scope}.session"]` — with Integer/String coercion (16-19). If `!env['devise.skip_timeout'] && record.timedout?(last_request_at) && !proxy.remember_me_is_active?(record)` → signs out and `throw :warden, scope: scope, message: :timeout` (24-28). Then **touches `last_request_at = Time.now.utc.to_i` every request unless `env['devise.skip_trackable']`** (31-33 — note: it reuses the *trackable* skip flag for the touch). `timedout?` = `last_access <= timeout_in.ago` (`devise/lib/devise/models/timeoutable.rb:30-32`). Interplay for our per-request touch: a cookie-session write already happens every request for timeoutable users, so our touch adds no cookie overhead — but any *DB* touch must be throttled (contrast devise-security's expirable which does `update_column` per request — `devise-security/lib/devise-security/hooks/expirable.rb:7-12`, `models/expirable.rb:30`).
157
+
158
+ ### lockable
159
+ Increment happens **inside the strategy's validate call**, in the model: `valid_for_authentication?` override (`devise/lib/devise/models/lockable.rb:102-120`) — on bad password `increment_failed_attempts` (line 112; atomic `increment_counter` + `reload`, 122-125), `lock_access!` when `attempts_exceeded?` (113-115, lock sets `locked_at`, 42-50). Counter reset on successful login via hook `after_set_user except: :fetch` → `reset_failed_attempts!` (`devise/lib/devise/hooks/lockable.rb:5-9`). Columns: `failed_attempts`, `locked_at`, `unlock_token` (`models/lockable.rb:29-36`). Failure messages: `:locked` / `:last_attempt` (127-139). Locked-account *page hits* are enforced by the activatable hook: `after_set_user` (all events) → `!active_for_authentication?` → logout + throw (`devise/lib/devise/hooks/activatable.rb:6-12`). **Our gem records attempts only; lockout stays Devise's job.**
160
+
161
+ ### omniauthable
162
+ Model is config-only (`devise/lib/devise/models/omniauthable.rb`). The app's callback controller (user-written, per README pattern) calls `sign_in_and_redirect @user, event: :authentication` → `sign_in(scope, resource, options)` (`devise/lib/devise/controllers/helpers.rb:237-244`) → `warden.set_user`. Without the `event:` option the hooks see `:set_user`; `winning_strategy` is `nil` either way. **Provider info is not passed to hooks — but it's in the env**: read `warden.request.env["omniauth.auth"]` (provider/uid) inside the hook during the callback request. The stock controller sets `devise.skip_timeout` (`devise/app/controllers/devise/omniauth_callbacks_controller.rb:4`).
163
+
164
+ ## 5. devise-security session_limitable — the revocation template
165
+
166
+ v0.18.0 (`devise-security/lib/devise-security/version.rb:4`), requires devise >= 4.8.1. Column: `unique_session_id` on the user (`devise-security/lib/devise-security/models/session_limitable.rb:17-19`). The whole mechanism is three hooks in `devise-security/lib/devise-security/hooks/session_limitable.rb`:
167
+
168
+ **(1) Store on login** — lines 6-19:
169
+ ```ruby
170
+ Warden::Manager.after_set_user except: :fetch do |record, warden, options|
171
+ if record.devise_modules.include?(:session_limitable) &&
172
+ warden.authenticated?(options[:scope]) && !record.skip_session_limitable?
173
+ if !options[:skip_session_limitable]
174
+ unique_session_id = Devise.friendly_token
175
+ warden.session(options[:scope])['unique_session_id'] = unique_session_id # line 13
176
+ record.update_unique_session_id!(unique_session_id) # line 14
177
+ else
178
+ warden.session(options[:scope])['devise.skip_session_limitable'] = true # line 16
179
+ end
180
+ end
181
+ end
182
+ ```
183
+ **(2) Validate per request** — lines 25-44: `after_set_user only: :fetch`, guards `options[:store] != false` (line 30); on mismatch `record.unique_session_id != warden.session(scope)['unique_session_id']` (line 31) and not skipped (32-33): logs (34-38), **`warden.raw_session.clear`** (39), **`warden.logout(scope)`** (40), **`throw :warden, scope: scope, message: :session_limited`** (41).
184
+ **(3) Clear on logout** — lines 49-55: `before_logout` → `record.update_unique_session_id!(nil)` (53), so zero valid sessions remain after explicit sign-out.
185
+
186
+ Model plumbing: `update_unique_session_id!` is `update_attribute_without_validatons_or_callbacks` (`models/session_limitable.rb:26-32`); hooks register when the model module is autoloaded — `require 'devise-security/hooks/session_limitable'` at `models/session_limitable.rb:4`.
187
+
188
+ **Weaknesses to design around:**
189
+ - **Single-session only.** Token is one column on the user; each login overwrites it (hook 1, line 14) and hook 2 then kills every other browser. We invert it: token → row in a `sessions` table; per-request check = "row exists and not revoked" → N devices, per-device revocation.
190
+ - **Races.** Login overwrite is non-atomic with in-flight `:fetch` checks from other tabs; a request racing a concurrent login can be logged out spuriously. With a sessions table, concurrent logins each get their own row — race disappears except revoke-vs-inflight-request (acceptable: revocation wins next request).
191
+ - **Skip surface (3 layers):** per-call `sign_in(user, skip_session_limitable: true)` (option forwarded by `sign_in` → `set_user`; hook line 11), per-model `skip_session_limitable?` (model 37-39), and a sticky session flag `'devise.skip_session_limitable'` (lines 16, 33) so skipped logins don't get nuked on subsequent fetches. We should mirror all three (`sessions_skip:` option, model predicate, session flag).
192
+ - **`bypass_sign_in` blind spot:** no callbacks run (`devise/lib/devise/controllers/sign_in_out.rb:56-60`), used by registrations#update after password change (`devise/app/controllers/devise/registrations_controller.rb:60` area) — token in warden session and column both survive unchanged, so it stays consistent; but no fresh-login record is produced. Same applies to our gem (fine — same session continues).
193
+ - The `before_logout` nil-clear (hook 3) means "log out one device = invalidate the only token" — meaningless once tokens are per-row; our analog is "mark this row revoked/ended".
194
+ - Curiosity: `Devise.add_module :session_non_transferable` is registered at `devise-security/lib/devise-security.rb:108` but no model file exists in the repo — broken autoload at HEAD; don't copy blindly.
195
+
196
+ ## 6. Multi-scope
197
+
198
+ - `Devise.mappings` is a plain hash scope→`Devise::Mapping` (`devise/lib/devise.rb:276-283`); on Rails 8 it force-loads lazy routes first (`Rails.application.try(:reload_routes_unless_loaded)`, devise.rb:281, added for Rails 8 in 5.0.0.rc — CHANGELOG.md:55-56).
199
+ - `mapping.name` = singular scope symbol (`devise/lib/devise/mapping.rb:31`), `mapping.to` = the class (mapping.rb:82-84), `Mapping.find_scope!(obj)` resolves class/record→scope (mapping.rb:35-47).
200
+ - Warden is configured per scope at `configure_warden!`: `scope_defaults mapping.name, strategies: mapping.strategies` + per-scope serializers (`devise/lib/devise.rb:492-501`; warden side: `warden/lib/warden/config.rb:74-85`, scope-named serializer methods `warden/lib/warden/manager.rb:69-92`, dispatch in `warden/lib/warden/session_serializer.rb:23-38`).
201
+ - Session keys are scope-qualified: `warden.user.user.key`, `warden.user.admin_user.key`; scoped data `warden.user.admin_user.session`. Controller helpers are generated per mapping (`authenticate_admin_user!`, `current_admin_user` — `devise/lib/devise/controllers/helpers.rb:113-142`).
202
+ - **What our gem must do:** treat `opts[:scope]` as first-class — store it on every row; make the owner association polymorphic (`record.class.name` + `record.to_key`/`id`); never assume `:user`; resolve per-scope config via `Devise.mappings[scope]` *only when Devise is present* (plain Warden apps have scopes without mappings); remember `Devise.sign_out_all_scopes` (default true) means one `DELETE /users/sign_out` triggers `before_logout` once **per scope** (`warden/lib/warden/proxy.rb:272-278`, `devise/app/controllers/devise/sessions_controller.rb:28`).
203
+
204
+ ## 7. State of Devise, June 2026
205
+
206
+ - **Version 5.0.4** (`devise/lib/devise/version.rb:4`), released **2026-05-08** (`devise/CHANGELOG.md:1`). Cadence: 5.0.0.rc 2025-12-31 → 5.0.0 2026-01-23 → 5.0.1 2026-02-13 → 5.0.2 2026-02-18 → 5.0.3 2026-03-16 → 5.0.4 2026-05-08 (CHANGELOG.md:1-27). Repo HEAD committed 2026-06-10 — actively maintained.
207
+ - Security posture: CVE-2026-40295 (FailureApp open redirect via Referer, 5.0.4, CHANGELOG.md:4) and CVE-2026-32700 (confirmable change-email race, 5.0.3, CHANGELOG.md:9).
208
+ - Constraints: `railties >= 7.0`, `warden ~> 1.2.3`, ruby >= 2.7 (`devise/devise.gemspec:29-34`); lockfile resolves warden 1.2.9 (`devise/Gemfile.lock:279`). Warden itself: 1.2.9 since 2020-08-31 (`warden/CHANGELOG.md:3`), `rack >= 2.2.3` (`warden/warden.gemspec`), last commit 2025-09-02 — dormant-but-stable; the hook ABI we attach to hasn't changed in years.
209
+ - **Rails 8/8.1**: official support landed in 5.0.0.rc — "Add Rails 8 support. Routes are lazy-loaded by default in test and development environments now so Devise loads them before `Devise.mappings` call" (CHANGELOG.md:55-56, PR #5728). Rack 3.1: new apps get `error_status = :unprocessable_content` (CHANGELOG.md:57-59).
210
+ - **Hotwire/Turbo**: fully integrated since 4.9/5.x; 5.0 swapped `[data-turbo-cache=false]` → `[data-turbo-temporary]` in shared error partials (CHANGELOG.md:50-52); failure app responds with the configured `error_status` so Turbo handles 422 re-renders.
211
+ - **Rails 8 native auth coexistence: nothing in the CHANGELOG.** No mention of the Rails 8 auth generator — Devise and Rails-native auth are simply parallel worlds; our gem must bridge them itself (Warden hooks for Devise, model/controller concerns for omakase auth).
212
+ - `sign_in_after_reset_password?` became a customizable controller hook in 5.0.2 (CHANGELOG.md:16, PR #5826).
213
+
214
+ ## 8. How third-party gems attach cleanly
215
+
216
+ devise-security's pattern (`devise-security/lib/devise-security.rb`): `require 'devise'` at line 9 (hard dependency, fine for them, **not for us**); adds `Devise.mattr_accessor` config (11-92); registers modules via `Devise.add_module :session_limitable, model: 'devise-security/models/session_limitable'` (104-111) — `add_module` (`devise/lib/devise.rb:397-441`) inserts into `Devise::ALL`/`STRATEGIES`/`ROUTES` and sets up a `Devise::Models` **autoload** (devise.rb:433-437), so the model file (which requires the hook file at its line 3-4) loads only when an app declares `devise :session_limitable`. The engine is minimal: `DeviseSecurity::Engine < ::Rails::Engine` + `ActiveSupport.on_load(:action_controller)` + `to_prepare` patches (`devise-security/lib/devise-security/rails.rb:4-18`). Devise also exposes `Devise.warden { |config| ... }` for warden *config* (strategies, failure app) — blocks stored at `devise/lib/devise.rb:453-455`, executed in `configure_warden!` at devise.rb:504.
217
+
218
+ **Recommended pattern for the sessions gem** (soft-depend on both Devise and Warden):
219
+
220
+ ```ruby
221
+ class Sessions::Railtie < ::Rails::Railtie
222
+ initializer "sessions.warden" do
223
+ # Bundler.require has already loaded every gem in the Gemfile,
224
+ # so defined?(Warden) is reliable here regardless of Gemfile order.
225
+ require "sessions/warden_hooks" if defined?(::Warden::Manager)
226
+ end
227
+ end
228
+ ```
229
+
230
+ Why this is safe: (a) all gems are loaded before initializers run, so `defined?(::Warden::Manager)` is decisive; (b) hook arrays are class-level on `Warden::Manager` and read live per request (`warden/lib/warden/hooks.rb:67-69`, `warden/lib/warden/manager.rb:51-53`) — registration just has to precede the first request, and middleware insertion (done by Devise's engine, `devise/lib/devise/rails.rb:11-13`) is independent of hook registration; (c) no `require "warden"` from our gem → zero load-order coupling and the gem stays inert in non-Warden apps. Guard Devise-specific lookups (`Devise.mappings`, `Devise.friendly_token`) behind `defined?(::Devise)` inside the hook bodies. Do **not** rely on `ActiveSupport.on_load(:warden)` — no such load hook exists. Ordering note: Devise's own hooks register at model-class load (eager-load, after initializers), so our initializer-registered hooks run **before** trackable/timeoutable in `_run_callbacks` order; if we ever need to run after them, register ours lazily too (e.g. from `to_prepare`) or tolerate both orders (preferred).
231
+
232
+ ## Implications for the sessions gem
233
+
234
+ Recommended hook set (all in one `sessions/warden_hooks.rb`):
235
+
236
+ 1. **Record login success + create session row** — `Warden::Manager.after_set_user except: :fetch do |record, warden, opts|`, guarded by `warden.authenticated?(opts[:scope])` && `opts[:store] != false` && our skip flags (mirror trackable.rb:8 + rememberable.rb:5 + session_limitable.rb:7-11 guards). Generate token (`SecureRandom`/`Devise.friendly_token`-equivalent), write `warden.session(opts[:scope])['sessions_token'] = token` (template: session_limitable.rb:13), insert row with: scope, polymorphic owner, IP/UA from `warden.request` (an `ActionDispatch::Request` per warden_compat.rb:4-6), login method from `opts[:event]` + `warden.winning_strategy&.class` (§1 matrix), provider from `warden.request.env["omniauth.auth"]` if present.
237
+ 2. **Record failure with attempted identity** — `Warden::Manager.before_failure do |env, opts|`: persist `opts[:scope]/:action/:message/:attempted_path` (manager.rb:138-142); identity via `request.params[opts[:scope].to_s]` filtered to `request.post? && hash present` to exclude plain 401 page hits and timeouts (§3). Never store the password key.
238
+ 3. **Per-request validate-and-touch** — `Warden::Manager.after_set_user only: :fetch`: look up row by `warden.session(scope)['sessions_token']`; if missing/revoked → `warden.raw_session.clear; warden.logout(scope); throw :warden, scope: scope, message: :session_revoked` (template: session_limitable.rb:39-41); else touch `last_seen_at` **throttled** (devise-security's expirable does an unthrottled `update_column` every request — expirable.rb:7-12 — don't copy that). Guard `options[:store] != false` (timeoutable.rb:13).
239
+ 4. **Record logout** — `Warden::Manager.before_logout do |record, warden, opts|`: mark row ended/revoked; remember it fires per scope, also for *forced* logouts (timeout throw at timeoutable.rb:27-28, activatable.rb:9-10, our own revocation) — record a reason when we can infer one (we threw it), else "logout".
240
+
241
+ Edge cases to handle explicitly:
242
+ - **Remember-me**: a cookie re-auth is `:authentication` with `winning_strategy == Devise::Strategies::Rememberable` (§4) — it's a genuinely new Rack session: create a new row (or re-link via the remember cookie's `[id, token, ts]` triple) rather than ignoring it; CSRF is *not* rotated there.
243
+ - **Timeoutable**: its hook may `throw` on the same `:fetch` event our hooks use; tolerate running before or after it (a touched-then-timed-out session is harmless noise). Honor `env['devise.skip_timeout']` semantics on sign-in/out requests (sessions_controller.rb:7).
244
+ - **`sign_in_after_reset_password` / sign-up auto-login**: both call the `sign_in` helper (`devise/app/controllers/devise/passwords_controller.rb:43`, `registrations_controller.rb:100`) → event `:set_user` → our hook 1 fires (correct: it IS a new login). Confirmations controller never auto-signs-in (no `sign_in` call in `confirmations_controller.rb`).
245
+ - **`bypass_sign_in`** (password change keep-alive): zero callbacks (sign_in_out.rb:56-60) — same session continues; our token stays valid. Document it.
246
+ - **API / HTTP Basic / token auth**: `skip_session_storage [:http_auth]` makes the strategy `store? == false` (`devise/lib/devise/strategies/authenticatable.rb:13-15`) → `set_user` with `store: false` still fires `after_set_user` **on every request** — without the `opts[:store] != false` guard we'd create a session row per API call. Same guard protects against devise-jwt-style integrations.
247
+ - **Paranoid mode**: failure message is `:invalid` instead of `:not_found_in_database` (database_authenticatable.rb:22-25) — store the symbol verbatim, don't infer user existence.
248
+ - **Rack SID**: never persist it as the session identifier — `:renew` (proxy.rb:178-186) rotates it at commit after login. Our own token in the warden scoped session is the stable handle; it also gets cleaned up by warden itself on logout (proxy.rb:276).
249
+ - **Multi-scope**: rows carry scope + polymorphic owner; `sign_out_all_scopes` produces one `before_logout` per scope (§6).