sessions 0.1.3 → 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -2
- data/README.md +9 -9
- data/app/controllers/sessions/application_controller.rb +2 -2
- data/app/views/sessions/_devices.html.erb +1 -1
- data/docs/PRD.md +52 -52
- data/docs/research/{01-carhey.md → 01-host-app.md} +23 -23
- data/docs/research/02-ecosystem.md +1 -1
- data/docs/research/07-device-detection.md +13 -13
- data/lib/generators/sessions/install_generator.rb +1 -1
- data/lib/generators/sessions/madmin_generator.rb +5 -5
- data/lib/generators/sessions/templates/add_lifecycle_to_sessions.rb.erb +38 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +26 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +17 -5
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +4 -4
- data/lib/generators/sessions/templates/initializer.rb +2 -2
- data/lib/generators/sessions/templates/madmin/session_resource.rb +16 -8
- data/lib/generators/sessions/templates/madmin/sessions_controller.rb +4 -4
- data/lib/generators/sessions/upgrade_generator.rb +4 -1
- data/lib/sessions/adapters/omakase.rb +62 -18
- data/lib/sessions/adapters/warden.rb +163 -48
- data/lib/sessions/configuration.rb +4 -3
- data/lib/sessions/current.rb +4 -4
- data/lib/sessions/end_reason.rb +67 -0
- data/lib/sessions/models/concerns/has_sessions.rb +3 -3
- data/lib/sessions/models/concerns/model.rb +124 -59
- data/lib/sessions/models/event.rb +24 -17
- data/lib/sessions/version.rb +1 -1
- data/lib/sessions.rb +13 -12
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 10c90937b69181aefb74b594f97b4f0142d75148aac755cb3e180866df74e249
|
|
4
|
+
data.tar.gz: 94219c51f790720e0bff485db1242f288b48161ffb5adab0bf066be22d189a30
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 978aea94d42c6259b8add5117692fbfd28ef071b5c9a078ec45900173bc76041481cba36433d7ce6eaf2d6dafa2d7cf1365e92c2b55a6c0946f19ca212d3c8dd
|
|
7
|
+
data.tar.gz: 4d956731a73bbc7750f67161f6a69acc9e7befa96c0ad8b5e02bfb7ccedbac4002cc2fa39b43ab0643c07a062c0d3e84315757d72ccac0a291ca1a0ad3cb09a2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 (2026-06-21)
|
|
4
|
+
|
|
5
|
+
Lifecycle-row rewrite and auth-safety correction for the Hotwire Native / Devise remember-me hardening introduced in 0.1.3.
|
|
6
|
+
|
|
7
|
+
- **Session rows are now the lifecycle source of truth.** 0.1.x used "row missing + event tombstone" as the revocation signal. That made Warden infer security intent from absence, which crossed the gem's own boundary: `sessions` decorates Devise/Rails auth, it does not own auth. 0.2 adds `ended_at`, `ended_reason`, `ended_by`, and `ended_metadata`; `sessions.live` is the revocable device set, and `sessions_events` is audit history.
|
|
8
|
+
- **Tracking can no longer turn quiet housekeeping into logout.** Warden fetch now kicks only when a token-backed row exists and has an explicit kicking lifecycle reason (`logout`, `expired`, `user_revoked`, `admin_revoked`, `password_change`, `logout_everywhere`, `pruned`, `unknown`). Quiet `superseded`, token mismatch, database lookup errors, stale tracking keys, and missing rows fail open and clear only the gem's tracking key. Legacy v0.1.x destroyed rows still kick when an old `revoked`/`expired` event proves explicit action.
|
|
9
|
+
- **Remembered Warden restores stay quiet.** Same-device remember-me restores still reattach to the existing row without a duplicate login event, preserving the 0.1.3 noise reduction. The tokenless `[row_id, nil]` value is now only a tracking hint: if the signed browser-continuity cookie matches, the row may be touched; if it does not, tracking is dropped for that request and Devise/Rails continue to own authentication.
|
|
10
|
+
- **Explicit revocation is transactional.** `revoke!` now ends the row and persists the matching `revoked`/`expired` audit event in one transaction. If the event cannot be written, the row is left live and the explicit action fails loudly.
|
|
11
|
+
- **Tokenless remembered restores still log out cleanly.** The quiet known-device hint introduced in 0.1.3 now participates in the Warden logout hook when the signed browser-continuity cookie still matches the row, so a user sign-out ends the device row and writes a `logout` event without needing a raw Warden token.
|
|
12
|
+
- **Upgrade migration added.** `rails generate sessions:upgrade` now adds the 0.2 lifecycle columns to existing installs. Fresh installs include them in the base sessions table.
|
|
13
|
+
|
|
3
14
|
## 0.1.3 (2026-06-21)
|
|
4
15
|
|
|
5
16
|
Production-found hardening for Hotwire Native / Devise remember-me startup flows, plus compatibility and generated admin-schema fixes.
|
|
6
17
|
|
|
7
18
|
- **Remembered Warden logins on non-document requests are deferred until the first document navigation.** A native iOS path-configuration fetch like `/native/configurations/ios/v1.json` can carry the remember-me cookie before the WebView entry page loads; the gem now keeps that login pending instead of recording a misleading `RailsFast/... CFNetwork/...` row/event, then records the real WebView/native device on the HTML request with the original `remembered` auth detail.
|
|
8
|
-
- **Remembered restores for an already-live device now reattach to the existing row.** iOS WebView startup bursts can run several remembered document requests before the app settles; if the signed device cookie already names a live row for the same user/scope, the gem reuses it, writes no duplicate login event, and validates the tokenless Warden session against that same signed device cookie on later fetches.
|
|
19
|
+
- **Remembered restores for an already-live device now reattach to the existing row.** iOS WebView startup bursts can run several remembered document requests before the app settles; if the signed device cookie already names a live row for the same user/scope, the gem reuses it, writes no duplicate login event, and validates the tokenless Warden session against that same signed device cookie on later fetches. Explicitly ended legacy rows still kick normally.
|
|
9
20
|
- **Internal same-device superseding is quiet housekeeping again.** Replacing an abandoned same-browser row no longer writes a user-facing `revoked` / `superseded` event; remote revocations, password-change revocations, expiry and logout still write their normal trail entries.
|
|
10
21
|
- **Pre-gem adoption also waits for a document request.** Existing authenticated sessions no longer get adopted from background JSON/native HTTP requests before the actual browser/WebView request can name the device.
|
|
11
22
|
- **Trackdown integration duck-types optional fields.** `sessions` now tolerates older `trackdown` releases that do not expose `region` or coordinate methods, preserving valid country/city data instead of swallowing the whole geo result.
|
|
@@ -43,7 +54,7 @@ Plus a full-codebase audit pass:
|
|
|
43
54
|
First release — the missing session layer for Rails. 🔐
|
|
44
55
|
|
|
45
56
|
- **Live device registry** on the session table your app already has: the Rails 8 `sessions` table is adopted and enriched in place (zero app-code edits), and Devise apps get the same Rails-8-shaped table generated for them.
|
|
46
|
-
- **Per-session remote revocation** that actually works on both stacks: `session.revoke!`, `user.revoke_other_sessions!`, `user.revoke_all_sessions!` —
|
|
57
|
+
- **Per-session remote revocation** that actually works on both stacks: `session.revoke!`, `user.revoke_other_sessions!`, `user.revoke_all_sessions!` — the device is signed out on its very next request. On Devise this generalizes the proven `session_limitable` mechanism into token-per-row (N devices, each individually revocable) and rotates remember-me credentials on revoke.
|
|
47
58
|
- **Append-only login-activity trail** (`sessions_events`): every successful *and failed* login, logout, revocation and expiry — with the attempted identity (even for unknown accounts), the device, the geo, and the trail↔registry linkage (`session_id`) no prior art has.
|
|
48
59
|
- **Device dedup via browser continuity**: a signed, long-lived `sessions_device_id` cookie (minted only at login — never a pre-login tracker) identifies the browser install, so a repeat login from the same browser *supersedes* its old row instead of stacking duplicate "Firefox on macOS" entries. Robust to browser updates (identity is the cookie, not the UA); private windows and other users on the same machine stay separate; superseding is quiet housekeeping (no revocation hook, no remember-me rotation, and the surviving trail prevents false new-device alerts).
|
|
49
60
|
- **The "Last used" badge, server-side**: `Sessions.last_login(request)` returns the most recent login event from THIS browser — on the login page, signed out — because the continuity cookie survives logout and login events carry the device id. One lookup powers the "Last used" pill next to your OAuth/passkey/password buttons; no JavaScript, no localStorage.
|
data/README.md
CHANGED
|
@@ -14,7 +14,7 @@ And it's built for how people actually sign in now: password, OAuth (Google, App
|
|
|
14
14
|
## 👨💻 Example
|
|
15
15
|
|
|
16
16
|
```ruby
|
|
17
|
-
current_user.sessions.
|
|
17
|
+
current_user.sessions.live # every live device, most recent first
|
|
18
18
|
|
|
19
19
|
session = current_user.sessions.first
|
|
20
20
|
session.device_name # => "Chrome 137 on macOS"
|
|
@@ -105,14 +105,14 @@ rails generate sessions:upgrade
|
|
|
105
105
|
rails db:migrate
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
This adds `sessions.adoption_key` (for installs before 0.1.2),
|
|
108
|
+
This adds `sessions.adoption_key` (for installs before 0.1.2), `sessions_events.app_build` (for installs before 0.1.3), and the 0.2 lifecycle columns (`sessions.ended_at`, `ended_reason`, `ended_by`, `ended_metadata`). Fresh installs already get all of them from `sessions:install`.
|
|
109
109
|
|
|
110
110
|
## What `sessions` does (and doesn't) do
|
|
111
111
|
|
|
112
112
|
**Does:**
|
|
113
113
|
|
|
114
114
|
- **Live device registry** — one row per signed-in device on the (Rails-8-shaped) `sessions` table, enriched with parsed device intelligence, geolocation, auth method, and a throttled `last_seen_at`.
|
|
115
|
-
- **Remote revocation that actually works** —
|
|
115
|
+
- **Remote revocation that actually works** — mark the row ended, and that device is logged out on its very next matching request, on both auth stacks. Revoking a Devise session also rotates remember-me credentials so a stolen long-lived cookie can't quietly revive it.
|
|
116
116
|
- **Append-only login trail** — logins, *failed* logins (with the typed identity, even for accounts that don't exist), logouts, revocations, expirations. Each trail row links to the live session it created: a suspicious login is one lookup away from the kill switch.
|
|
117
117
|
- **Every 2026 login method** — password and OAuth classify automatically (OmniAuth failures get captured too, via a composed `on_failure`); One Tap / passkeys / magic links / SSO take one `Sessions.tag` line.
|
|
118
118
|
- **Hotwire Native device intelligence** — platform, OS version, and (on Android) device model work with zero setup; add the [UA prefix convention](#-hotwire-native) for app versions and iOS hardware models.
|
|
@@ -305,7 +305,7 @@ config.on_new_device = ->(user:, session:, event:) do
|
|
|
305
305
|
end
|
|
306
306
|
```
|
|
307
307
|
|
|
308
|
-
Pass the **event** to your mailer, not the session: the event is a persisted, GlobalID-able record that survives revocation
|
|
308
|
+
Pass the **event** to your mailer, not the session: the event is a persisted, GlobalID-able record that survives revocation and account-erasure cleanup, and it carries everything the email needs — `event.user`, `event.device_name`, `event.location`, `event.country_flag`, `event.source_line`, `event.occurred_at`:
|
|
309
309
|
|
|
310
310
|
```ruby
|
|
311
311
|
class SecurityMailer < ApplicationMailer
|
|
@@ -353,7 +353,7 @@ Scopes are the admin product — `Sessions::Event.failed_logins.last_24_hours.gr
|
|
|
353
353
|
rails generate sessions:madmin
|
|
354
354
|
```
|
|
355
355
|
|
|
356
|
-
…generates the two resources (the
|
|
356
|
+
…generates the two resources (the lifecycle registry with a per-row **Revoke session** action, and the login trail with its triage scopes as filters) plus their controllers, with madmin's two namespacing footguns pre-solved. The generated files use only stock madmin APIs and are yours to restyle. For a per-user security panel (devices + trail on the user's show page), load `user.sessions.live.by_recency` and `user.session_events.recent` in a member action — including the user's *failed* attempts by matching `Sessions::Event.where(identity: Sessions::Event.normalize_identity(user.email))` (failures never link to accounts; matching the signed-in user's own identity is the safe way to show them).
|
|
357
357
|
|
|
358
358
|
## 🧹 Retention & the sweep
|
|
359
359
|
|
|
@@ -372,7 +372,7 @@ It purges trail rows past `config.events_retention` (12 months by default — CN
|
|
|
372
372
|
## 🔏 Security & privacy posture
|
|
373
373
|
|
|
374
374
|
- **Tracking never breaks login.** Every adapter path, parser, geo lookup and hook is error-isolated; the test suite includes a chaos test that detonates every pipeline stage at once and asserts sign-in still works.
|
|
375
|
-
- **Tracking outages fail OPEN.** A revoked session is a row
|
|
375
|
+
- **Tracking outages fail OPEN.** A revoked session is a row with an explicit lifecycle end (`ended_reason`); an *errored* lookup (sessions table unreachable, a migration mid-deploy, a timeout) is an outage — the request proceeds untracked instead of logging anyone out. Kicks are scope-precise, too: revoking a user session never touches an admin scope riding the same rack session, or your cart/locale data.
|
|
376
376
|
- **The trail rejects rewrites.** Normal Active Record mutations on events are blocked — `update`/`destroy` raise `ActiveRecord::ReadOnlyRecord`. The callback-bypassing APIs (`update_columns`, `delete_all`) remain available, because the gem's own sanctioned paths use them: async geo backfill, `Sessions.forget`'s GDPR scrub, the retention sweep. Append-only at the model-contract level — not a database constraint, and a host determined to rewrite history still can.
|
|
377
377
|
- **No usable credential is ever persisted.** Devise-mode session tokens are random 32-byte values stored as SHA-256 digests; the raw token lives only in the user's own session. Rails-8-mode rows store nothing secret (the signed cookie is the credential). Nothing secret is ever logged.
|
|
378
378
|
- **Revocation is server-side and immediate** (checked on the very next request, both stacks) — OWASP ASVS 7.4.1; "view and terminate any or all currently active sessions" is literally ASVS 3.3.4 / 7.5.2, the requirement this gem exists to satisfy.
|
|
@@ -427,10 +427,10 @@ end
|
|
|
427
427
|
|
|
428
428
|
## 🧱 Why the models?
|
|
429
429
|
|
|
430
|
-
Two primitives, linked — **rows are
|
|
430
|
+
Two primitives, linked — **rows are lifecycle state; events are history**:
|
|
431
431
|
|
|
432
|
-
- **`sessions`** (the registry — *your* table, Rails-8-shaped on both stacks): one row = one signed-in device.
|
|
433
|
-
- **`sessions_events`** (the trail — gem-owned, append-only): what happened and from where
|
|
432
|
+
- **`sessions`** (the registry — *your* table, Rails-8-shaped on both stacks): one row = one signed-in device lifecycle. `ended_at: nil` means live; logout, revocation, expiry, pruning, and supersede mark the row ended in place with `ended_reason`. Adapters only disconnect a device after the explicit lifecycle state says they should, so a tracking failure cannot silently delete auth state.
|
|
433
|
+
- **`sessions_events`** (the trail — gem-owned, append-only): what happened and from where. Events describe the lifecycle transition; the row itself remains the liveness source of truth. Its `session_id` is a plain column with no foreign key *on purpose*: history and retention must survive row purges, account erasure, and older host tables.
|
|
434
434
|
|
|
435
435
|
On Rails 8 auth, the gem **adopts** the generated table and model: one migration adds columns (the `add_devise_to_users` precedent), and the 2-line `Session` model is decorated via a concern at boot — your generated code stays byte-identical. On Devise, the install generator creates the same Rails-8-shaped table and a 3-line shell model — so if you ever migrate Devise → Rails auth, your sessions table is already exactly where Rails expects it.
|
|
436
436
|
|
|
@@ -141,9 +141,9 @@ module Sessions
|
|
|
141
141
|
def sessions_owner_sessions
|
|
142
142
|
user = sessions_current_user
|
|
143
143
|
if user.respond_to?(:sessions)
|
|
144
|
-
user.sessions
|
|
144
|
+
user.sessions.live
|
|
145
145
|
else
|
|
146
|
-
Sessions.session_model.where(user: user)
|
|
146
|
+
Sessions.session_model.live.where(user: user)
|
|
147
147
|
end
|
|
148
148
|
end
|
|
149
149
|
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<%
|
|
8
8
|
current_session = local_assigns[:current_session] || Sessions.current(request)
|
|
9
9
|
sessions = local_assigns[:sessions] ||
|
|
10
|
-
local_assigns[:user]&.sessions&.by_recency&.to_a ||
|
|
10
|
+
local_assigns[:user]&.sessions&.live&.by_recency&.to_a ||
|
|
11
11
|
[]
|
|
12
12
|
routes = sessions_engine_routes if respond_to?(:sessions_engine_routes)
|
|
13
13
|
%>
|
data/docs/PRD.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# `sessions` — Product Requirements Document
|
|
2
2
|
|
|
3
3
|
> **Status**: Draft v1 for review (Javi). **Date**: 2026-06-11.
|
|
4
|
-
> **Research base**: every claim and shape in this document is backed by the nine research memos in [`docs/research/`](research/) — read-only audits of
|
|
4
|
+
> **Research base**: every claim and shape in this document is backed by the nine research memos in [`docs/research/`](research/) — read-only audits of HostApp, LicenseSeat, the rameerez gem ecosystem, rails/rails (v8.1.3 + main), Devise 5.0.4 + Warden + devise-security, OmniAuth + google_sign_in + webauthn-ruby, authtrail + authie + authentication-zero + Rodauth, the `browser`/`device_detector` parsers, hotwire-native-ios/android, plus live web research (all sources fetched 2026-06-10/11, exact URLs inline and in the memos). Citations below use the form `(→ research/NN §X)` plus direct URLs where load-bearing.
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
`sessions` gives every Rails 8+ app, in one `bundle add` + one generator + one `has_sessions` macro:
|
|
13
13
|
|
|
14
|
-
1. **A live device registry** — every active session, enriched with parsed device intelligence ("Chrome 137 on macOS", "
|
|
15
|
-
2. **Per-session remote revocation** — "log out of that device", "sign out everywhere else" — that actually works on Rails 8 omakase auth (
|
|
14
|
+
1. **A live device registry** — every active session, enriched with parsed device intelligence ("Chrome 137 on macOS", "HostApp 2.4.1 on iPhone 15 Pro (iOS 19.5)"), IP geolocation (via `trackdown`, soft dependency), auth method ("via Google", "via passkey"), and throttled last-seen tracking.
|
|
15
|
+
2. **Per-session remote revocation** — "log out of that device", "sign out everywhere else" — that actually works on Rails 8 omakase auth (end the lifecycle row), on Devise (token-per-row generalization of devise-security's proven `session_limitable` mechanism), and on remember-me cookies.
|
|
16
16
|
3. **An append-only login-activity trail** — every successful *and failed* login attempt, logout, and revocation, with attempted identity, device, geo, and failure reason — linked to the live session it created (the linkage no prior art has, → research/06 §Improve).
|
|
17
17
|
4. **A drop-in "Your devices" page** — engine-mounted, Tailwind-friendly, i18n'd (en + es), matching the GitHub/Google/Stripe UX contract — plus admin-grade scopes for fraud triage and a madmin recipe.
|
|
18
18
|
5. **First-class citizenship for every 2026 login method**: Rails 8 native auth, Devise (4.x & 5.x), OmniAuth/OAuth (with failed-OAuth capture), Google One Tap (FedCM-era), Sign in with Apple, passkeys, magic links — via automatic classification plus a one-line `Sessions.tag` API for the flows that can't self-identify (→ research/05).
|
|
@@ -29,7 +29,7 @@ The gem **decorates the session of record; it never becomes it** (the #1 lesson
|
|
|
29
29
|
### 1.1 Rails 8 omakase auth: a substrate, deliberately unfinished
|
|
30
30
|
|
|
31
31
|
- Rails 8.0 (2024-11-07) shipped `bin/rails generate authentication`: a `Session < ApplicationRecord` model (2 lines), a `sessions` table (`user:references ip_address:string user_agent:string`), an `Authentication` concern, and a signed **permanent** cookie holding the Session row id — `cookies.signed.permanent[:session_id]`, httponly, SameSite=Lax, 20-year expiry ([authentication.rb.tt](https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/authentication/templates/app/controllers/concerns/authentication.rb.tt), → research/03 §1).
|
|
32
|
-
- **Every request resolves the row** (`Session.find_by(id: cookies.signed[:session_id])`), so
|
|
32
|
+
- **Every request resolves the row** (`Session.find_by(id: cookies.signed[:session_id])`), so a lifecycle row can be made server-revocable without changing Rails' cookie shape. Rails built the substrate and shipped no product on top of it: the only generated route is singular `resource :session` — no index, no devices page; ip/user_agent are written once and never read again; **no code path ever UPDATEs a session row**, so `updated_at == created_at` forever (grep-verified, → research/03 §2).
|
|
33
33
|
- The punchline: Rails' own security guide recommends an `updated_at`-based `Session.sweep` for expiry ([security guide §sessions](https://guides.rubyonrails.org/security.html)) **that the generated code can never satisfy because nothing touches `updated_at`** (→ research/03 §5). The framework documents the hole this gem fills.
|
|
34
34
|
- The gaps are **policy, not backlog**. DHH on the PR: *"This is not intended to be an all-singing, all-dancing answer to every possible authentication concern… do not expect magic links or passkeys or 2FA. That's not going to happen with this generator"* ([rails/rails#52328](https://github.com/rails/rails/pull/52328)). Rails 8.1 (2025-10-22) added zero auth features (only: password reset now `sessions.destroy_all`s, and passwords#create got `rate_limit`); 8.2 edge adds only Argon2 + Sec-Fetch-Site CSRF. The `Authentication` concern is **byte-identical from 8.0.5 through 8.1.3 to main** — an exceptionally stable instrumentation target (→ research/03 §4, research/08 §Timeline).
|
|
35
35
|
|
|
@@ -50,7 +50,7 @@ The gem **decorates the session of record; it never becomes it** (the #1 lesson
|
|
|
50
50
|
### 1.4 Hotwire Native
|
|
51
51
|
|
|
52
52
|
- Hotwire Native UA construction is deterministic and documented in SDK source: iOS *appends* `"Hotwire Native iOS; Turbo Native iOS; bridge-components: […]"`; Android *prepends* its segment to the stock Chromium WebView UA. `turbo_native_app?` matches `/(Turbo|Hotwire) Native/` (→ research/07 §B).
|
|
53
|
-
- **Android WebView is exempt from Chrome's UA reduction** — native Android UAs still carry real device model + OS version for free. iOS carries real OS version but never the hardware model; app version appears nowhere by default.
|
|
53
|
+
- **Android WebView is exempt from Chrome's UA reduction** — native Android UAs still carry real device model + OS version for free. iOS carries real OS version but never the hardware model; app version appears nowhere by default. HostApp's native HTTP clients already use a richer convention (`"HostApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)"` + validated `X-Client-*` headers) that the gem should formalize (→ research/07 §B, research/01 §3).
|
|
54
54
|
|
|
55
55
|
### 1.5 The gap, in one table
|
|
56
56
|
|
|
@@ -123,7 +123,7 @@ GitHub (Settings → Sessions + security log), Google ("Your devices" + the cano
|
|
|
123
123
|
5. **DX is candy.** `has_sessions`, `session.device_name`, `session.revoke!`, `user.revoke_other_sessions!` — bang verbs, `?` predicates, kwargs, chainable scopes, reads like English (→ research/02 §4).
|
|
124
124
|
6. **Soft dependencies everywhere.** `trackdown` (geo), `device_detector` (parser upgrade), Devise/Warden, OmniAuth — all optional, detected with `defined?()`, rescued everywhere. Only hard runtime deps: Rails frameworks + `browser` (MIT, zero-dep, 15 KB) (→ research/07 §A, research/02 §2).
|
|
125
125
|
7. **Privacy as a feature.** Bounded retention + purge job, optional IP truncation, optional AR encryption, no client-side fingerprinting ever (WP224 consent trap), lat/lng precision reduction (→ research/09 §Privacy).
|
|
126
|
-
8. **Incubated in production.** Built against
|
|
126
|
+
8. **Incubated in production.** Built against HostApp (Devise 5 + Hotwire Native + Cloudflare + Spanish UI) and LicenseSeat (Devise 4.9 + MaxMind trackdown + api_keys), extracted only when the shapes survive contact with both (→ research/01).
|
|
127
127
|
|
|
128
128
|
---
|
|
129
129
|
|
|
@@ -133,9 +133,9 @@ GitHub (Settings → Sessions + security log), Google ("Your devices" + the cano
|
|
|
133
133
|
|---|---|---|
|
|
134
134
|
| **End user** | "Is my account safe? What's logged in? Kick that device." | `/settings/sessions` devices page: friendly device names, location, last seen, current-session badge, per-row Log out, "Sign out everywhere else", and a "Was this you?" email on new-device logins. |
|
|
135
135
|
| **Developer** | "Give me GitHub-style session security without building it for the third time." | `bundle add sessions` → `rails g sessions:install` → `has_sessions` → done. Works identically on Rails 8 auth and Devise; OAuth/native just work; one initializer of lambdas to integrate mailers/audit (goodmail, noticed, AuditLog…). |
|
|
136
|
-
| **Admin / T&S** | "Who tried to brute-force us last night? Is this account being taken over? Kill every session this user has." | `Sessions::Event` scopes (failed logins, by IP, by identity, velocity), per-user session admin + `revoke_all!`, new-device/new-country signals, madmin resource recipe, optional tee into the host's AuditLog (
|
|
136
|
+
| **Admin / T&S** | "Who tried to brute-force us last night? Is this account being taken over? Kill every session this user has." | `Sessions::Event` scopes (failed logins, by IP, by identity, velocity), per-user session admin + `revoke_all!`, new-device/new-country signals, madmin resource recipe, optional tee into the host's AuditLog (HostApp pattern). |
|
|
137
137
|
|
|
138
|
-
Three installed bases to serve from day one: (a) Rails 8 generator apps, (b) Devise apps (
|
|
138
|
+
Three installed bases to serve from day one: (a) Rails 8 generator apps, (b) Devise apps (HostApp, LicenseSeat, RailsFast), (c) any of the above with OAuth/One Tap/passkeys/magic links layered on (→ research/08 §Implications).
|
|
139
139
|
|
|
140
140
|
---
|
|
141
141
|
|
|
@@ -180,14 +180,14 @@ Three installed bases to serve from day one: (a) Rails 8 generator apps, (b) Dev
|
|
|
180
180
|
┌─────────────────────────────┐ ┌──────────────────────────────────┐
|
|
181
181
|
│ sessions (registry) │ │ sessions_events (trail) │
|
|
182
182
|
│ host-owned, Rails-8-shaped │◄────────│ gem-owned, append-only │
|
|
183
|
-
│
|
|
184
|
-
│ one signed-in device
|
|
185
|
-
│
|
|
183
|
+
│ LIFECYCLE state: one row = │ session │ HISTORY: logins (ok+failed), │
|
|
184
|
+
│ one signed-in device; │ _id │ logouts, revocations, expiry. │
|
|
185
|
+
│ ended_at NULL means live. │ │ Survives cleanup/erasure. │
|
|
186
186
|
└─────────────────────────────┘ └──────────────────────────────────┘
|
|
187
187
|
```
|
|
188
188
|
|
|
189
|
-
- **One mental model**: *rows =
|
|
190
|
-
- The trail's `session_id` column (plain id, deliberately **no FK constraint** —
|
|
189
|
+
- **One mental model**: *rows = session lifecycle state; events = audit history.* `revoke!` ends the registry row in place (`ended_at`/`ended_reason`) and writes a `revoked` event in the same transaction. Devise/Warden never infers security intent from a missing row; it kicks only when a token-backed row has an explicit kicking lifecycle reason.
|
|
190
|
+
- The trail's `session_id` column (plain id, deliberately **no FK constraint** — account erasure and legacy host deletes may still remove the registry row) is the linkage no prior art has: a suspicious login event is one click away from revoking the live session it created (→ research/06 §Improve).
|
|
191
191
|
|
|
192
192
|
### 6.2 Adopt-or-generate: the registry is always the Rails 8 shape
|
|
193
193
|
|
|
@@ -223,14 +223,14 @@ All gem logic lives in `Sessions::Model` (the concern) and `Sessions::Event` (ge
|
|
|
223
223
|
|
|
224
224
|
Both first-class adapters activate automatically and independently (an app can have both — e.g. Devise app that later adds omakase auth for a second scope). Activation is duck-typed and guarded:
|
|
225
225
|
|
|
226
|
-
- **Rails 8 adapter** (→ research/03 §Implications): `Rails.application.config.to_prepare` → if `defined?(::Session)` && table has `ip_address`/`user_agent` → `Session.include(Sessions::Model)`; if `ApplicationController.private_method_defined?(:start_new_session_for)` → `ApplicationController.prepend(Sessions::OmakaseControllerHooks)`. The prepend sits in front of the included `Authentication` concern in the ancestor chain, so `super`-wrapping `resume_session` (throttled touch) and `terminate_session` (logout
|
|
226
|
+
- **Rails 8 adapter** (→ research/03 §Implications): `Rails.application.config.to_prepare` → if `defined?(::Session)` && table has `ip_address`/`user_agent` → `Session.include(Sessions::Model)`; if `ApplicationController.private_method_defined?(:start_new_session_for)` → `ApplicationController.prepend(Sessions::OmakaseControllerHooks)`. The prepend sits in front of the included `Authentication` concern in the ancestor chain, so `super`-wrapping `resume_session` (throttled touch/expiry/end-state refusal) and `terminate_session` (logout lifecycle end) is clean, name-stable-since-8.0, and requires zero app edits. Login events ride **model callbacks** (`after_create_commit`: ip/UA already on the row); explicit logout/revocation uses `end!`; host-side `destroy_all` remains covered by the compatibility callback (→ research/03 §Top 6).
|
|
227
227
|
- **Devise adapter** (→ research/04 §Implications): registered from a Railtie initializer guarded by `defined?(::Warden::Manager)` (Bundler.require precedes initializers; hooks live on the Manager *class* and are read live per request — no load-order coupling, no `require "warden"`). The four hooks:
|
|
228
228
|
1. `after_set_user except: :fetch` — any fresh login (form, remember-me, OmniAuth, sign-up auto-login, post-password-reset). Guards: `warden.authenticated?(scope)` && `opts[:store] != false` (**critical**: token/HTTP-Basic auth fires this hook *every request* with `store: false` — without the guard we'd mint a session row per API call) && our skip flags. Creates the registry row, stores `[row_id, raw_token]` in `warden.session(scope)` (survives Warden's `:renew` SID rotation; auto-deleted by Warden on logout).
|
|
229
229
|
2. `after_set_user only: :fetch` — per-request resume: look up row by id, `secure_compare` token digest; missing/revoked → `warden.raw_session.clear; warden.logout(scope); throw :warden, scope:, message: :session_revoked` (the proven session_limitable sequence); else throttled touch.
|
|
230
230
|
3. `before_failure` — failed logins: persist scope/action/message/attempted_path from `env['warden.options']`; attempted identity from `request.params[scope]` **only when `request.post?` && credentials hash present** (filters out plain 401 page-hits and timeouts); never read the password key. Store Devise's failure symbol verbatim (`:invalid` under paranoid mode — don't infer account existence).
|
|
231
|
-
4. `before_logout` — mark row
|
|
231
|
+
4. `before_logout` — mark row ended + `logout` event (fires once per scope; also on forced logouts like timeout).
|
|
232
232
|
- **OmniAuth integration** (→ research/05 §1): no hook needed for successes (the callback lands in the same controller seam either adapter already covers; we classify by sniffing `env['omniauth.auth']`). Failures: compose-wrap `OmniAuth.config.on_failure` in an after-Devise initializer — record `omniauth.error.type` + provider + origin + IP/UA, then call the original endpoint.
|
|
233
|
-
- **Explicit API**: the universal seam for everything else —
|
|
233
|
+
- **Explicit API**: the universal seam for everything else — HostApp's manual native sign-in branch renders 422s without touching Warden's failure app, so it needs `Sessions.record_failed_attempt` (→ research/01 §Implications 3); One Tap/passkey/magic-link controllers call `Sessions.tag(request, method: :passkey, detail: {...})` before signing in.
|
|
234
234
|
|
|
235
235
|
### 6.4 Auth-method classification pipeline
|
|
236
236
|
|
|
@@ -250,23 +250,23 @@ Taxonomy (two indexed columns + one JSON): `auth_method` ∈ `password, oauth, g
|
|
|
250
250
|
Raw-first, three layers (→ research/07 §Implications):
|
|
251
251
|
|
|
252
252
|
1. **Persist raw**: `user_agent` as `text` (no 255 truncation — authie's footgun; Hotwire Native UAs with bridge-components exceed 255), plus interesting headers (`Sec-CH-UA*`, `X-Client-*`) in a `client_hints` JSON column. Parsing is a projection; `sessions:reparse` (v1.x) can re-run it as parsers improve.
|
|
253
|
-
2. **Native matcher first** (the moat — no third-party parser does this): `/(Turbo|Hotwire) Native (iOS|Android)/` (same contract as turbo-rails' `turbo_native_app?`) → platform; then the documented prefix convention `AppName/1.2.3 (iPhone15,2; iOS 19.5; build 241);` → app name/version/build/model/OS;
|
|
253
|
+
2. **Native matcher first** (the moat — no third-party parser does this): `/(Turbo|Hotwire) Native (iOS|Android)/` (same contract as turbo-rails' `turbo_native_app?`) → platform; then the documented prefix convention `AppName/1.2.3 (iPhone15,2; iOS 19.5; build 241);` → app name/version/build/model/OS; HostApp's legacy shapes (`"HostApp Android 1.0.5 (build 6; Android 14; sdk 34; Pixel 7)"`) and validated `X-Client-Platform/Version/Build/OS` headers accepted as input too; on Android, fall back to the embedded WebView UA for model/OS (UA-reduction-exempt).
|
|
254
254
|
3. **Web parser**: `browser` gem (hard dep: MIT, zero-dep, ~15 KB, maintained, ships `ios_app?`/`android_app?` webview heuristics — also what Mastodon uses) for browser name/version, OS family, device type, bot flag. **`device_detector` auto-upgrade adapter** when the host bundles it (better Android device names, Client-Hints-native, 108 KB bot list — but LGPL, 1.5 MB data, stale since 2024-07, which is also why it's not the default; GitLab wraps it with a 1024-char truncation we mirror). `config.ua_parser` accepts `:browser`, `:device_detector`, or a lambda.
|
|
255
255
|
|
|
256
|
-
**Honest display names** (frozen-UA reality, → research/07 §C): never render frozen tokens as facts — "Chrome 137 on macOS" (no version), "Safari on iOS 19.5 · iPhone", "
|
|
256
|
+
**Honest display names** (frozen-UA reality, → research/07 §C): never render frozen tokens as facts — "Chrome 137 on macOS" (no version), "Safari on iOS 19.5 · iPhone", "HostApp 2.4.1 on Pixel 8 (Android 16)". iPads on Safari display as "Safari on macOS" (no server-side tell; documented). Optional `config.request_client_hints = true` sets `Accept-CH` to recover real platform versions + Android models on Chromium; login POSTs are rarely first-navigations, so hints are reliably present exactly when we need them.
|
|
257
257
|
|
|
258
258
|
### 6.6 Geolocation via `trackdown` (soft dependency)
|
|
259
259
|
|
|
260
260
|
The contract, lifted verbatim from footprinted's proven integration (→ research/02 §2):
|
|
261
261
|
|
|
262
262
|
- Guard every call with `defined?(Trackdown)`; rescue **everything** and log (trackdown raises on private/loopback IPs in dev — a geo failure must never block a login write).
|
|
263
|
-
- Always `Trackdown.locate(ip.to_s, request: request)` so Cloudflare headers win when present (
|
|
263
|
+
- Always `Trackdown.locate(ip.to_s, request: request)` so Cloudflare headers win when present (HostApp mode: zero-config, free, synchronous header read).
|
|
264
264
|
- MaxMind mode (LicenseSeat shape): geolocate in the gem's enrichment job, and **pre-extract CF-header geo at enqueue time** so workers never need the MaxMind DB.
|
|
265
265
|
- Skip lookup when `country_code` already present. Store footprinted's proven column set (country_code/name, city, region; lat/lng on events only, precision-reduced). Flag emoji derives from `country_code` at render time — no column.
|
|
266
266
|
|
|
267
267
|
### 6.7 Touch, expiry & lifecycle
|
|
268
268
|
|
|
269
|
-
- **Throttled touch**: `last_seen_at` updated at most every `config.touch_every` (default 5 minutes) via one conditional `update_all` statement (hot-row-safe, callback-free —
|
|
269
|
+
- **Throttled touch**: `last_seen_at` updated at most every `config.touch_every` (default 5 minutes) via one conditional `update_all` statement (hot-row-safe, callback-free — HostApp's `IS DISTINCT FROM` pattern generalized; Rodauth's validate+touch-in-one-UPDATE proves the shape; authie's touch-every-request and devise-security's per-request `update_column` are the documented anti-patterns) (→ research/01 §Implications 6, research/06 §Steal).
|
|
270
270
|
- This finally makes the Rails security guide's own `Session.sweep` recommendation implementable (→ research/03 §5).
|
|
271
271
|
- **Expiry**: `config.idle_timeout` / `config.max_session_lifetime` (both default `nil` — a tracking gem must not silently change login lifetimes; see §12) with `config.timeout_preset = :nist_aal2` sugar (24h absolute / 1h idle). Enforced inline at resume (both adapters) and by the generated `Sessions::SweepJob` (also prunes per-user overflow beyond `config.max_sessions_per_user`, default 100 — GitLab's number — and purges trail rows past retention).
|
|
272
272
|
|
|
@@ -321,7 +321,7 @@ create_table :sessions_events do |t|
|
|
|
321
321
|
t.references :authenticatable, polymorphic: true # nullable: unknown-identity failures
|
|
322
322
|
t.string :scope
|
|
323
323
|
t.bigint :session_id # ← the linkage. Plain column, NO FK constraint:
|
|
324
|
-
#
|
|
324
|
+
# lifecycle rows can be erased; history must survive.
|
|
325
325
|
t.string :identity # email-as-typed (normalized), even for unknown accounts
|
|
326
326
|
t.string :auth_method, :auth_provider
|
|
327
327
|
t.json :auth_detail
|
|
@@ -375,12 +375,12 @@ Post-install message: ecosystem house style (emoji headline, numbered steps, yel
|
|
|
375
375
|
### 8.2 The model API
|
|
376
376
|
|
|
377
377
|
```ruby
|
|
378
|
-
current_user.sessions.
|
|
379
|
-
current_user.sessions.inactive # stale > 30 days (UI grouping, not enforcement)
|
|
378
|
+
current_user.sessions.live # live devices, most recent first
|
|
379
|
+
current_user.sessions.inactive # stale live rows > 30 days (UI grouping, not enforcement)
|
|
380
380
|
|
|
381
381
|
session = current_user.sessions.find(params[:id])
|
|
382
382
|
session.device_name # => "Chrome 137 on macOS"
|
|
383
|
-
# => "
|
|
383
|
+
# => "HostApp 2.4.1 on iPhone 15 Pro (iOS 19.5)"
|
|
384
384
|
session.location # => "Madrid, Spain" (+ session.country_flag => "🇪🇸")
|
|
385
385
|
session.last_seen_at # => 3 minutes ago
|
|
386
386
|
session.current? # => true for the request's own session
|
|
@@ -388,7 +388,7 @@ session.hotwire_native? # session.native_ios? / session.native_android? / ses
|
|
|
388
388
|
session.via_oauth? # session.auth_method / .auth_provider / "Signed in with Google"
|
|
389
389
|
session.suspicious? # v1.x: new-IP/new-country heuristics
|
|
390
390
|
|
|
391
|
-
session.revoke!(reason: :user_revoked, by: current_user) #
|
|
391
|
+
session.revoke!(reason: :user_revoked, by: current_user) # ends row + writes event
|
|
392
392
|
current_user.revoke_other_sessions! # GitHub's "sign out everywhere else"
|
|
393
393
|
current_user.revoke_all_sessions! # admin hammer (account takeover response)
|
|
394
394
|
|
|
@@ -405,7 +405,7 @@ Sessions::Event.by_country("RU").logins # admin: geo filteri
|
|
|
405
405
|
Sessions.current(request) # the registry row for this request (both adapters)
|
|
406
406
|
Sessions.tag(request, method: :passkey, detail: { user_verified: true }) # before sign-in
|
|
407
407
|
Sessions.record_failed_attempt(request, scope: :user, identity: params[:email],
|
|
408
|
-
reason: :invalid_password) # manual seams (
|
|
408
|
+
reason: :invalid_password) # manual seams (HostApp's native branch)
|
|
409
409
|
Sessions.track_login(user, request, method: :sso) # fully manual integrations
|
|
410
410
|
```
|
|
411
411
|
|
|
@@ -424,7 +424,7 @@ Sessions.configure do |config|
|
|
|
424
424
|
# — Device intelligence —
|
|
425
425
|
config.ua_parser = :browser # :device_detector | ->(ua, headers) { ... }
|
|
426
426
|
config.request_client_hints = false # set Accept-CH for real platform versions / Android models
|
|
427
|
-
config.native_app_names = ["
|
|
427
|
+
config.native_app_names = ["HostApp"] # extra UA prefixes to recognize (auto-learned from convention)
|
|
428
428
|
# — IP & geo —
|
|
429
429
|
config.ip_resolver = ->(request) { request.remote_ip } # CF-Connecting-IP setups override
|
|
430
430
|
config.ip_mode = :full # :truncated → zero last IPv4 octet / last 80 v6 bits (GA precedent)
|
|
@@ -456,14 +456,14 @@ end
|
|
|
456
456
|
```
|
|
457
457
|
|
|
458
458
|
- chats-style isolated engine: `path: ""` root resources, semantic `sessions-*` CSS classes with minimal default styles (Tailwind-friendly, themeable), no JS beyond Turbo (`button_to` + `turbo_confirm`), `rails g sessions:views` ejection for full control (→ research/02 §7).
|
|
459
|
-
- Alternatively, render the partials inside an existing settings shell (
|
|
460
|
-
- Contents per the standardized UX contract (§2.3): device icon by `device_type`, `device_name`, approximate location (labeled approximate), `last_seen_at` in words, "This device" badge, per-row **Log out** button, **Sign out of all other sessions** button, link to login history. i18n: `en` + `es` shipped (
|
|
459
|
+
- Alternatively, render the partials inside an existing settings shell (HostApp's `<section>`/`setting_row` pages; LicenseSeat's `settings` namespace): `render "sessions/devices", user: current_user`.
|
|
460
|
+
- Contents per the standardized UX contract (§2.3): device icon by `device_type`, `device_name`, approximate location (labeled approximate), `last_seen_at` in words, "This device" badge, per-row **Log out** button, **Sign out of all other sessions** button, link to login history. i18n: `en` + `es` shipped (HostApp's UI is Spanish, → research/01 §5).
|
|
461
461
|
|
|
462
462
|
### 8.6 Generators
|
|
463
463
|
|
|
464
464
|
- `sessions:install` — adaptive migration(s) + initializer + SweepJob into `app/jobs/` + `recurring.yml` snippet (trackdown/nondisposable pattern).
|
|
465
465
|
- `sessions:views` — eject engine views/partials.
|
|
466
|
-
- v1.x: `sessions:madmin` (resource files mirroring
|
|
466
|
+
- v1.x: `sessions:madmin` (resource files mirroring HostApp's `audit_log_resource.rb`), `sessions:one_tap`, `sessions:passkeys` integration injectors.
|
|
467
467
|
|
|
468
468
|
---
|
|
469
469
|
|
|
@@ -472,18 +472,18 @@ end
|
|
|
472
472
|
### 9.1 Rails 8 omakase (zero-touch)
|
|
473
473
|
|
|
474
474
|
- **Login events**: `Session.after_create_commit` — ip/UA already on the row from `start_new_session_for`; classification pipeline runs against `Sessions::Current.request` (set by a tiny gem middleware that stores the request reference per-request — needed because model callbacks lack request context; reset automatically by the executor).
|
|
475
|
-
- **Logout/revocation events**: `
|
|
475
|
+
- **Logout/revocation events**: `end!`/`revoke!` writes lifecycle state plus audit event transactionally; `after_destroy_commit` remains only as a compatibility hook for host-side raw deletes and Rails-generated `destroy_all` paths (→ research/03 §Top 6).
|
|
476
476
|
- **Touch**: prepend-wrap `resume_session` → after `super`, throttled touch of `Current.session`.
|
|
477
477
|
- **Failed logins**: two automatic layers + one manual: (a) prepend `SessionsController#create` when the generated duck-shape is detected — after `super`, if no new session was started and the request was a credentials POST, record `failed_login` with the permitted identity param; (b) subscribe to the `rate_limit.action_controller` notification (8.1+) for brute-force-threshold events — free signal, no code; (c) `Sessions.record_failed_attempt` for custom controllers. Opt-out: `config.track_failed_logins = false`.
|
|
478
478
|
- **Edge cases encoded** (→ research/03 §Implications 8): the generated `sign_in_as` test helper creates rows with nil ip/UA — all parsing is nil-tolerant; `--api` apps lack `helper_method` — engine helpers are Base/API-aware; signed-not-encrypted cookie exposes sequential row ids (informational; revocation security rests on the signature); 20-year permanent cookie means our sweep + optional idle timeout is the only real expiry; `ip_address` truthfulness depends on `trusted_proxies` (README "Behind Cloudflare" section: cloudflare-rails / replacement-semantics warning / CF-Connecting-IP only when origin-locked).
|
|
479
479
|
|
|
480
|
-
### 9.2 Devise / Warden (4.x and 5.x —
|
|
480
|
+
### 9.2 Devise / Warden (4.x and 5.x — HostApp is 5.0.4, LicenseSeat 4.9.4)
|
|
481
481
|
|
|
482
482
|
- The four hooks of §6.3, with the full guard set lifted from Devise's own hooks: skip flags (`env['sessions.skip']`, per-call `sign_in(user, sessions_skip: true)`, sticky session flag — mirroring session_limitable's three layers), `opts[:store] != false`, `warden.authenticated?(scope)`.
|
|
483
483
|
- **Fixation-safe by construction**: Warden's `set_user` renews the Rack SID but keeps session *data* — our token, stored in `warden.session(scope)`, survives login rotation and is deleted by Warden on logout. We never key on the Rack SID (→ research/04 §Top 3).
|
|
484
|
-
- **Remember-me**: a cookie re-auth is a real `:authentication` event (strategy `Rememberable`) arriving with a fresh Rack session → new registry row; stale rows get swept (v1) or re-linked via the browser-continuity cookie (v1.x). **Revocation closes the remember-me hole**: with `config.revoke_remember_me = true` (default), `revoke!` also rotates the user's remember credentials (Devise `forget_me!`/salt semantics — user-wide, documented: other devices keep their live sessions but cannot auto-revive after those end; GitLab does exactly this).
|
|
484
|
+
- **Remember-me**: a cookie re-auth is a real `:authentication` event (strategy `Rememberable`) arriving with a fresh Rack session → new registry row; stale rows get swept (v1) or re-linked via the browser-continuity cookie (v1.x). **Revocation closes the remember-me hole**: with `config.revoke_remember_me = true` (default), `revoke!` also rotates the user's remember credentials (Devise `forget_me!`/salt semantics — user-wide, documented: other devices keep their live sessions but cannot auto-revive after those end; GitLab does exactly this). HostApp's silent 1-year native remember-me makes this non-negotiable (→ research/01 §8 gap 4).
|
|
485
485
|
- **Multi-scope**: rows carry `scope`; `Devise.sign_out_all_scopes` fires `before_logout` once per scope — handled.
|
|
486
|
-
- **Coexistence**: `:trackable` keeps working (we never set `devise.skip_trackable`); `has_sessions` simply supersedes it — README suggests dropping trackable columns when ready. `bypass_sign_in` runs no callbacks → same session continues, token stays valid (documented). Timeoutable may `throw` on the same `:fetch` event — we tolerate both hook orders.
|
|
486
|
+
- **Coexistence**: `:trackable` keeps working (we never set `devise.skip_trackable`); `has_sessions` simply supersedes it — README suggests dropping trackable columns when ready. `bypass_sign_in` runs no callbacks → same session continues, token stays valid (documented). Timeoutable may `throw` on the same `:fetch` event — we tolerate both hook orders. HostApp's read-only `warden.user(run_callbacks: false)` peeks and its stale-remember-cookie native bootstrap never fire our hooks (correct — no login happens) (→ research/01 §3, research/04 §Implications).
|
|
487
487
|
|
|
488
488
|
### 9.3 OAuth / OmniAuth
|
|
489
489
|
|
|
@@ -498,13 +498,13 @@ Successes auto-classified (§6.4) on whichever adapter is active. Failures: the
|
|
|
498
498
|
|
|
499
499
|
### 9.5 Hotwire Native
|
|
500
500
|
|
|
501
|
-
- Detection/parsing per §6.5. **Device identity = the cookie jar, never the UA**: Hotwire Native shares one session cookie between WebView navigations and native HTTP calls while presenting two different UAs —
|
|
501
|
+
- Detection/parsing per §6.5. **Device identity = the cookie jar, never the UA**: Hotwire Native shares one session cookie between WebView navigations and native HTTP calls while presenting two different UAs — HostApp's NativeHttpClient explicitly syncs cookies — so one device = one registry row, with `client_hints` capturing the richer native-client headers (→ research/01 §3 "One cookie = one session across surfaces").
|
|
502
502
|
- README ships the 3-line iOS/Android `applicationUserAgentPrefix` snippets (app version everywhere, hardware model on iOS — Android model is free). Without them, the gem still yields platform + OS (+ Android model) from SDK defaults (→ research/07 §B).
|
|
503
503
|
- The signed `session_id` cookie is readable from middleware (`request.cookie_jar.signed[:session_id]` — the generated ActionCable Connection uses the same trick) for native-API contexts outside controllers (→ research/03 §Implications 5).
|
|
504
504
|
|
|
505
505
|
### 9.6 trackdown modes
|
|
506
506
|
|
|
507
|
-
- **
|
|
507
|
+
- **HostApp mode** (zero config, Cloudflare): synchronous CF-header read at request time — free.
|
|
508
508
|
- **LicenseSeat mode** (MaxMind initializer + refresh job): geo enrichment in `Sessions::GeolocateJob` with CF pre-extraction at enqueue.
|
|
509
509
|
- No trackdown → geo columns stay nil; UI omits location cleanly; README points to trackdown setup (footprinted's call-out box pattern).
|
|
510
510
|
|
|
@@ -524,7 +524,7 @@ Your devices
|
|
|
524
524
|
🖥 Chrome on macOS — This device [badge]
|
|
525
525
|
Madrid, Spain · Active now · Signed in May 2 via Google
|
|
526
526
|
|
|
527
|
-
📱
|
|
527
|
+
📱 HostApp 2.4.1 on iPhone 15 Pro (iOS 19.5) [Log out]
|
|
528
528
|
Madrid, Spain · Active 3 minutes ago · Signed in Apr 28
|
|
529
529
|
|
|
530
530
|
🖥 Firefox on Windows [Log out]
|
|
@@ -552,19 +552,19 @@ Requirements:
|
|
|
552
552
|
|
|
553
553
|
- **Scopes are the product** (BYOUI, moderate's posture): `Sessions::Event.failed_logins.last_24_hours.group(:ip_address).count`, `.for_identity`, `.for_ip`, `.by_country`, `.new_devices`, `user.sessions.active`, `Sessions::Event.velocity(identity:, within: 10.minutes)` (v1.x).
|
|
554
554
|
- **Admin verbs**: `user.revoke_all_sessions!(by: admin, reason: :admin_revoked)` — the account-takeover response; every admin action lands in the trail with `by`.
|
|
555
|
-
- **madmin recipe** (`sessions:madmin` generator, v1.x): SessionResource + EventResource mirroring
|
|
556
|
-
- **AuditLog tee**: `config.events = ->(event) { AuditLog.log(event_type: "session.#{event.name}", data: event.to_h, user: event.user, request: event.request) }` — one line wires
|
|
555
|
+
- **madmin recipe** (`sessions:madmin` generator, v1.x): SessionResource + EventResource mirroring HostApp's `audit_log_resource.rb` (menu parent "Security", recent-first, RelativeTimeField) (→ research/01 §5).
|
|
556
|
+
- **AuditLog tee**: `config.events = ->(event) { AuditLog.log(event_type: "session.#{event.name}", data: event.to_h, user: event.user, request: event.request) }` — one line wires HostApp's hash-chained ledger; same envelope works for Telegrama alerts (→ research/01 §2, research/02 §5).
|
|
557
557
|
- **New-device detection** (v1, powers `on_new_device`): a login is a *new device* when no prior session/event for that user matches on (device_type, os_name, app/browser identity) — deliberately coarse UA+IP-derived matching, never fingerprinting. New-country flag rides the same check when geo is present.
|
|
558
558
|
|
|
559
559
|
## 12. Security & privacy requirements (hard, each cited)
|
|
560
560
|
|
|
561
561
|
1. **Never persist a usable session credential.** Devise-mode tokens: random 32-byte, stored as SHA-256 digest, raw value only in the user's Rack session (OWASP Cheat Sheet "log a salted-hash"; Discourse/Rodauth precedent; high-entropy random ⇒ plain SHA-256 suffices — no pepper KDF theater). Omakase mode stores nothing secret (cookie signature is the credential). Never log raw tokens or cookie values anywhere (→ research/09 §Compliance, research/06 §Steal).
|
|
562
562
|
2. **Tracking is error-isolated** — every adapter body wrapped (authtrail `safely` pattern), failures logged at `warn`, login proceeds. A geo/parser/DB hiccup must never 500 a sign-in (→ research/02 §5).
|
|
563
|
-
3. **Revocation is server-side and immediate** (ASVS 7.4.1):
|
|
563
|
+
3. **Revocation is server-side and immediate** (ASVS 7.4.1): lifecycle end state is checked on the very next request in both adapters; Devise revoke also invalidates remember-me by default (§9.2).
|
|
564
564
|
4. **Terminate-others on password change** default-on (ASVS 3.3.3/7.4.3; Phoenix/Laravel/omakase-8.1 precedent). In Devise, the salt-embedded session value already kills cookie sessions on password change — our rows follow via the `:fetch` validation; we also emit the events.
|
|
565
565
|
5. **Failed-attempt logging is enumeration-safe**: store Devise's message symbol verbatim (paranoid mode stays `:invalid`); never echo whether the identity exists in any UI; never store the password or its length; identity normalized (`strip.downcase`) for correlation (→ research/04 §3).
|
|
566
566
|
6. **Data minimization** (GDPR Art. 5(1)(c)): no request bodies, no referrer trails (drop authtrail's `referrer` column), nullable IP, UA + IP + derived columns only. IPs and UAs are personal data (*Breyer* C-582/14, Recital 30) processed under Art. 6(1)(f)/Recital 49 (network security) — stated in README with a balancing-test note (→ research/09 §Privacy).
|
|
567
|
-
7. **Bounded retention**: trail default 12 months (CNIL 6–12), purge job generated and scheduled; registry rows
|
|
567
|
+
7. **Bounded retention**: trail default 12 months (CNIL 6–12), purge job generated and scheduled; live registry rows end with their sessions, then retention/account-erasure cleanup can remove them. Right-to-erasure: `dependent: :destroy`/`delete_all` wiring + `Sessions.forget(user)` helper that also nulls identity on retained failure rows.
|
|
568
568
|
8. **Optional hardening**: `config.ip_mode = :truncated` (GA precedent: zero last IPv4 octet / last 80 v6 bits, applied *before* persistence); AR-encryption recipe (`encrypts :ip_address, deterministic: true` — deterministic needed for equality queries, tradeoff documented per the Rails guide; non-deterministic for `user_agent`); lat/lng precision reduction default-on (→ research/09 §Privacy).
|
|
569
569
|
9. **No client-side fingerprinting, ever** — scope guardrail (WP224: consent-gated under ePrivacy 5(3)). Server-observed UA + IP only — the GitLab/Mastodon/Discourse line (→ research/09 §Fraud).
|
|
570
570
|
10. **Timeout enforcement is opt-in** — a tracking gem must not silently shorten anyone's sessions; presets (`:nist_aal2` etc.) make opting in one line. Defaults documented loudly. The 20-year omakase cookie means *our* sweep is the only expiry most apps will have — the README says so.
|
|
@@ -577,7 +577,7 @@ Requirements:
|
|
|
577
577
|
- **Structure**: `Rails::Engine` with `isolate_namespace Sessions`; spine files `require_relative`'d; models/jobs under `lib/sessions/{models,jobs}` wired into the host Zeitwerk loader (`push_dir`/`collapse`/`ignore` before `:set_autoload_paths` — moderate/chats pattern). Never defines a top-level `Session` constant in the gem itself (→ research/02 §1, research/03 §Implications 7).
|
|
578
578
|
- **Dependencies**: `activerecord`/`activesupport`/`actionpack`/`railties >= 7.1, < 9.0` (Rails-8-first, 7.1 floor is cheap per moderate/chats); `browser >= 6` (hard); everything else soft (`trackdown`, `device_detector`, Devise/Warden, OmniAuth). Ruby `>= 3.2`. MIT. `rubygems_mfa_required`. Authors/email per house gemspec (→ research/02 §1).
|
|
579
579
|
- **Errors**: `Sessions::Error < StandardError`, `Sessions::ConfigurationError`, `Sessions::UnknownAuthSystemError` (generator-time).
|
|
580
|
-
- **DB support**: PostgreSQL, MySQL, SQLite via portable column types + adaptive migration (uuid/bigint, jsonb/json, optional `:inet` upgrade on PG). Both
|
|
580
|
+
- **DB support**: PostgreSQL, MySQL, SQLite via portable column types + adaptive migration (uuid/bigint, jsonb/json, optional `:inet` upgrade on PG). Both HostApp and LicenseSeat are uuid-PK Postgres apps; dummy apps test bigint+SQLite too.
|
|
581
581
|
|
|
582
582
|
## 14. Testing & quality strategy
|
|
583
583
|
|
|
@@ -591,7 +591,7 @@ Requirements:
|
|
|
591
591
|
## 15. Rollout plan
|
|
592
592
|
|
|
593
593
|
1. **Phase 0 — Gem core** (this repo): registry + trail + both adapters + device intel + UI, built TDD against the two dummy apps. README written early in the house formula (emoji title → candy example → quickstart → deep dives → "what it doesn't do" → "why the models").
|
|
594
|
-
2. **Phase 1 — Incubate in
|
|
594
|
+
2. **Phase 1 — Incubate in HostApp** (Devise 5 + native + Cloudflare + Spanish): point Gemfile at the sibling checkout (`moderate` precedent); wire `config.events → AuditLog`, goodmail new-device recipe, madmin resources; "Sesiones y dispositivos" section in `/settings`; native UA-prefix snippets into hostapp-ios/android. Validates: Warden adapter, manual-branch failure seam, remember-me revocation, CF geo, native parsing, i18n.
|
|
595
595
|
3. **Phase 2 — Validate in LicenseSeat** (Devise 4.9, MaxMind, api_keys, English): validates devise 4.x, MaxMind async geo, token-auth exclusion, `settings` namespace UI fit.
|
|
596
596
|
4. **Phase 3 — Omakase proof**: a RailsFast-adjacent demo app on `rails g authentication` (zero-touch story, screenshots for README/launch post).
|
|
597
597
|
5. **Phase 4 — Extract & launch**: cut 0.1.0 → rubygems; launch content timed to the ecosystem calendar: Planet Argon 2026 survey results land **July 2026** (fresh auth-share data to cite) and **Rails World Austin is 2026-09-23/24** (→ research/08 §Implications 6).
|
|
@@ -619,14 +619,14 @@ Requirements:
|
|
|
619
619
|
6. **`Sessions::Event` vs `Sessions::Login` naming** for the trail model (events include logouts/revocations — `Event` recommended). Confirm.
|
|
620
620
|
7. **Reserve the gem name now?** Push a 0.0.1 stub to RubyGems this week to lock `sessions` (and optionally `sessionable`/`login_activity` as redirect-stubs? — probably unnecessary). Recommended: yes, immediately.
|
|
621
621
|
8. **Sudo mode**: ship only the `require_reauthentication` hook (recommended for v1) or build a first-party password-confirm flow (auth-zero-style `sudo_at` on the session row is cheap and we have the column budget — could be the v1.x headline)?
|
|
622
|
-
9. **Scope of `signup` enrichment**:
|
|
622
|
+
9. **Scope of `signup` enrichment**: HostApp's 22 `signup_*` columns overlap heavily with what every session row now carries. Should the gem also expose a `Sessions.attribution_for(user)` (first-session) helper so HostApp can eventually drop SignupAttribution, or is that HostApp's own cleanup later?
|
|
623
623
|
10. **License/positioning detail**: any appetite for a paid `sessions_pro` tier later (impossible travel, org dashboards)? Affects how much fraud tooling lands in the open core roadmap.
|
|
624
624
|
|
|
625
625
|
## 18. Research appendix
|
|
626
626
|
|
|
627
627
|
| Memo | Covers |
|
|
628
628
|
|---|---|
|
|
629
|
-
| [research/01-
|
|
629
|
+
| [research/01-host-app.md](research/01-host-app.md) | HostApp + LicenseSeat audit: Devise setup, native UA/header contracts, AuditLog pattern, trackdown modes, UI conventions, gaps |
|
|
630
630
|
| [research/02-ecosystem.md](research/02-ecosystem.md) | rameerez house style: macros, config, generators, hooks, UI shipping, trackdown/footprinted deep dives |
|
|
631
631
|
| [research/03-rails-core.md](research/03-rails-core.md) | Rails 8.1.3 + main auth generator internals, verbatim templates, supporting APIs, integration points |
|
|
632
632
|
| [research/04-devise-warden.md](research/04-devise-warden.md) | Warden hook ABI, Devise sign-in flow, session_limitable revocation template, edge cases, Devise 2026 state |
|
|
@@ -661,12 +661,12 @@ So "existing projects already solve all the needs" is false — but "authtrail a
|
|
|
661
661
|
|
|
662
662
|
**The bear case, stated plainly:**
|
|
663
663
|
|
|
664
|
-
1. **This is a vitamin, not a painkiller, for most apps.** Sessions pages are security hygiene — rarely visited, never revenue. Unlike `usage_credits` or `pricing_plans` (which touch money), nobody's launch is blocked on this. Demand concentrates in serious production apps: B2B SaaS facing SOC2/ASVS checklists, consumer apps fighting fraud/ATO (
|
|
664
|
+
1. **This is a vitamin, not a painkiller, for most apps.** Sessions pages are security hygiene — rarely visited, never revenue. Unlike `usage_credits` or `pricing_plans` (which touch money), nobody's launch is blocked on this. Demand concentrates in serious production apps: B2B SaaS facing SOC2/ASVS checklists, consumer apps fighting fraud/ATO (HostApp's exact case), and anyone selling upmarket. Hobby apps will skip it.
|
|
665
665
|
2. **The ceiling is authtrail-magnitude, not Devise-magnitude.** Millions of downloads over years, not hundreds of millions. That's a successful rameerez gem, not a breakout — *unless* distribution changes the math (shipping it default-on in RailsFast, where a devices page becomes a template selling point, is genuinely your unfair advantage).
|
|
666
|
-
3. **Part of the value is
|
|
666
|
+
3. **Part of the value is thinner on Rails 8 than Devise.** Rails has a first-party session row and signed cookie; on omakase our value is breadth and polish (failure capture has no hook, touch has write-amplification traps, lifecycle state, retention/GDPR, native parsing), not inventing the primitive. On Devise the value is capability. The technical moat is Devise; the marketing wedge is Rails 8.
|
|
667
667
|
4. **Teams who want zero effort already have an answer: hosted auth** (Clerk/WorkOS sell session management as a feature). Our market is specifically the own-your-auth majority that the Rails 8 wave is actively growing — which is the right side of the trend, but it's a real boundary.
|
|
668
668
|
|
|
669
|
-
**What makes me land on "build it" despite all that:** the validation isn't vibes — Laravel and Phoenix ship this *by default*, OWASP ASVS makes it a literal L2 requirement, GitLab/Mastodon/Discourse each pay ongoing maintenance on hand-rolled versions, and authtrail proves people install adjacent tooling at scale while leaving the harder 70% of the feature unserved. And your economics are unusually favorable: **
|
|
669
|
+
**What makes me land on "build it" despite all that:** the validation isn't vibes — Laravel and Phoenix ship this *by default*, OWASP ASVS makes it a literal L2 requirement, GitLab/Mastodon/Discourse each pay ongoing maintenance on hand-rolled versions, and authtrail proves people install adjacent tooling at scale while leaving the harder 70% of the feature unserved. And your economics are unusually favorable: **HostApp needs this regardless** (it currently has unrevocable 1-year native remember-me cookies and zero login history — a genuine security gap), so the incubation cost is sunk; gem-ifying is marginal cost on top.
|
|
670
670
|
|
|
671
671
|
If I had to bet: solid, durable, "obvious in retrospect" ecosystem gem with authtrail-or-better adoption — not a rocket. The two things that would most change the odds upward: RailsFast default inclusion, and nailing the 60-second zero-config demo on a fresh Rails 8 app. The one thing that would kill it: scope creep into auth itself.
|
|
672
672
|
|
|
@@ -700,19 +700,19 @@ If I compress it to one sentence: **be the obvious answer in every fresh Rails 8
|
|
|
700
700
|
|
|
701
701
|
## Do we provide views? Do we provide an engine? How does this all work, exactly?
|
|
702
702
|
|
|
703
|
-
Both — it's a four-layer stack where each layer is optional and built on the one below. This is the synthesis of how your own gems already do it (chats = engine + ejection, moderate = primitives + BYOUI, api_keys = mounted dashboard, profitable = authenticated mount), picked per layer for this gem's reality:
|
|
703
|
+
Both — it's a four-layer stack where each layer is optional and built on the one below. This is the synthesis of how your own gems already do it (chats = engine + ejection, moderate = primitives + BYOUI, api_keys = mounted dashboard, profitable = authenticated mount), picked per layer for this gem's reality: HostApp wants Spanish `setting_row` sections inside its existing settings page, LicenseSeat wants a page in its `settings` namespace — so one fixed page can't be the only offering (→ research/01 §5).
|
|
704
704
|
|
|
705
705
|
**Layer 0 — Model API, no UI at all.** Everything works headless: `user.sessions.active`, `session.device_name`, `session.revoke!`, `Sessions::Event` scopes. A host can build 100% custom UI in ~20 lines of their own controller. This layer is the contract; everything above is convenience.
|
|
706
706
|
|
|
707
707
|
**Layer 1 — Partials, renderable inside the host's own pages.** Rails automatically appends every engine's `app/views` to the host's view lookup path, so the gem ships partials that any host view can render directly with locals:
|
|
708
708
|
|
|
709
709
|
```erb
|
|
710
|
-
<%# inside
|
|
710
|
+
<%# inside HostApp's app/views/settings/show.html.erb, in its own <section> %>
|
|
711
711
|
<%= render "sessions/devices", user: current_user %>
|
|
712
712
|
<%= render "sessions/history", user: current_user, limit: 10 %>
|
|
713
713
|
```
|
|
714
714
|
|
|
715
|
-
No mount, no routes from us — the revoke buttons are `button_to`s pointing at engine routes *or* host-provided routes (configurable URL helper, so
|
|
715
|
+
No mount, no routes from us — the revoke buttons are `button_to`s pointing at engine routes *or* host-provided routes (configurable URL helper, so HostApp can route revocation through its own controller if it wants). This is the layer HostApp actually uses.
|
|
716
716
|
|
|
717
717
|
**Layer 2 — The mounted engine (the README headline).** A complete drop-in page for apps that just want it done:
|
|
718
718
|
|
|
@@ -736,7 +736,7 @@ Mechanics, exactly:
|
|
|
736
736
|
|
|
737
737
|
**Layer 3 — Ejection, exactly Devise/chats-style.** `rails g sessions:views` copies the engine's views into `app/views/sessions/` — and because application view paths take precedence over engine view paths, the copies shadow the gem's automatically. Edit freely; gem updates never touch them (the documented tradeoff, same as `devise:views`). Controllers stay ours and deliberately trivial — if you need custom controller behavior, you've graduated to Layer 0/1 rather than us supporting a controller-override matrix (Devise's `scoped_views`/custom-controllers surface is a maintenance tarpit we're explicitly not replicating).
|
|
738
738
|
|
|
739
|
-
**Admin is *not* a layer** — that stays primitives + recipes (moderate's posture): `Sessions::Event` scopes plus the `sessions:madmin` generator producing resource files modeled on
|
|
739
|
+
**Admin is *not* a layer** — that stays primitives + recipes (moderate's posture): `Sessions::Event` scopes plus the `sessions:madmin` generator producing resource files modeled on HostApp's `audit_log_resource.rb`. No shipped admin UI; admin frameworks vary too much and madmin/Avo/Administrate all want to own that surface.
|
|
740
740
|
|
|
741
741
|
So the gem tree looks like: `app/views/sessions/` (partials + engine pages), `app/controllers/sessions/` (two small controllers), `config/routes.rb` (engine routes), `lib/sessions/models/` (Zeitwerk-wired models), `lib/generators/sessions/{install,views,madmin}`. The README leads with Layer 2 (the 60-second demo needs the mount), then immediately shows Layer 1 ("or render it inside your own settings page") — because that's the path both of your production apps will actually take.
|
|
742
742
|
|