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,450 @@
1
+ # Market validation & security/privacy requirements
2
+
3
+ Research memo for the `sessions` gem PRD — drop-in session & login-activity tracking + device management
4
+ for Rails 8+ (omakase auth, Devise, OAuth): demand validation via cross-framework and in-the-wild
5
+ precedent, the UX bar, competitive scan, and security/privacy/compliance norms as hard requirements.
6
+ All sources verified via live web fetch on **2026-06-10/11**; non-verifiable items marked **UNVERIFIED**.
7
+
8
+ ## Top findings
9
+
10
+ 1. **Every comparable framework ships this; Rails doesn't.** Laravel's default app is born with a
11
+ `sessions` table (`user_id`, `ip_address`, `user_agent`, `last_activity`) and Jetstream ships a
12
+ "Browser Sessions" page with "logout other browser sessions"; Phoenix's `mix phx.gen.auth` tracks
13
+ every session token in a DB table and deletes them all on password change; Django's answer is
14
+ `django-user-sessions` (Jazzband) — adopted but aging. Rails 8's own auth generator creates the
15
+ *table* (`CreateSessions user:references ip_address:string user_agent:string`) but ships **zero UI:
16
+ no listing, no per-session revocation, no alerts, no audit trail**. The gap is the product.
17
+ 2. **Every serious Rails app hand-rolls it**: GitLab (`ActiveSession`, Redis + device_detector),
18
+ Mastodon (`SessionActivation`, Postgres + `browser` gem), Discourse (`UserAuthToken`, rotating
19
+ SHA-1-hashed tokens + `UserAuthTokenLog` audit table). Three independent, expensive reimplementations
20
+ of the same feature = textbook gem opportunity.
21
+ 3. **"View and revoke your sessions" is a literal compliance requirement**: OWASP ASVS v4.0.3 **3.3.4**
22
+ (L2): "users are able to view and (having re-entered login credentials) log out of any or all
23
+ currently active sessions and devices" — carried into ASVS 5.0 as **7.5.2**. Kill-all-sessions on
24
+ password change is ASVS 3.3.3 / 7.4.3.
25
+ 4. **The gem name `sessions` is unregistered on RubyGems** (API 404, 2026-06-10) and no living Ruby
26
+ library owns the space: `authtrail` (4.1M downloads) only logs login *events*; `devise-security`
27
+ (20.8M) only *limits* to one session; `session_tracker` died in 2021; `active_sessions`,
28
+ `user_sessions`, `login_activity` and `sessionable` are all unregistered.
29
+ 5. **IPs are personal data** (CJEU *Breyer* C-582/14; GDPR Recital 30), but security logging has an
30
+ explicit lawful basis (Recital 49 → Art. 6(1)(f)). CNIL recommends **6–12 month** log retention. So
31
+ the gem must ship configurable retention + a purge job, optional IP truncation (Google Analytics
32
+ last-octet precedent), and must **never log raw session IDs** (OWASP: log a salted hash — Discourse
33
+ already does exactly this).
34
+ 6. **Fingerprinting is a legal trap**: Article 29 WP Opinion 9/2014 (WP224) makes device fingerprinting
35
+ consent-gated under ePrivacy Art. 5(3). UA + IP parsing (the GitLab/Mastodon/Discourse scope) is the
36
+ safe industry standard; "impossible/atypical travel" (Microsoft Entra) is the established next-step
37
+ fraud signal, computable later from the data this gem stores on day one.
38
+
39
+ ---
40
+
41
+ ## Cross-framework precedent
42
+
43
+ ### Laravel (PHP) — the strongest precedent
44
+
45
+ - **Database session driver is the default**: "By default, Laravel is configured to use the `database`
46
+ session driver." — https://laravel.com/docs/12.x/session (accessed 2026-06-10).
47
+ - **The schema ships in the default migration** (`0001_01_01_000000_create_users_table.php`): `id`
48
+ (string PK), `user_id` (`foreignId`, nullable, indexed), `ip_address` (string 45, nullable),
49
+ `user_agent` (text, nullable), `payload` (longText), `last_activity` (integer, indexed) —
50
+ https://github.com/laravel/laravel/blob/12.x/database/migrations/0001_01_01_000000_create_users_table.php
51
+ (accessed 2026-06-10). Implication: **every new Laravel app is born with user-attributed,
52
+ IP/UA-stamped session rows.**
53
+ - **Jetstream "Browser Sessions"** (official feature docs): users "may view the browser sessions
54
+ associated with their account" and "logout browser sessions other than the one being used by the
55
+ device they are currently using"; built on `Illuminate\Session\Middleware\AuthenticateSession`;
56
+ requires `SESSION_DRIVER=database` — https://jetstream.laravel.com/features/browser-sessions.html
57
+ (accessed 2026-06-10).
58
+ - **Framework-level API** — "Invalidating Sessions on Other Devices": `Auth::logoutOtherDevices($password)`
59
+ plus the `auth.session` middleware alias; "invalidating and 'logging out' a user's sessions that are
60
+ active on other devices without invalidating the session on their current device… typically utilized
61
+ when a user is changing or updating their password" —
62
+ https://laravel.com/docs/12.x/authentication#invalidating-sessions-on-other-devices (accessed 2026-06-10).
63
+ - Positioning note: Laravel treats this as a *starter-kit default*, not an enterprise add-on. That is the
64
+ bar Rails is being compared against.
65
+
66
+ ### Django (Python)
67
+
68
+ - **django-user-sessions** (Jazzband): "Extend Django sessions with a foreign key back to the user,
69
+ allowing enumerating all user's sessions."
70
+ - Features: per-user session queryset, remote logout (`user.session_set.all().delete()`), **IP +
71
+ user-agent stored per session**, Django admin integration, bundled session-list template.
72
+ - Adoption/maintenance: 712 stars, 127 forks; **last release 1.7.1 (Jan 2020)**; Django 3.2/4.2 —
73
+ https://github.com/jazzband/django-user-sessions (accessed 2026-06-10). Read: enough demand for
74
+ Jazzband adoption; its staleness is the maintenance gap a fresh, Rails-8-native gem avoids.
75
+ - Django core stores sessions server-side by default but does **not** bind them to users or expose any
76
+ device UI — which is exactly why the package exists.
77
+
78
+ ### Phoenix / Elixir
79
+
80
+ - `mix phx.gen.auth` generates a `UserToken` schema backed by a `users_tokens` table: "All sessions and
81
+ tokens are tracked in a separate table. This allows you to track how many sessions are active for each
82
+ account. You could even expose this information to users if desired." On password change: "all tokens
83
+ are deleted, and the user has to log in again on all devices." —
84
+ https://hexdocs.pm/phoenix/mix_phx_gen_auth.html (accessed 2026-06-10).
85
+ - The *official generator* bakes in revocable, enumerable, server-side sessions — the same architecture
86
+ this gem brings to Rails.
87
+
88
+ ### JS ecosystem (NextAuth/Auth.js)
89
+
90
+ - No built-in "your devices" UI; session invalidation is a recurring community ask: "How to
91
+ invalidate/delete sessions for CredentialsProvider" —
92
+ https://github.com/nextauthjs/next-auth/discussions/4687 (accessed 2026-06-10). Database-session
93
+ strategy makes listing *possible* but UI, revocation and alerts are DIY. Commercial auth (Clerk,
94
+ Auth0) sells device/session management as a hosted feature (**UNVERIFIED**: tier placement not re-checked).
95
+
96
+ ### Rails — framing the gap
97
+
98
+ - Rails 8's authentication generator creates exactly the right table and nothing else:
99
+ `generate "migration", "CreateSessions", "user:references ip_address:string user_agent:string"`; routes
100
+ `resource :session, only: [:new, :create, :destroy]` (current-session lifecycle only) —
101
+ https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/authentication/authentication_generator.rb
102
+ (accessed 2026-06-11).
103
+ - Devise persists nothing per-session (cookie-only by default); `activerecord-session_store` (the legacy
104
+ extraction) persists sessions without `user_id`/IP/UA or any management UI —
105
+ https://github.com/rails/activerecord-session_store (accessed 2026-06-10; **UNVERIFIED** beyond README).
106
+ - **Rails ships the schema; nobody ships the product.** A gem named `sessions` that upgrades the exact
107
+ table Rails 8 already generates is a natural, omakase-aligned extension.
108
+
109
+ ---
110
+
111
+ ## Rails apps that hand-rolled it
112
+
113
+ ### GitLab — `ActiveSession` (Redis)
114
+
115
+ - **User docs** (https://docs.gitlab.com/user/profile/active_sessions/, accessed 2026-06-10): Profile →
116
+ Access → Active sessions; each row shows "IP: {ip_address}, Browser: {browser}, Last active:
117
+ {updated_at}" with a per-session **Revoke** button; "GitLab allows users to have up to 100 active
118
+ sessions at once. If the number of active sessions exceeds 100, the oldest ones are deleted." The
119
+ current session cannot be revoked; revoking a session also revokes all "Remember me" tokens.
120
+ - **Model** (https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/active_session.rb, accessed
121
+ 2026-06-10): Redis-backed (per-user lookup set + per-session keys); attributes `ip_address, browser,
122
+ os, device_name, device_type, is_impersonated, session_id, session_private_id, admin_mode,
123
+ step_up_authenticated` (+ `created_at`, `updated_at`); `ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100`;
124
+ `destroy_session`, `destroy_all_but_current`, `clean_up_old_sessions`.
125
+ - **UA parsing**: `class SafeDeviceDetector < ::DeviceDetector` (the `device_detector` gem) with
126
+ `USER_AGENT_MAX_SIZE = 1024` truncation — https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/safe_device_detector.rb (accessed 2026-06-11).
127
+
128
+ ### Mastodon — `SessionActivation` (Postgres)
129
+
130
+ - **Schema** (model annotation): `id, ip, user_agent, created_at, updated_at, access_token_id,
131
+ session_id, user_id, web_push_subscription_id`.
132
+ - Lifecycle: `deactivate(id)` (revoke one), `exclusive(id)` (destroy all but one), `purge_old` capped by
133
+ `Rails.configuration.x.max_session_activations` —
134
+ https://github.com/mastodon/mastodon/blob/main/app/models/session_activation.rb (accessed 2026-06-10).
135
+ - **Browser/platform detection via the `browser` gem**: `@detection ||= Browser.new(user_agent)`;
136
+ `browser → detection.id`; `platform → detection.platform.id` —
137
+ https://github.com/mastodon/mastodon/blob/main/app/models/concerns/browser_detection.rb (accessed 2026-06-11).
138
+ - UX: sessions list (browser/platform/IP/last-active) with revoke in account security settings
139
+ (**UNVERIFIED**: exact settings path not re-checked against a live instance).
140
+
141
+ ### Discourse — `UserAuthToken` (rotating hashed tokens)
142
+
143
+ - **Hashed at rest with a server-side secret**:
144
+ `Digest::SHA1.base64digest("#{token}#{GlobalSetting.safe_secret_key_base}")`; the raw token exists only
145
+ in memory (`attr_accessor :unhashed_auth_token`) and is never persisted.
146
+ - **Dual-token rotation**: `auth_token` + `prev_auth_token`, rotated every `ROTATE_TIME = 10.minutes`
147
+ (`URGENT_ROTATE_TIME = 1.minute` if unseen) so a stolen-cookie replay desyncs. **Limits & hygiene**:
148
+ `MAX_SESSION_COUNT = 60` with oldest-token eviction; `cleanup!` purges tokens past `maximum_session_age`.
149
+ - **Audit companion** `UserAuthTokenLog`: `client_ip`, `user_agent`, `seen_at`, `action`
150
+ (generate/rotate/destroy), `path`. All from
151
+ https://github.com/discourse/discourse/blob/main/app/models/user_auth_token.rb (accessed 2026-06-10).
152
+ - **End-user UI** — "Recently Used Devices" in user preferences: "a list of all devices you are currently
153
+ logged in with… operating system, browser, location, and last seen time" + "Log Out All" (shipped in
154
+ Discourse 2.2) — https://meta.discourse.org/t/see-recently-used-devices/100070 (accessed 2026-06-11).
155
+
156
+ **Conclusion:** three flagship Rails codebases independently built — and maintain, with genuinely tricky
157
+ security code (token rotation, UA-parser hardening, Redis cluster handling) — the exact feature set this
158
+ gem packages. None of their implementations is extractable or reusable by a normal app.
159
+
160
+ ---
161
+
162
+ ## The UX bar (SaaS examples)
163
+
164
+ What end-users have been trained to expect from a "sessions / your devices" page:
165
+
166
+ - **GitHub** — Settings → Access → **Sessions**: list of active web sessions + GitHub Mobile devices;
167
+ "To revoke a web session, click **Revoke session**"; revoking a mobile session also removes it as a
168
+ 2FA factor —
169
+ https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/viewing-and-managing-your-sessions
170
+ (accessed 2026-06-10). Per-session device icon and approximate-location rendering on the live page:
171
+ **UNVERIFIED** (requires signed-in UI).
172
+ - Companion **security log**: "Each audit log entry shows applicable information about an event, such
173
+ as… The user (actor) who performed the action… The action that was performed, Which country the
174
+ action took place in, The date and time the action occurred." —
175
+ https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/reviewing-your-security-log
176
+ (accessed 2026-06-11). This pairing is precisely the gem's two-surface design: devices page + audit trail.
177
+ - **Google** — "Your devices" / Manage all devices: device name, location, last activity ("the last time
178
+ there was communication between the device or session and Google's systems, at each location"),
179
+ per-device **Sign out**; guidance keyed to "You don't recognize a device" —
180
+ https://support.google.com/accounts/answer/3067630 (accessed 2026-06-10).
181
+ - **Google sign-in alerts** — the canonical **"Was this you?"** pattern: security alert email/push on
182
+ sign-in from a new device or location; "review the sign-in details… device type, time, and location";
183
+ one-tap "No, secure account"; legitimate alerts are mirrored in the account's Recent security
184
+ activity — https://support.google.com/accounts/answer/2590353 (accessed 2026-06-11).
185
+ - **Slack** — Account Settings → "Sign out of all other sessions" (password-confirmed); per-device
186
+ sign-out documented in "Sign out of Slack" —
187
+ https://slack.com/intl/en-gb/help/articles/214613347-sign-out-of-slack (accessed 2026-06-10).
188
+ - **Stripe** — Dashboard → Personal details: "The Login sessions section at the bottom of the page shows
189
+ the locations, IP addresses, and times of recent logins" + a **Sign out all other sessions** button —
190
+ https://support.stripe.com/questions/sign-out-of-stripe-web-sessions (accessed 2026-06-11).
191
+ - **Shopify** — Profile → Security → **Devices**: recently logged-in devices, per-device **Log out**,
192
+ "Log out all devices" (when >5 devices), per-device login history and pages visited; admins can revoke
193
+ a staff member's device/app access (Settings → Users → Revoke Access) —
194
+ https://help.shopify.com/en/manual/your-account/logging-in (accessed 2026-06-10).
195
+
196
+ **Takeaway** — the contract is standardized: **device/browser label (parsed UA) + approximate location
197
+ (from IP) + last-active timestamp + per-row revoke + "sign out everywhere" + email alert on new
198
+ sign-in.** The gem's default views should replicate exactly this, and nothing more exotic.
199
+
200
+ ---
201
+
202
+ ## Demand signals & competitive scan
203
+
204
+ ### Devise can't do it, and users keep asking
205
+
206
+ - "**Force logout for specific user**" — heartcombo/devise issue #5262 (opened 2020-06-26, closed
207
+ unresolved): "The method sign_out always destroy the current_user session, not the session for the
208
+ specific user I sent as parameter." — https://github.com/heartcombo/devise/issues/5262 (accessed 2026-06-10).
209
+ - "**expire_all_remember_me_on_sign_out does not work as expected and might be obsolete**" — #5027
210
+ (multi-device remember-me semantics) — https://github.com/heartcombo/devise/issues/5027 (accessed 2026-06-10).
211
+ - "**Disable automatic logout when log in on other browser**" — #4607 (concurrent-session pain from
212
+ single-session hacks) — https://github.com/heartcombo/devise/issues/4607 (accessed 2026-06-10).
213
+
214
+ ### A decade-deep cottage industry of workarounds
215
+
216
+ Tutorials that all reinvent the same `session_token`-in-`authenticatable_salt` hack (revoke-all-or-nothing,
217
+ no listing, no per-device revoke, no audit trail), all accessed 2026-06-10/11:
218
+
219
+ - Jon Leighton, "Revocable sessions with Devise" (2013) — https://jonleighton.name/2013/revocable-sessions-with-devise/
220
+ - makandra dev, "Devise: Invalidating all sessions for a user" — https://makandracards.com/makandra/53562-devise-invalidating-sessions-user
221
+ - "Invalidating All User Sessions With Rails and Devise Gem" — https://medium.com/better-programming/invalidating-all-user-sessions-with-rails-and-devise-gem-b457c15e0dc
222
+ - PentesterLab, "How Devise Solves Session Invalidation in Rails" — https://pentesterlab.com/blog/rails-devise-session-invalidation
223
+ - "Setting up multi-device/browser session tracking for Devise" — https://rails.substack.com/p/setting-up-multi-devicebrowser-session
224
+ - The same question has been asked-and-hacked from 2013 through today. StackOverflow per-question view
225
+ counts: **UNVERIFIED** (Stack Exchange API unreachable from this environment; treat as directional).
226
+
227
+ ### RubyGems competitive scan
228
+
229
+ Source: RubyGems API (`https://rubygems.org/api/v1/gems/NAME.json`), accessed 2026-06-10.
230
+
231
+ | Gem | Total downloads | Latest release | Verdict |
232
+ |---|---|---|---|
233
+ | `authtrail` | 4,114,257 (v1.0.0: 86,334 since 2026-04-04 ≈ 1.3k/day) | 1.0.0 — 2026-04-04 | Alive & loved ("Track Devise login activity"), but **login-event log only**: Devise-coupled, no live sessions, no devices UI, no revocation. Validates demand without occupying the space. |
234
+ | `devise-security` | 20,779,731 | 0.18.0 — 2023-04-15 | Enterprise policy modules; `session_limitable` "ensures, that there is only one session usable per account at once" — **no listing, no devices UI** (README: https://github.com/devise-security/devise-security, accessed 2026-06-11). |
235
+ | `session_tracker` | 20,192 | 0.0.5 — 2021-11-19 | Dead (Redis session lister, abandoned). |
236
+ | `sessions` | — | — | **404 — name available.** |
237
+ | `active_sessions` / `user_sessions` / `login_activity` / `sessionable` | — | — | All 404 — no squatters, no competitors. |
238
+
239
+ **Conclusion:** no living Ruby library offers session listing + device management + login audit as a
240
+ product; the closest gems prove adjacent demand (authtrail) or adjacent policy (devise-security), and
241
+ the `sessions` name is free.
242
+
243
+ ---
244
+
245
+ ## Compliance requirements (OWASP/ASVS/NIST/SOC2)
246
+
247
+ ### OWASP ASVS v4.0.3 — V3 Session Management (released 2021-10-28)
248
+
249
+ Source: https://github.com/OWASP/ASVS/blob/v4.0.3/4.0/en/0x12-V3-Session-management.md (accessed
250
+ 2026-06-11); release date via GitHub Releases API (tag `v4.0.3_release`).
251
+
252
+ - **3.2.1** (L1+): "Verify the application generates a new session token on user authentication."
253
+ - **3.3.1** (L1+): "Verify that logout and expiration invalidate the session token, such that the back
254
+ button or a downstream relying party does not resume an authenticated session…"
255
+ - **3.3.2**: periodic re-authentication when staying logged in — L1: 30 days; L2: "12 hours or 30 minutes
256
+ of inactivity, 2FA optional"; L3: "12 hours or 15 minutes of inactivity, with 2FA".
257
+ - **3.3.3** (L2+): "Verify that the application gives the option to terminate all other active sessions
258
+ after a successful password change (including change via password reset/recovery), and that this is
259
+ effective across the application, federated login (if present), and any relying parties."
260
+ - **3.3.4** (L2+): "Verify that users are able to view and (having re-entered login credentials) log out
261
+ of any or all currently active sessions and devices." ← **the gem's core feature is a literal ASVS
262
+ requirement.**
263
+
264
+ ### OWASP ASVS 5.0 — V7 Session Management (v5.0.0 released 2025-05-30)
265
+
266
+ Source: https://github.com/OWASP/ASVS/blob/master/5.0/en/0x16-V7-Session-Management.md
267
+ (accessed 2026-06-11; 5.0 line, post-5.0.0 text); release date via GitHub Releases API (`v5.0.0_release`).
268
+
269
+ - **7.5.2** (L2): "Verify that users are able to view and (having authenticated again with at least one
270
+ factor) terminate any or all currently active sessions."
271
+ - **7.4.3** (L2): terminate all other sessions "after a successful change or removal of any
272
+ authentication factor (including password change via reset or recovery and, if present, an MFA
273
+ settings update)."
274
+ - **7.4.1** (L1): when termination is triggered (logout/expiration), "the application disallows any
275
+ further use of the session."
276
+ - **7.3.1 / 7.3.2** (L2): inactivity timeout and absolute maximum session lifetime enforced "according
277
+ to risk analysis and documented security decisions."
278
+
279
+ ### OWASP Session Management Cheat Sheet
280
+
281
+ Source: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
282
+ (accessed 2026-06-11).
283
+
284
+ - **User-facing session controls are recommended outright**: "add user capabilities that allow checking
285
+ the details of active sessions at any time, monitor and alert the user about concurrent logons, provide
286
+ user features to remotely terminate sessions manually, and track account activity history."
287
+ - **Never log session IDs**: "Sensitive data like the session ID should not be included in the logs…";
288
+ "It is recommended to log a salted-hash of the session ID instead of the session ID itself in order to
289
+ allow for session-specific log correlation without exposing the session ID."
290
+ - **Server-side invalidation is mandatory**: "the web application must take active actions to invalidate
291
+ the session on both sides, client and server. The latter is the most relevant and mandatory from a
292
+ security perspective."
293
+ - **Timeouts**: idle "2-5 minutes for high-value applications and 15-30 minutes for low risk
294
+ applications"; absolute "between 4 and 8 hours" for all-day applications. **Rotation**: "The session ID
295
+ must be renewed or regenerated… after any privilege level change", most importantly at authentication.
296
+
297
+ ### NIST SP 800-63B-4 (Revision 4, published 2025)
298
+
299
+ Source: https://pages.nist.gov/800-63-4/sp800-63b.html (accessed 2026-06-11).
300
+
301
+ - AAL1: "A definite reauthentication overall timeout SHALL be established, which SHOULD be no more than
302
+ 30 days" (§2.1.3). AAL2: overall timeout "SHOULD be no more than 24 hours"; "The inactivity timeout
303
+ SHOULD be no more than 1 hour" (§2.2.3). AAL3: overall "SHALL be no more than 12 hours"; inactivity
304
+ "SHOULD be no more than 15 minutes" (§2.3.3).
305
+ - Session management requirements live in **Section 5 ("Session")**, reauthentication in §5.2.
306
+ - PRD implication: ship idle/absolute timeout config with named presets (`:nist_aal2`, `:owasp_low_risk`…).
307
+
308
+ ### SOC 2 (AICPA Trust Services Criteria) — practical expectations
309
+
310
+ - **CC6.1** criterion text: "The entity implements logical access security software, infrastructure, and
311
+ architectures over protected information assets to protect them from security events to meet the
312
+ entity's objectives." — reproduced at
313
+ https://hub.powerpipe.io/mods/turbot/aws_compliance/controls/benchmark.soc_2_cc_6_1 and explained at
314
+ https://www.hicomply.com/en-us/hub/soc-2-controls-cc6-logical-and-physical-access-controls
315
+ (both accessed 2026-06-11).
316
+ - Auditors operationalize CC6.x as: identification & authentication of users (CC6.1), credential
317
+ issuance/registration and **removal of access** (CC6.2/CC6.3), plus **monitoring of system activity for
318
+ anomalies** under CC7.x. In practice: session timeout policy, the ability to terminate a departed or
319
+ compromised user's sessions, and a reviewable login/audit trail are standard SOC 2 evidence requests.
320
+ (Practical interpretation from the secondary sources above; exact AICPA points-of-focus wording:
321
+ **UNVERIFIED** — the AICPA TSC PDF is behind a download wall.)
322
+
323
+ ---
324
+
325
+ ## Privacy & GDPR requirements
326
+
327
+ 1. **IP addresses are personal data.**
328
+ - CJEU, *Breyer v Bundesrepublik Deutschland*, C-582/14 (judgment 2016-10-19): a dynamic IP held by a
329
+ website operator is personal data where the operator "has the legal means which enable it to
330
+ identify the data subject with additional data which the internet service provider has" —
331
+ https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A62014CJ0582 (accessed 2026-06-11).
332
+ - GDPR **Recital 30**: "Natural persons may be associated with online identifiers provided by their
333
+ devices… such as internet protocol addresses, cookie identifiers…" —
334
+ https://gdpr-info.eu/recitals/no-30/ (accessed 2026-06-11).
335
+ - → Everything the gem stores (IP, UA, session metadata) is PII: encryptable, purgeable, exportable.
336
+ 2. **Lawful basis exists and is explicit — document it, don't ask for consent.**
337
+ - GDPR **Recital 49**: processing "strictly necessary and proportionate for the purposes of ensuring
338
+ network and information security" — e.g. "preventing unauthorised access to electronic
339
+ communications networks" — "constitutes a legitimate interest" (i.e. Art. 6(1)(f)) —
340
+ https://gdpr-info.eu/recitals/no-49/ (accessed 2026-06-11). *Breyer* itself rejected a national rule
341
+ that would have barred storing IPs for "maintaining general website security" (same EUR-Lex source).
342
+ - → Ship a docs section stating Art. 6(1)(f)/Recital 49 as the default basis with a balancing-test
343
+ note; no consent banner is needed for UA+IP security logging.
344
+ 3. **Retention must be bounded.**
345
+ - CNIL, *Recommandation relative aux mesures de journalisation* (published 2021): keep logs
346
+ "pour une durée comprise entre six mois et un an" (six months to one year), extendable to ~3 years
347
+ only with documented necessity and proportionality —
348
+ https://www.cnil.fr/fr/la-cnil-publie-une-recommandation-relative-aux-mesures-de-journalisation
349
+ (accessed 2026-06-11).
350
+ - → Default audit-trail retention ≈ 12 months, configurable, enforced by a built-in purge job.
351
+ 4. **IP minimization has a famous precedent.**
352
+ - Google Analytics IP anonymization "sets the last octet of IPv4 user IP addresses… to zeros in memory
353
+ shortly after being sent" and "the last 80 bits of IPv6 addresses to zeros"; "the full IP address is
354
+ never written to disk in this case" — https://support.google.com/analytics/answer/2763052
355
+ (accessed 2026-06-11).
356
+ - → Offer `ip_mode: :full | :truncated | :none`, with truncation applied **before persistence**.
357
+ 5. **Encryption at rest, with the querying tradeoff documented.**
358
+ - Rails Active Record Encryption: "The `:deterministic` option… will produce the same encrypted output
359
+ given the same plaintext input… makes querying encrypted attributes possible"; but it "allows for
360
+ querying by trading off lesser security… non-deterministic encryption is recommended… unless you
361
+ need to query by the encrypted attribute" — https://guides.rubyonrails.org/active_record_encryption.html
362
+ (accessed 2026-06-11).
363
+ - → Support `encrypts :ip_address, deterministic: true` (needed for "other logins from this IP"
364
+ queries) and non-deterministic for `user_agent`; document the tradeoff verbatim.
365
+ 6. **Data minimization** (GDPR Art. 5(1)(c), https://gdpr-info.eu/art-5-gdpr/, accessed 2026-06-11):
366
+ store only what the UX requires (IP, UA, timestamps, hashed session reference); never request bodies,
367
+ attempted passwords, or full referrer trails — even for failed logins.
368
+
369
+ ---
370
+
371
+ ## Fraud-detection norms
372
+
373
+ - **Atypical/impossible travel is the canonical account-takeover signal.**
374
+ - Microsoft Entra ID Protection, "Atypical travel": "identifies two sign-ins originating from
375
+ geographically distant locations… takes into account… the time between the two sign-ins and the time
376
+ it would take for the user to travel from the first location to the second"; ignores known-VPN false
377
+ positives; "initial learning period of the earliest of 14 days or 10 logins."
378
+ - Related detections worth mirroring conceptually: "Impossible travel" (Defender for Cloud Apps),
379
+ "New country", "Anonymous IP address", and "Unfamiliar sign-in properties" ("IP, ASN, location,
380
+ device, browser, and tenant IP subnet", with a ≥5-day learning mode) —
381
+ https://learn.microsoft.com/en-us/entra/id-protection/concept-identity-protection-risks
382
+ (page dated 2026-04-22; accessed 2026-06-11).
383
+ - → Roadmap stance: v1 stores the raw material (IP, geo, timestamps) + exposes hooks; heuristics
384
+ (new-country, velocity/impossible-travel) come later and stay advisory, never auto-blocking.
385
+ - **New-device / new-location alerting is industry table stakes**: Google's "Did you just sign in?"
386
+ alert (device type, time, location, "No, secure account") —
387
+ https://support.google.com/accounts/answer/2590353 (accessed 2026-06-11); GitHub/Stripe/Shopify
388
+ equivalents cited above.
389
+ - → The gem ships a `new_device` mailer with a "secure your account" CTA (revoke session + change
390
+ password) on by default.
391
+ - **Why NOT browser fingerprinting**: Article 29 Working Party, Opinion 9/2014 on device fingerprinting
392
+ (WP224, adopted 2014-11-27): fingerprinting that stores or reads device information requires **prior
393
+ consent under Art. 5(3) of the ePrivacy Directive**, with no general exemption for tracking purposes —
394
+ https://ec.europa.eu/justice/article-29/documentation/opinion-recommendation/files/2014/wp224_en.pdf
395
+ (accessed 2026-06-11). Server-observed UA + IP (the GitLab/Mastodon/Discourse scope) stays on the
396
+ Recital 49 legitimate-interest side of the line; canvas/JS entropy harvesting would drag every host app
397
+ into consent territory and poison the "drop-in" pitch.
398
+
399
+ ---
400
+
401
+ ## Implications for the sessions gem
402
+
403
+ Hard requirements the PRD must include, each tied to evidence above:
404
+
405
+ 1. **DB-backed session records bound to the user**, with at minimum `ip_address`, `user_agent`,
406
+ `last_active_at`, `created_at` — parity with Laravel's default schema (laravel/laravel migration,
407
+ 2026-06-10) and Rails 8's generator columns (rails/rails `authentication_generator.rb`, 2026-06-11).
408
+ 2. **End-user "your devices" page**: device/browser label, approximate location, last-active, per-row
409
+ revoke, "sign out everywhere" — ASVS 4.0.3 **3.3.4** / 5.0 **7.5.2**; UX contract per
410
+ GitHub/Google/Stripe/Shopify/Discourse (sources above).
411
+ 3. **Server-side, immediate revocation**: logout/expiry/revoke must "disallow any further use of the
412
+ session" — ASVS 7.4.1; OWASP Cheat Sheet server-side invalidation rule.
413
+ 4. **Re-authentication ("sudo mode") before destructive session actions** — ASVS 3.3.4 "(having
414
+ re-entered login credentials)" / 7.5.2 "(having authenticated again with at least one factor)".
415
+ 5. **Terminate-other-sessions on password/MFA change**, default-on hook — ASVS 3.3.3 / 7.4.3; precedent:
416
+ phx.gen.auth deletes all tokens on password change; Laravel `logoutOtherDevices`.
417
+ 6. **Configurable idle + absolute timeouts with named presets** — NIST 800-63B-4 §2.2.3 (AAL2 ≤24h
418
+ overall / ≤1h idle); OWASP Cheat Sheet ranges (idle 15–30 min low-risk; absolute 4–8h).
419
+ 7. **Store only a digest of the session identifier** (salted/peppered hash) and **never write raw session
420
+ IDs to logs or audit rows** — OWASP Cheat Sheet ("log a salted-hash of the session ID"); Discourse
421
+ `hash_token` precedent.
422
+ 8. **Login-activity audit trail** (success/failure, identifier, IP, UA, timestamp, optional path),
423
+ separate from live sessions — Discourse `UserAuthTokenLog`; authtrail's 4.1M downloads prove
424
+ standalone demand; SOC 2 CC6.1/CC7.x evidence needs.
425
+ 9. **New-device/new-location "Was this you?" email**, on by default with opt-out, linking to revoke +
426
+ password change — Google alert pattern (support 2590353); Entra "unfamiliar sign-in properties" as
427
+ the detection model.
428
+ 10. **Per-user session cap with oldest-eviction + scheduled cleanup** — GitLab
429
+ `ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100`; Discourse `MAX_SESSION_COUNT = 60`; Mastodon `purge_old`.
430
+ 11. **UA parsing via an established gem** (`device_detector` per GitLab or `browser` per Mastodon), with
431
+ input hardening (GitLab truncates UA at 1024 chars) — never roll a custom parser.
432
+ 12. **Geolocation optional, coarse, labeled approximate**, degrading gracefully when absent — Google
433
+ "Your devices" location semantics (support 3067630); avoids a hard geo-IP dependency.
434
+ 13. **Treat IP/UA as personal data**: optional Active Record Encryption integration — deterministic for
435
+ IP (keeps equality queries; documented tradeoff per Rails guide) — *Breyer* C-582/14 + Recital 30.
436
+ 14. **Configurable retention + built-in purge job; default ≈12 months for audit rows** (CNIL 6–12 month
437
+ recommendation); session rows deleted on revoke/expiry; docs name Art. 6(1)(f)/Recital 49 as basis.
438
+ 15. **Optional IP anonymization before persistence** (zero last IPv4 octet / last 80 IPv6 bits — Google
439
+ Analytics precedent) and data-minimization defaults (GDPR Art. 5(1)(c)): no bodies, no attempted
440
+ credentials, nullable IP.
441
+ 16. **Scope guardrail: no client-side fingerprinting** (no canvas/JS entropy) — WP224 makes it
442
+ consent-gated under ePrivacy Art. 5(3); UA+IP server-side only. Impossible-travel heuristics stay
443
+ roadmap-stage, computed from stored geo+timestamps (Entra model), advisory-only.
444
+ 17. **Auth-framework-agnostic adapters**: Rails 8 generator's `Session` model (enrich the table it
445
+ already creates), Devise/Warden hooks, OAuth/OmniAuth callbacks — justified by Devise issues
446
+ #5262/#5027/#4607 and the workaround corpus; the ubiquitous salt-rotation hack shows hosts need the
447
+ gem to own *revocation*, not just display.
448
+ 18. **Naming/positioning**: ship as `sessions` (RubyGems 404 on 2026-06-10 — name available); position
449
+ against `authtrail` ("events only") and `devise-security` ("policy only") as **"the missing
450
+ GitHub-style devices page + login audit trail for Rails."**
@@ -0,0 +1,34 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 7.1.0"
7
+
8
+ group :development do
9
+ gem "appraisal"
10
+ gem "rubocop", "~> 1.0", require: false
11
+ gem "rubocop-minitest", "~> 0.35", require: false
12
+ gem "rubocop-performance", "~> 1.0", require: false
13
+ end
14
+
15
+ group :test do
16
+ gem "minitest", "~> 6.0"
17
+ gem "minitest-mock"
18
+ gem "mocha", "~> 2.0"
19
+ gem "simplecov", require: false
20
+ gem "actionmailer"
21
+ gem "activejob"
22
+ gem "bcrypt"
23
+ gem "warden"
24
+ gem "device_detector"
25
+ gem "mysql2"
26
+ gem "pg"
27
+ gem "sqlite3"
28
+ gem "bootsnap", require: false
29
+ gem "propshaft"
30
+ gem "puma"
31
+ gem "rdoc", ">= 7.0"
32
+ end
33
+
34
+ gemspec path: "../"
@@ -0,0 +1,34 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 7.2.0"
7
+
8
+ group :development do
9
+ gem "appraisal"
10
+ gem "rubocop", "~> 1.0", require: false
11
+ gem "rubocop-minitest", "~> 0.35", require: false
12
+ gem "rubocop-performance", "~> 1.0", require: false
13
+ end
14
+
15
+ group :test do
16
+ gem "minitest", "~> 6.0"
17
+ gem "minitest-mock"
18
+ gem "mocha", "~> 2.0"
19
+ gem "simplecov", require: false
20
+ gem "actionmailer"
21
+ gem "activejob"
22
+ gem "bcrypt"
23
+ gem "warden"
24
+ gem "device_detector"
25
+ gem "mysql2"
26
+ gem "pg"
27
+ gem "sqlite3"
28
+ gem "bootsnap", require: false
29
+ gem "propshaft"
30
+ gem "puma"
31
+ gem "rdoc", ">= 7.0"
32
+ end
33
+
34
+ gemspec path: "../"
@@ -0,0 +1,34 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 8.0.0"
7
+
8
+ group :development do
9
+ gem "appraisal"
10
+ gem "rubocop", "~> 1.0", require: false
11
+ gem "rubocop-minitest", "~> 0.35", require: false
12
+ gem "rubocop-performance", "~> 1.0", require: false
13
+ end
14
+
15
+ group :test do
16
+ gem "minitest", "~> 6.0"
17
+ gem "minitest-mock"
18
+ gem "mocha", "~> 2.0"
19
+ gem "simplecov", require: false
20
+ gem "actionmailer"
21
+ gem "activejob"
22
+ gem "bcrypt"
23
+ gem "warden"
24
+ gem "device_detector"
25
+ gem "mysql2"
26
+ gem "pg"
27
+ gem "sqlite3"
28
+ gem "bootsnap", require: false
29
+ gem "propshaft"
30
+ gem "puma"
31
+ gem "rdoc", ">= 7.0"
32
+ end
33
+
34
+ gemspec path: "../"
@@ -0,0 +1,34 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 8.1.0"
7
+
8
+ group :development do
9
+ gem "appraisal"
10
+ gem "rubocop", "~> 1.0", require: false
11
+ gem "rubocop-minitest", "~> 0.35", require: false
12
+ gem "rubocop-performance", "~> 1.0", require: false
13
+ end
14
+
15
+ group :test do
16
+ gem "minitest", "~> 6.0"
17
+ gem "minitest-mock"
18
+ gem "mocha", "~> 2.0"
19
+ gem "simplecov", require: false
20
+ gem "actionmailer"
21
+ gem "activejob"
22
+ gem "bcrypt"
23
+ gem "warden"
24
+ gem "device_detector"
25
+ gem "mysql2"
26
+ gem "pg"
27
+ gem "sqlite3"
28
+ gem "bootsnap", require: false
29
+ gem "propshaft"
30
+ gem "puma"
31
+ gem "rdoc", ">= 7.0"
32
+ end
33
+
34
+ gemspec path: "../"