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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 342cda70d0a4f3ed6b760d8dee9ff8dea68c97151d932e77c478474e216fe9ef
4
+ data.tar.gz: 6885d70e1cc6cbb5ebd9850c0b3f5d015ce22d1aeae5ba2968b0fd5bb33412e0
5
+ SHA512:
6
+ metadata.gz: a15d061c3b9397265aa2e7581ed5e06ce64ae50943a54aa71b45539dfe528153004422b9c782d1dcd5cd2b62a55edef8612ed26c4f8a55ee8d91c0379f3d9133
7
+ data.tar.gz: 4ecef8ccd7f56454a31feb6623245c38585dbae7601fdb681daa6192e764964a4c47300dc3b8aaaa6dbe2811246a66c304210d202e5a6f9e46c40085d34a246f
data/.rubocop.yml ADDED
@@ -0,0 +1,61 @@
1
+ # MERGE our excludes with rubocop's defaults (vendor/, node_modules/, …) —
2
+ # a bare Exclude key would REPLACE them.
3
+ inherit_mode:
4
+ merge:
5
+ - Exclude
6
+
7
+ AllCops:
8
+ TargetRubyVersion: 3.2
9
+ Exclude:
10
+ # The dummy host app mirrors GENERATED code: its auth files are vendored
11
+ # verbatim from `rails generate authentication` (Rails-omakase style) and
12
+ # its sessions migrations are copies of the install templates — don't
13
+ # lint generated-code mirrors against the gem's own style.
14
+ - test/dummy/**/*
15
+ # Generator templates are emitted into HOST apps (host style, and some
16
+ # carry ERB tags rubocop can't parse as Ruby).
17
+ - lib/generators/sessions/templates/**/*
18
+ # Appraisal-generated Gemfiles.
19
+ - gemfiles/*
20
+
21
+ Style/StringLiterals:
22
+ EnforcedStyle: double_quotes
23
+
24
+ Style/StringLiteralsInInterpolation:
25
+ EnforcedStyle: double_quotes
26
+
27
+ # Hard size metrics fight readable, well-commented domain code and thorough
28
+ # test classes; we optimize for the latter.
29
+ Metrics:
30
+ Enabled: false
31
+
32
+ # The warden hook ABI fixes block parameter names (|user, warden, opts| and
33
+ # |env, opts|) — renaming them to appease a cop would only obscure the
34
+ # upstream contract the code mirrors.
35
+ Naming/MethodParameterName:
36
+ AllowedNames: [at, by, id, ip, to, ua]
37
+
38
+ # `has_sessions` is the macro and the product — it matches the ecosystem
39
+ # grammar (has_credits, has_api_keys, has_wallets), not a predicate.
40
+ Naming/PredicatePrefix:
41
+ AllowedMethods: [has_sessions]
42
+
43
+ # %{client} / %{platform} are I18n interpolation tokens — annotated tokens
44
+ # aren't a thing in I18n templates.
45
+ Style/FormatStringToken:
46
+ EnforcedStyle: template
47
+
48
+ # Duck-typed contracts (the ua_parser lambda, hook procs) fix keyword names
49
+ # even where an implementation doesn't use them.
50
+ Lint/UnusedMethodArgument:
51
+ AllowUnusedKeywordArguments: true
52
+
53
+ # Hook lambdas spell out their kwargs (->(user:, session:, event:) { … })
54
+ # even when a body ignores some — the names ARE the documented contract.
55
+ Lint/UnusedBlockArgument:
56
+ AllowUnusedKeywordArguments: true
57
+
58
+ Layout/LineLength:
59
+ Max: 120
60
+ Exclude:
61
+ - sessions.gemspec # the long-form rubygems description is one line by design
data/.simplecov ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SimpleCov configuration file (auto-loaded before test suite)
4
+ # This keeps test_helper.rb clean and follows best practices.
5
+ # Coherent with the rest of the gem ecosystem (chats, moderate, usage_credits, pricing_plans, …).
6
+
7
+ SimpleCov.start do
8
+ # Use SimpleFormatter for terminal-only output (no HTML generation)
9
+ formatter SimpleCov::Formatter::SimpleFormatter
10
+
11
+ # Don't count the test suite itself toward coverage
12
+ add_filter "/test/"
13
+
14
+ # Don't count code that ISN'T unit-testable by this suite and would only
15
+ # distort the numbers:
16
+ # - Generators + their templates: these run via `rails generate
17
+ # sessions:install` / `sessions:views` in a real host. The generator
18
+ # classes ARE exercised by Rails::Generators::TestCase, but the
19
+ # migration .erb itself is never loaded as Ruby here (the dummy
20
+ # migrates a copy of it instead).
21
+ # - version.rb: a single constant; nothing to cover.
22
+ add_filter "/lib/generators/"
23
+ add_filter "/lib/sessions/version.rb"
24
+
25
+ # Track Ruby files in the lib directory (gem source code)
26
+ track_files "lib/**/*.rb"
27
+
28
+ # Enable branch coverage for more detailed metrics
29
+ enable_coverage :branch
30
+
31
+ # Minimum coverage thresholds to prevent coverage REGRESSION. The
32
+ # primitives (models, parsing, classification, configuration, the facade)
33
+ # are thoroughly covered by the unit suite; the adapters and engine
34
+ # controllers are driven by the integration tests against the dummy app.
35
+ # The thresholds sit just under the current floor so the gate catches a
36
+ # real regression without failing on the existing baseline; raise them as
37
+ # coverage grows.
38
+ minimum_coverage line: 80, branch: 60
39
+
40
+ # Disambiguate parallel test runs
41
+ command_name "Job #{ENV["TEST_ENV_NUMBER"]}" if ENV["TEST_ENV_NUMBER"]
42
+ end
43
+
44
+ # Print coverage summary to terminal after tests complete
45
+ SimpleCov.at_exit do
46
+ SimpleCov.result.format!
47
+ puts "\n#{"=" * 60}"
48
+ puts "COVERAGE SUMMARY"
49
+ puts "=" * 60
50
+ puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
51
+ branch_coverage = SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || "N/A"
52
+ puts "Branch Coverage: #{branch_coverage}%"
53
+ puts "=" * 60
54
+ end
data/AGENTS.md ADDED
@@ -0,0 +1,5 @@
1
+ # AGENTS.md
2
+
3
+ This file provides guidance to AI Agents (like OpenAI's Codex, Cursor Agent, Claude Code, etc) when working with code in this repository.
4
+
5
+ Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read `docs/PRD.md` for the full product requirements — it's the source of truth for what we're building and why, and every technical decision in it is backed by the research memos in `docs/research/` (read the relevant memo before touching the corresponding subsystem). Once the gem has code, the README and the code itself become the source of truth for the public API.
data/Appraisals ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Test the minimum supported Rails version (matches the gemspec floor).
4
+ # There's no omakase auth generator on 7.1 — this lane proves the Devise/
5
+ # Warden side and the engine still work (the omakase adapter simply stays
6
+ # duck-detected off… except the dummy vendors the generated code, which runs
7
+ # fine on 7.1 because the concern only uses 7.1-era APIs).
8
+ appraise "rails-7.1" do
9
+ gem "rails", "~> 7.1.0"
10
+ end
11
+
12
+ appraise "rails-7.2" do
13
+ gem "rails", "~> 7.2.0"
14
+ end
15
+
16
+ # The version that shipped `rails generate authentication` — the substrate
17
+ # this gem decorates.
18
+ appraise "rails-8.0" do
19
+ gem "rails", "~> 8.0.0"
20
+ end
21
+
22
+ # The latest Rails — also adds the rate_limit.action_controller notification
23
+ # the failed-login pipeline subscribes to.
24
+ appraise "rails-8.1" do
25
+ gem "rails", "~> 8.1.0"
26
+ end
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-06-12)
4
+
5
+ First release — the missing session layer for Rails. 🔐
6
+
7
+ - **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.
8
+ - **Per-session remote revocation** that actually works on both stacks: `session.revoke!`, `user.revoke_other_sessions!`, `user.revoke_all_sessions!` — row destroyed, device 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.
9
+ - **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.
10
+ - **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).
11
+ - **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.
12
+ - **Repeated-failed-logins detection**: `config.repeated_failed_logins = { threshold:, within: }` + `on_repeated_failed_logins` hook — fires exactly once at the threshold crossing (never per attempt: that's notification fatigue and an inbox-flooding vector), with the tripping event's IP/location/device. Complements lockable/rate-limiting: they stop the attacker, this tells the user.
13
+ - **Two-factor awareness, verified from source against every mainstream setup**: devise-two-factor classifies automatically (single-phase password+TOTP → `password` + `auth_detail.second_factor: "totp"`/`"backup_code"`); passkey-first logins (devise-passkeys, warden-webauthn) classify as `passkey` by strategy name, and passkey *re*authentication (sudo confirms) is recognized — never a duplicate device row; `session.second_factor?`/`#second_factor` on rows and events plus `Session#second_factor!` for post-login step-up gates; `Sessions.skip!(request)` so a two-phase challenge's password phase (right password, no session yet) is never miscounted as a failed login; one-line recipes for devise-otp, authentication-zero `--two-factor` (whose Rails-8-shaped table the installer adopts), webauthn-rails second-factor mode, and DIY rotp/active_model_otp flows — every shape exercised end-to-end in the test suite (single-phase, two-phase challenge, passkey-first, step-up, failed factors).
14
+ - `Event#source_line` — the location-first one-liner ("🇪🇸 Madrid, Spain · IP 83.45.112.7 · Firefox 139 on Windows") shared by rows and events, ready for security emails and notification bodies (`ip: false` for compact feed rows).
15
+ - **Device intelligence**: Hotwire Native detection out of the box (platform, OS version, app name/version/build, device model — including the documented UA prefix convention and validated `X-Client-*` headers), web parsing via the `browser` gem with an automatic `device_detector` upgrade, client-hints consumption, and *honest* display names (frozen UA tokens are never rendered as facts).
16
+ - **Auth-method classification**: password, OAuth (any OmniAuth provider, with failure capture via an `on_failure` composer), Google One Tap, passkeys, magic links (devise-passwordless auto-detected), OTP/SSO — automatic where possible, one-line `Sessions.tag` where flows can't self-identify.
17
+ - **A mountable "Your devices" page** (en + es): device names, approximate location, last active, "This device" badge, per-row Log out, "Sign out of all other sessions", login history — view-by-view ejectable with `rails g sessions:views`, or rendered as partials inside your own settings page.
18
+ - **Lifecycle**: throttled `last_seen_at` touch (one conditional UPDATE per window — finally making the Rails security guide's own `Session.sweep` recommendation implementable), per-user session cap with oldest-eviction, opt-in idle/absolute timeouts with NIST presets, revoke-on-password-change (ASVS 3.3.3) on both stacks.
19
+ - **Privacy first**: bounded trail retention (12 months, CNIL) with a generated sweep job, optional IP truncation before persistence (the Google Analytics precedent), lat/lng precision reduction, no client-side fingerprinting ever, GDPR `Sessions.forget(user)` helper.
20
+ - **Never breaks login**: every tracking path is error-isolated — a parsing/geo/hook/database failure may lose a log row; it can never 500 a sign-in (proven by a chaos test that detonates every pipeline stage at once).
21
+ - **Fails open, never closed** (pre-release security audit hardening): an *errored* registry lookup is an outage, not a revocation — the request proceeds untracked instead of kicking every active session; kicks are scope-precise (an admin scope and host session data survive a user-scope kick); the sudo gate fails closed (a falsy gate 403s — it can never fall through to the destructive action); the trail rejects rewrites (normal AR `update`/`destroy` raise `ReadOnlyRecord`; geo backfill, GDPR scrubs and retention sweeps use the callback-bypassing APIs, which remain available — a model-contract guardrail, not a database constraint); polymorphic installs sweep per (owner type, id); `--model` on an omakase app takes the create-table path; `user.session_history` is the honest user-facing trail read (identity-matched failures included — the raw association can't see them by design); login-burst rate limits are the only throttles recorded as failed logins; `Sessions.current(request, scope:)` disambiguates multi-scope sessions.
22
+ - Geolocation via the [`trackdown`](https://github.com/rameerez/trackdown) gem (soft dependency): Cloudflare headers synchronously for free, MaxMind asynchronously.
23
+ - Adaptive install generator: detects Rails 8 auth vs Devise (capability-based: table shape / controller duck-test — never guessed from file names), honors uuid/bigint primary keys and jsonb/json column types **on both tables** (the trail's own primary key follows the host too — uuid apps embed events in uuid polymorphic associations like Noticed records, where a bigint id silently casts to NULL), ships an annotated initializer and the `SessionsSweepJob`.
24
+ - `config.layout` for hosts whose signed-in surfaces use a non-default layout; the engine's route proxy is discovered from the host's routes, so `mount … as: :anything` keeps every partial working.
25
+ - Incubated against TWO production apps before release — a Devise 5 + Hotwire Native + Cloudflare + PostGIS app (Spanish UI) and a Devise 4.9 + MaxMind + api_keys app (English UI): the Warden logout hook reads the raw session (a Devise-activatable-style logout-and-throw can't recurse through it), the trail's device columns power `Sessions::Event#device_name` for admin lists, PostGIS counts as PostgreSQL everywhere adapters are sniffed, the madmin generator's controllers are self-contained on stock Madmin (a `resource_name` override, never a host-patch class attribute), and token-authenticated API traffic never mints rows.
26
+ - PostgreSQL (and PostGIS), MySQL and SQLite supported; Rails 7.1 → 8.1; Ruby 3.2+.
data/CLAUDE.md ADDED
@@ -0,0 +1,5 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to AI coding agents when working with code in this repository.
4
+
5
+ Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read `docs/PRD.md` for the full product requirements — it's the source of truth for what we're building and why, and every technical decision in it is backed by the research memos in `docs/research/` (read the relevant memo before touching the corresponding subsystem). Once the gem has code, the README and the code itself become the source of truth for the public API.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 rameerez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.