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.
- checksums.yaml +7 -0
- data/.rubocop.yml +61 -0
- data/.simplecov +54 -0
- data/AGENTS.md +5 -0
- data/Appraisals +26 -0
- data/CHANGELOG.md +26 -0
- data/CLAUDE.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +454 -0
- data/Rakefile +32 -0
- data/app/assets/stylesheets/sessions.css +50 -0
- data/app/controllers/sessions/application_controller.rb +159 -0
- data/app/controllers/sessions/devices_controller.rb +48 -0
- data/app/helpers/sessions/engine_helper.rb +126 -0
- data/app/views/sessions/_device.html.erb +40 -0
- data/app/views/sessions/_devices.html.erb +34 -0
- data/app/views/sessions/_event.html.erb +13 -0
- data/app/views/sessions/_history.html.erb +20 -0
- data/app/views/sessions/devices/history.html.erb +5 -0
- data/app/views/sessions/devices/index.html.erb +15 -0
- data/config/locales/en.yml +59 -0
- data/config/locales/es.yml +59 -0
- data/config/routes.rb +17 -0
- data/docs/PRD.md +743 -0
- data/docs/research/01-carhey.md +250 -0
- data/docs/research/02-ecosystem.md +261 -0
- data/docs/research/03-rails-core.md +220 -0
- data/docs/research/04-devise-warden.md +249 -0
- data/docs/research/05-oauth.md +193 -0
- data/docs/research/06-prior-art.md +312 -0
- data/docs/research/07-device-detection.md +250 -0
- data/docs/research/08-rails8-landscape.md +216 -0
- data/docs/research/09-market-security.md +450 -0
- data/gemfiles/rails_7.1.gemfile +34 -0
- data/gemfiles/rails_7.2.gemfile +34 -0
- data/gemfiles/rails_8.0.gemfile +34 -0
- data/gemfiles/rails_8.1.gemfile +34 -0
- data/lib/generators/sessions/install_generator.rb +230 -0
- data/lib/generators/sessions/madmin_generator.rb +95 -0
- data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +81 -0
- data/lib/generators/sessions/templates/create_sessions.rb.erb +101 -0
- data/lib/generators/sessions/templates/create_sessions_events.rb.erb +108 -0
- data/lib/generators/sessions/templates/initializer.rb +201 -0
- data/lib/generators/sessions/templates/madmin/event_resource.rb +77 -0
- data/lib/generators/sessions/templates/madmin/session_events_controller.rb +21 -0
- data/lib/generators/sessions/templates/madmin/session_resource.rb +65 -0
- data/lib/generators/sessions/templates/madmin/sessions_controller.rb +30 -0
- data/lib/generators/sessions/templates/session.rb.erb +14 -0
- data/lib/generators/sessions/templates/sessions_sweep_job.rb +20 -0
- data/lib/generators/sessions/views_generator.rb +33 -0
- data/lib/sessions/adapters/omakase.rb +195 -0
- data/lib/sessions/adapters/omniauth.rb +64 -0
- data/lib/sessions/adapters/warden.rb +293 -0
- data/lib/sessions/classifier.rb +208 -0
- data/lib/sessions/configuration.rb +441 -0
- data/lib/sessions/current.rb +20 -0
- data/lib/sessions/device.rb +411 -0
- data/lib/sessions/engine.rb +120 -0
- data/lib/sessions/errors.rb +24 -0
- data/lib/sessions/geolocation.rb +111 -0
- data/lib/sessions/ip_address.rb +56 -0
- data/lib/sessions/jobs/geolocate_job.rb +58 -0
- data/lib/sessions/macros.rb +26 -0
- data/lib/sessions/middleware.rb +41 -0
- data/lib/sessions/models/concerns/device_display.rb +134 -0
- data/lib/sessions/models/concerns/has_sessions.rb +116 -0
- data/lib/sessions/models/concerns/model.rb +513 -0
- data/lib/sessions/models/event.rb +293 -0
- data/lib/sessions/version.rb +5 -0
- data/lib/sessions.rb +423 -0
- metadata +225 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# OAuth & modern login methods: tracking integration points
|
|
2
|
+
|
|
3
|
+
Research memo for the `sessions` gem — where a session/login-activity tracker can intercept each modern auth flow (OAuth via OmniAuth, Google One Tap, Sign in with Apple, passkeys, magic links/OTP) in both Rails 8 omakase and Devise apps, and what metadata exists at that moment.
|
|
4
|
+
|
|
5
|
+
- Date: 2026-06-11. All web sources accessed 2026-06-10/11 (dates inline). Code citations are `repo/path:line` against read-only clones in `/tmp/sessions-research/`:
|
|
6
|
+
- `omniauth` @ `2ad2d0d` (2026-02-27, post-v2.1.4 master), `omniauth-rails_csrf_protection` @ `c4f53d7` (v2.0.1, 2025-12-11), `google_sign_in` @ `e7f2a9a` (v1.3.1, 2025-08-29), `omniauth-google-oauth2` @ `5559071` (v1.2.2, 2026-02-23), `webauthn-ruby` @ `ff4be43` (v3.4.3, 2026-01-15); plus pre-existing clones `devise` @ `372b295` (2026-06-10), `warden`, `rails` (main).
|
|
7
|
+
|
|
8
|
+
## Top findings
|
|
9
|
+
|
|
10
|
+
1. **OmniAuth is pure Rack and deliberately stops at the callback**: it only sets `env['omniauth.auth']` (the AuthHash) and lets the app create the session (`omniauth/lib/omniauth/strategy.rb:424-427`, README:100-103). So OAuth session creation **always happens in an app controller** — the same place our gem already hooks session creation. We just need to sniff `request.env['omniauth.auth']` at that moment to get `method: :oauth, provider: auth.provider`.
|
|
11
|
+
2. **Failed OAuth is a first-class, interceptable event**: every strategy failure funnels through `fail!`, which sets `env['omniauth.error']`, `env['omniauth.error.type']`, `env['omniauth.error.strategy']` and then calls the swappable `OmniAuth.config.on_failure` rack endpoint (`strategy.rb:542-554`). Wrapping `on_failure` (composing, not replacing) gives us provider + error type + origin + IP/UA for every failed OAuth attempt — including CSRF-blocked initiations (`strategy.rb:263-264`).
|
|
12
|
+
3. **Warden gives a clean discriminator in Devise apps**: password logins run `authenticate!` → `set_user(..., event: :authentication)` with `winning_strategy` set (`warden/lib/warden/proxy.rb:339`); OmniAuth logins go through Devise `sign_in` → `set_user` with default `event: :set_user` and **no** winning strategy (`devise/lib/devise/controllers/sign_in_out.rb:44`, `warden/lib/warden/proxy.rb:175`). One `after_set_user` hook + env sniffing classifies both.
|
|
13
|
+
4. **FedCM became mandatory for Google One Tap in August 2025** ("August 2025 Mandatory adoption of FedCM APIs by the Google Sign-in platform library… any `use_fedcm` settings are ignored" — https://developers.google.com/identity/sign-in/web/gsi-with-fedcm, accessed 2026-06-11) — even though Chrome **kept third-party cookies** (April 22, 2025 reversal; no choice prompt either: https://www.didomi.io/blog/google-chrome-third-party-cookies-april-2025, https://www.onetrust.com/blog/google-drops-plans-for-third-party-cookie-choice-prompt-in-chrome/, accessed 2026-06-10).
|
|
14
|
+
5. **One Tap hands the server a `select_by` field that says exactly HOW the user signed in** (`fedcm`, `fedcm_auto`, `btn`, `btn_confirm`, `user`, `auto`, `itp`…) alongside the `credential` JWT (https://developers.google.com/identity/gsi/web/reference/js-reference, accessed 2026-06-11). This is gold for our taxonomy — one endpoint, but we can record one-tap vs button vs auto-sign-in.
|
|
15
|
+
6. **Basecamp's `google_sign_in` is alive (v1.3.1, 2025-08-29) and immune to the FedCM/GIS-JS churn** — it's a pure server-side OAuth code flow (`google_sign_in/app/controllers/google_sign_in/authorizations_controller.rb:6-9`), not the GIS JS. But it does **not** do One Tap, and its ID-token validator dependency `google-id-token` last shipped **2017-09-11** (https://rubygems.org/gems/google-id-token, accessed 2026-06-11) — a real (if so-far-functional) liability.
|
|
16
|
+
7. **Apple's guideline 4.8 no longer literally mandates Sign in with Apple** — it requires "another login service" with privacy guarantees (data limited to name/email, email hiding, no ad tracking without consent) whenever third-party login sets up the primary account; SiwA is the canonical qualifier (https://developer.apple.com/app-store/review/guidelines/, accessed 2026-06-10). `omniauth-apple` shipped v1.4.0 on 2026-01-06 (https://rubygems.org/gems/omniauth-apple/versions, accessed 2026-06-10).
|
|
17
|
+
8. **Passkeys at login time yield flags + sign_count but NOT the AAGUID** — AAGUID arrives only at registration (attested credential data; `webauthn-ruby/lib/webauthn/authenticator_data.rb:94-100`). At authentication our gem can record `user_verified?`, `credential_backed_up?`, `sign_count`, credential id (`authenticator_data.rb:53-67`) and join to a registration-time AAGUID.
|
|
18
|
+
9. **The omakase passkey story is cedarcode's `webauthn-rails`** (v0.1.0 2025-09-26 … v0.1.2 2025-10-10), a generator **built on the Rails 8 auth generator** — its sign-in funnels into `start_new_session_for`, i.e. our existing hook (https://github.com/cedarcode/webauthn-rails, accessed 2026-06-10). Rails core auth generator still has no passkey/magic-link/OAuth support.
|
|
19
|
+
10. **No flow self-identifies at the `Session#create` row level** — the inevitable conclusion is a two-layer design: automatic inference (omniauth env, warden strategy class) + an explicit annotate API (`Sessions.tag(request, method: :passkey)`) for One Tap, passkeys, magic links, OTP.
|
|
20
|
+
|
|
21
|
+
## 1. OmniAuth 2.x mechanics
|
|
22
|
+
|
|
23
|
+
### 1.1 Request phase: POST-only + CSRF (why)
|
|
24
|
+
|
|
25
|
+
- Default config: `allowed_request_methods => %i[post]` and `request_validation_phase => OmniAuth::AuthenticityTokenProtection` (`omniauth/lib/omniauth.rb:51`, `:43`). GET on `/auth/:provider` is dead by default; re-enabling it logs a loud warning citing **CVE-2015-9284** (login CSRF: attacker silently links *their* OAuth account to the victim's app session) (`omniauth/lib/omniauth/strategy.rb:205-223`).
|
|
26
|
+
- The request phase (`request_call`) stores `session['omniauth.params'] = request.GET`, runs the validation phase, then captures **origin**: `request.params[origin_param]` or `HTTP_REFERER` into `session['omniauth.origin']` (`strategy.rb:233-259`, origin at `:252-256`). CSRF failures raise `OmniAuth::AuthenticityError` → `fail!(:authenticity_error)` (`strategy.rb:263-264`) — i.e. even pre-redirect failures flow through the failure pipeline our gem can observe.
|
|
27
|
+
- `omniauth-rails_csrf_protection` exists because the built-in `AuthenticityTokenProtection` (rack-protection) doesn't understand Rails' masked/per-form tokens. Its `TokenVerifier` literally includes `ActionController::RequestForgeryProtection`, delegates config to `ActionController::Base`, and raises `ActionController::InvalidAuthenticityToken` unless `verified_request?` (`omniauth-rails_csrf_protection/lib/omniauth/rails_csrf_protection/token_verifier.rb:19-64`; Rails 8.1 config shim at `:20-34`). A railtie installs it as the global `request_validation_phase` (`lib/omniauth/rails_csrf_protection/railtie.rb:7-9`). README: "provides a mitigation against CVE-2015-9284 … by implementing a CSRF token verifier that directly uses `ActionController::RequestForgeryProtection`" (`README.md:3-6`). Practical consequence for apps: OAuth links must be `button_to`/POST forms (`README.md` Usage).
|
|
28
|
+
|
|
29
|
+
### 1.2 Callback phase: what exists at session-creation time
|
|
30
|
+
|
|
31
|
+
`callback_call` restores `env['omniauth.origin']` and `env['omniauth.params']` from the session, runs the `before_callback_phase` hook, then the strategy's `callback_phase` sets the AuthHash and passes the request down to the app (`strategy.rb:268-276`, `:424-427`):
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
def callback_phase
|
|
35
|
+
env['omniauth.auth'] = auth_hash # strategy.rb:425
|
|
36
|
+
call_app!
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
AuthHash construction (`strategy.rb:398-406`): `AuthHash.new(provider: name, uid: uid)` + `info`, `credentials`, `extra`. Validity requires `uid && provider && info` (`omniauth/lib/omniauth/auth_hash.rb:18-20`). Shape, with `omniauth-google-oauth2` as the concrete example (`omniauth-google-oauth2/lib/omniauth/strategies/google_oauth2.rb:45-86`):
|
|
41
|
+
|
|
42
|
+
| Key | Contents | Tracking value |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `provider` | strategy name, e.g. `"google_oauth2"` | → our `provider` column |
|
|
45
|
+
| `uid` | stable provider user id (`raw_info['sub']`, `:45`) | identity linking |
|
|
46
|
+
| `info` | `name`, `email` (verified only), `unverified_email`, `email_verified`, `first_name`, `last_name`, `image` (`:47-60`) | display + email-verified signal |
|
|
47
|
+
| `credentials` | token, refresh_token, expires_at (from OAuth2 base) + granted `scope` (`:62-65`) | scope auditing; do NOT store tokens |
|
|
48
|
+
| `extra` | `id_token`, claim-verified `id_info` (iss/aud/exp checked, `:71-83`), `raw_info` | `id_info` has `hd` (workspace domain), `auth_time`-ish claims |
|
|
49
|
+
|
|
50
|
+
Also at callback time: `env['omniauth.origin']` (page that initiated login — record it), `env['omniauth.params']`, `env['omniauth.strategy']` (`strategy.rb:187`). Docs: README:93-98 ("The `omniauth.auth` key … provides an Authentication Hash").
|
|
51
|
+
|
|
52
|
+
### 1.3 Failure endpoint: failed OAuth is fully interceptable
|
|
53
|
+
|
|
54
|
+
`fail!` (`strategy.rb:542-554`):
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
env['omniauth.error'] = exception
|
|
58
|
+
env['omniauth.error.type'] = message_key.to_sym # :invalid_credentials, :access_denied, :csrf_detected…
|
|
59
|
+
env['omniauth.error.strategy'] = self # strategy instance → .name = provider
|
|
60
|
+
OmniAuth.config.on_failure.call(env)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Default `on_failure` is `OmniAuth::FailureEndpoint` (`omniauth/lib/omniauth.rb:41`): raises in development (`failure_endpoint.rb:20`, envs list `omniauth.rb:42`), otherwise 302-redirects to `/auth/failure?message=<type>&origin=<origin>&strategy=<name>` (`failure_endpoint.rb:28-33`). **Devise replaces it** at require time with a proc that dispatches to the mapped `OmniauthCallbacksController.action(:failure)` (`devise/lib/devise/omniauth.rb:15-20`); that action reads `omniauth.error` / `error.type` / `error.strategy` and extracts `error_reason`/`error` off the exception (`devise/app/controllers/devise/omniauth_callbacks_controller.rb:17-27`). Interception options for us, best first:
|
|
64
|
+
|
|
65
|
+
1. Wrap `OmniAuth.config.on_failure` in our railtie *after* Devise/app initializers (compose: record, then call original). Captures: error type symbol, provider, `omniauth.origin`, IP/UA, timestamp. No user identity (auth hash usually absent on failure).
|
|
66
|
+
2. Rack middleware watching responses on `/auth/failure` (works even if apps swap `on_failure` later, but misses `failure_raise_out_environments`).
|
|
67
|
+
|
|
68
|
+
## 2. Canonical Rails integration
|
|
69
|
+
|
|
70
|
+
### 2.1 Without Devise (omakase apps) — README pattern, quoted
|
|
71
|
+
|
|
72
|
+
`omniauth/README.md:113-160` ("Rails (without Devise)") — Gemfile `omniauth` + `omniauth-rails_csrf_protection` (`:118-119`); middleware `Rails.application.config.middleware.use OmniAuth::Builder do … end` (`:126-128`); then:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# config/routes.rb README.md:137-138
|
|
76
|
+
get 'auth/:provider/callback', to: 'sessions#create'
|
|
77
|
+
get '/login', to: 'sessions#new'
|
|
78
|
+
|
|
79
|
+
# app/controllers/sessions_controller.rb README.md:143-152
|
|
80
|
+
class SessionsController < ApplicationController
|
|
81
|
+
def create
|
|
82
|
+
user_info = request.env['omniauth.auth']
|
|
83
|
+
raise user_info # Your own session management should be placed here.
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
…with a POST login form `form_tag('/auth/developer', method: 'post', data: {turbo: false})` (`README.md:157-159`). So the omakase recipe is: **OAuth callback lands in the same `SessionsController#create`-style action that calls Rails 8's `start_new_session_for`** (`rails/railties/lib/rails/generators/rails/authentication/templates/app/controllers/concerns/authentication.rb.tt:41-46` — `user.sessions.create!(user_agent:, ip_address:)` + `Current.session` + signed permanent cookie; password path at `templates/app/controllers/sessions_controller.rb.tt:8-15` via `User.authenticate_by`). Note the callback route is `get` — fine, because CSRF protection applies to the *request* phase, and the callback carries provider `state`.
|
|
89
|
+
|
|
90
|
+
### 2.2 With Devise (omniauthable)
|
|
91
|
+
|
|
92
|
+
- Model `devise :omniauthable, omniauth_providers: [:google_oauth2]`; routes auto-generate `user_google_oauth2_omniauth_authorize_path` (POST) and callback routed to the app's `Users::OmniauthCallbacksController` (Devise README "OmniAuth" section; URL helpers at `devise/lib/devise/omniauth/url_helpers.rb:6-13`).
|
|
93
|
+
- The app implements an action **named after the provider** which reads `request.env["omniauth.auth"]`, finds/creates the user, and calls `sign_in_and_redirect @user` → `sign_in` → `warden.set_user(resource, scope:)` (`devise/lib/devise/controllers/sign_in_out.rb:33-46`). Default warden event is `:set_user` (`warden/lib/warden/proxy.rb:175`), **not** `:authentication` — so naive `Warden::Manager.after_authentication` hooks miss OAuth logins; hook `after_set_user` instead and exclude `:fetch` (`warden/lib/warden/hooks.rb:53-59`).
|
|
94
|
+
- Failures: Devise's `failure` action (see §1.3). The base `Devise::OmniauthCallbacksController` also defines `passthru` (404) (`devise/app/controllers/devise/omniauth_callbacks_controller.rb:6-8`).
|
|
95
|
+
|
|
96
|
+
## 3. `google_sign_in` (Basecamp): state & viability
|
|
97
|
+
|
|
98
|
+
- What it is: a Rails engine doing the **server-side OAuth 2 auth-code flow** against Google, no GIS JavaScript at all. Routes: `resource :authorization, only: :create; resource :callback, only: :show` under `/google_sign_in` (`google_sign_in/config/routes.rb:1-4`). Button helper renders a plain POST form with hidden `proceed_to` (`app/helpers/google_sign_in/button_helper.rb:2-6`). `AuthorizationsController#create` redirects to Google's auth URL with `scope: 'openid profile email'` + random `state` stashed in flash (`app/controllers/google_sign_in/authorizations_controller.rb:6-18`).
|
|
99
|
+
- **Flash handoff**: the callback exchanges the code for an **id_token only** and redirects to `proceed_to` with `flash[:google_sign_in] = { id_token: }` or `{ error: }` (`app/controllers/google_sign_in/callbacks_controller.rb:4-25`, token exchange `:31-33`, state check `:27-29`). Your controller then builds `GoogleSignIn::Identity.new(flash[:google_sign_in]["id_token"])` exposing `user_id` (sub), `name`, `email_address`, `email_verified?`, `avatar_url`, `hosted_domain`… (`lib/google_sign_in/identity.rb:15-49`), validated via the `google-id-token` gem's `GoogleIDToken::Validator` (`identity.rb:1,8,61-62`).
|
|
100
|
+
- Maintenance: last release v1.3.1, last commit 2025-08-29 (clone `git log`); deps `rails >= 6.1`, `google-id-token >= 1.4.0`, `oauth2 >= 1.4.0` (gemspec). **Viability**: works with current Google Identity Services because the deprecations hit the old `gapi`/GIS *JavaScript* libraries and One Tap rendering — the OAuth2 web-server flow it uses is untouched. Caveats: (a) no One Tap/FedCM support and README never mentions GIS (grep confirms), (b) `google-id-token` is dormant since 2017-09-11 though still functional at 11.3M downloads (https://rubygems.org/gems/google-id-token, accessed 2026-06-11). For tracking: the session is created in the app's `proceed_to` action — `flash[:google_sign_in]` present at session-creation time is our detection signal (method `oauth`/`google`, or its own tag).
|
|
101
|
+
|
|
102
|
+
## 4. Google Identity Services, One Tap & FedCM (mid-2026)
|
|
103
|
+
|
|
104
|
+
- **One Tap is alive and FedCM-backed.** Timeline (https://developers.googleblog.com/federated-credential-management-fedcm-migration-for-google-identity-services/, published 2024-02-13, accessed 2026-06-10): phased FedCM migration from April 2024; "GIS begins migrating all One Tap traffic to FedCM" October 2024; opt-out exemption expired February 2025. The hard cutover: "**August 2025 Mandatory adoption** of FedCM APIs by the Google Sign-in platform library… After the transition period, FedCM APIs are mandatory for all web apps using the Google Sign-In library. … any `use_fedcm` settings are ignored" (https://developers.google.com/identity/sign-in/web/gsi-with-fedcm, accessed 2026-06-11).
|
|
105
|
+
- **Third-party cookie saga ended in anticlimax**: 2024-07 Google pivoted from deprecation to a "user choice" prompt; 2025-04-22 it cancelled even the prompt — third-party cookies stay, Privacy Sandbox refocused (https://www.didomi.io/blog/google-chrome-third-party-cookies-april-2025; https://www.onetrust.com/blog/google-drops-plans-for-third-party-cookie-choice-prompt-in-chrome/, both accessed 2026-06-10). FedCM went mandatory for GIS anyway — implementer impact: `prompt()` display-moment callbacks removed, custom One Tap positioning unsupported, cross-origin iframes need `allow="identity-credentials-get"` (https://developers.google.com/identity/gsi/web/guides/fedcm-migration, accessed 2026-06-10).
|
|
106
|
+
- **What a Rails app needs for One Tap in 2026** (verified pattern: https://blog.superails.com/google-onetap-oauth (Rails 8 + Devise); https://www.t27duck.com/posts/10-integrating-google-one-tap-in-a-rails-application; both accessed 2026-06-10):
|
|
107
|
+
1. Load `https://accounts.google.com/gsi/client`, configure `g_id_onload` with `client_id` + `login_uri` (or JS callback).
|
|
108
|
+
2. Google POSTs to your endpoint: `credential` (JWT id_token), `g_csrf_token` (double-submit: must equal the `g_csrf_token` cookie — t27duck, above), and **`select_by`** (https://developers.google.com/identity/gsi/web/reference/js-reference, accessed 2026-06-11 — values incl. `auto`, `user`, `user_1tap`, `user_2tap`, `btn`, `btn_confirm`, `itp`, `fedcm`, `fedcm_auto`).
|
|
109
|
+
3. Server-side verification: `googleauth` gem — `Google::Auth::IDTokens.verify_oidc(params[:credential], aud: ENV["GOOGLE_CLIENT_ID"])` (module docs: https://docs.cloud.google.com/ruby/docs/reference/googleauth/latest/Google-Auth-IDTokens, accessed 2026-06-10). `google-id-token`'s validator also works but is dormant (2017) — recommend `googleauth` in our docs.
|
|
110
|
+
4. Skip Rails CSRF for that endpoint (cross-site POST), verify `g_csrf_token` manually, then create the session yourself — **no gem mediates this flow**; `google_sign_in` (Basecamp) does not cover it.
|
|
111
|
+
- There is no OmniAuth strategy involvement in One Tap; an app *may* hand the verified payload to a Devise-omniauthable user model, but the rack env carries no `omniauth.*` keys. → explicit tagging required.
|
|
112
|
+
|
|
113
|
+
## 5. Sign in with Apple
|
|
114
|
+
|
|
115
|
+
- **Guideline 4.8 "Login Services", current text** (https://developer.apple.com/app-store/review/guidelines/, accessed 2026-06-10): "Apps that use a third-party or social login service (such as Facebook Login, Google Sign-In, Log in with X, Sign In with LinkedIn, Login with Amazon, or WeChat Login) to set up or authenticate the user's primary account with the app must also offer as an equivalent option another login service with the following features: the login service limits data collection to the user's name and email address; the login service allows users to keep their email address private as part of setting up their account; and the login service does not collect interactions with your app for advertising purposes without consent." Exceptions: own-account-only apps, alternative app marketplaces, education/enterprise accounts, government/industry eID, and clients for a specific third-party service. (Marked "ASR & NR" — applies to App Store Review & Notarization.) Net: SiwA itself is no longer the only compliance path, but remains the de-facto one — any SaaS with an iOS app shipping Google login effectively ships Apple login too, so our gem must treat `apple` as a first-class provider.
|
|
116
|
+
- **omniauth-apple**: v1.4.0 released 2026-01-06 after a 3-year gap since 1.3.0 (2023-01-17) — maintained but slow (https://rubygems.org/gems/omniauth-apple/versions, accessed 2026-06-10; repo https://github.com/nhosoya/omniauth-apple).
|
|
117
|
+
- Practical Rails notes: standard OmniAuth strategy (so §1/§2 interception applies verbatim: AuthHash `provider: "apple"`). Apple-specific quirks to document (well-known; not re-verified in this pass — verify against the omniauth-apple README when writing gem docs): callback uses `response_mode=form_post` (cross-site POST → cookie `SameSite=Lax` issues with session/state), user's name+email are delivered **only on first authorization**, and email may be a private relay address — i.e. `info.email` can be relay, `extra` carries the decoded id_token.
|
|
118
|
+
|
|
119
|
+
## 6. Passkeys / WebAuthn in Rails (2026)
|
|
120
|
+
|
|
121
|
+
### 6.1 webauthn-ruby API at a glance
|
|
122
|
+
|
|
123
|
+
- **Registration**: `WebAuthn::Credential.options_for_create(user:, exclude:)` → stash `options.challenge` in session → browser `navigator.credentials.create` → `WebAuthn::Credential.from_create(params)` → `verify(session[:creation_challenge])` → persist `webauthn_id`, `public_key`, `sign_count` (`webauthn-ruby/README.md:171-218`).
|
|
124
|
+
- **Authentication**: `options_for_get(allow:)` → challenge in session → `navigator.credentials.get` → `from_get(params)` → look up stored credential by id → `verify(challenge, public_key:, sign_count:)`; raises `WebAuthn::SignCountVerificationError` on counter regression, `WebAuthn::Error` subclasses otherwise (`README.md:224-277`).
|
|
125
|
+
- **Metadata a passkey login yields** (authenticator data flags: `webauthn-ruby/lib/webauthn/authenticator_data.rb:19-28`, accessors `:53-67`): `user_verified?` (UV — biometric/PIN vs mere presence), `user_present?`, `credential_backup_eligible?` + `credential_backed_up?` (BE/BS — synced iCloud/Google passkey vs device-bound), `sign_count` (`:29`), credential id. **AAGUID** (authenticator model, e.g. iCloud Keychain vs 1Password) is only present when attested credential data is included — i.e. at **registration**, via `attestation_object` delegation (`lib/webauthn/attestation_object.rb:44`, `lib/webauthn/authenticator_attestation_response.rb:58`, zeroed-AAGUID guard `authenticator_data.rb:94-100`). → Our gem should record AAGUID on the *credential* at registration and join at login; per-login we record UV/BS flags + sign_count + credential id.
|
|
126
|
+
|
|
127
|
+
### 6.2 Ecosystem reality
|
|
128
|
+
|
|
129
|
+
- `webauthn-ruby` v3.4.3 (2026-01-15 clone log) is the foundation everything builds on.
|
|
130
|
+
- **Omakase**: `webauthn-rails` (cedarcode) — generator on top of the Rails 8 auth generator (`--with-rails-authentication`), Stimulus controller, passkey-first or 2FA routes (`/passkeys/new`, sign-in integrated into `/session/new`); v0.1.0 2025-09-26, v0.1.2 2025-10-10 (https://github.com/cedarcode/webauthn-rails; https://medium.com/cedarcode/passkey-authentication-in-rails-8-with-webauthn-rails-c58333abae26, accessed 2026-06-10). Because it modifies the generated `SessionsController` and reuses `start_new_session_for`, **our session-creation hook fires automatically** — only the method label needs tagging.
|
|
131
|
+
- **Rodauth**: first-class `webauthn` features (passwordless login + MFA) on webauthn-ruby (https://janko.io/passkey-authentication-with-rodauth/, accessed 2026-06-10).
|
|
132
|
+
- **Devise**: still no built-in passkeys (https://github.com/heartcombo/devise/issues/5527, accessed 2026-06-10); `devise-passkeys` (https://github.com/ruby-passkeys/devise-passkeys) exists but requires customizing controllers/views; maintenance cadence not verified — check before recommending.
|
|
133
|
+
- **Rails core**: no passkey support in the auth generator; nothing concrete in rails/rails beyond the original generator issue (#50446). Community pressure is visible (Rails World 2025 talk "Passkeys Have Problems, but So Will You If You Ignore Them", https://rubyonrails.org/world/2025/day-2/jason-meller, accessed 2026-06-10). Claims of core adoption: **unverified/none found**.
|
|
134
|
+
- `authentication-zero --passkeys`: not verified this pass — flag existence should be checked before citing in docs.
|
|
135
|
+
|
|
136
|
+
## 7. Magic links & email OTP
|
|
137
|
+
|
|
138
|
+
- **passwordless** (mikker, https://github.com/mikker/passwordless, accessed 2026-06-10): standalone session-based magic links; creates its own `Passwordless::Session` records and `sign_in` helper — i.e. session creation in *its* controllers; integration via our annotate API or a documented override.
|
|
139
|
+
- **devise-passwordless** (https://github.com/devise-passwordless/devise-passwordless, updated as recently as 2025-05-20 per search results, accessed 2026-06-10): adds `:magic_link_authenticatable` **as a Warden strategy** — so in Devise apps the login runs through `warden.authenticate!` and `warden.winning_strategy` is the magic-link strategy class. Our Warden hook can label `method: :magic_link` with zero app code. Tokens are stateless; Rails filters `:token` from logs by default.
|
|
140
|
+
- Rails 8 omakase: no generator support; the common tutorial pattern (e.g. https://avohq.io/blog/magic-link-authentication-with-rails, accessed 2026-06-10) is a signed/`generates_token_for` token in email → dedicated controller → `start_new_session_for user` — again our hook fires; method needs tagging. Email/SMS OTP: no dominant gem (devise-otp/rotp exist for TOTP 2FA, distinct from login OTP); treat `otp` as a method label apps set explicitly.
|
|
141
|
+
|
|
142
|
+
## 8. Ecosystem health snapshot (mid-2026)
|
|
143
|
+
|
|
144
|
+
| Gem | Latest | Date | Signal |
|
|
145
|
+
|---|---|---|---|
|
|
146
|
+
| omniauth | 2.1.4 | 2025-10-01 (https://rubygems.org/gems/omniauth, accessed 2026-06-11) | Healthy; master prepping Ruby 4 (clone `2ad2d0d`, 2026-02-27) |
|
|
147
|
+
| omniauth-rails_csrf_protection | 2.0.1 | 2025-12-11 (clone tag/log) | Healthy; Rails 8.1-ready (`token_verifier.rb:20-34`) |
|
|
148
|
+
| omniauth-google-oauth2 | 1.2.2 | 2026-02-24 (https://rubygems.org/gems/omniauth-google-oauth2, accessed 2026-06-11) | Healthy |
|
|
149
|
+
| omniauth-apple | 1.4.0 | 2026-01-06 (rubygems, accessed 2026-06-10) | Maintained, slow cadence |
|
|
150
|
+
| omniauth-github | 2.0.1 | 2022-09-23 (https://rubygems.org/gems/omniauth-github, accessed 2026-06-11) | Dormant but stable/ubiquitous |
|
|
151
|
+
| google_sign_in | 1.3.1 | 2025-08-29 (clone log) | Maintained by 37signals; no One Tap |
|
|
152
|
+
| google-id-token | 1.4.2 | 2017-09-11 (rubygems, accessed 2026-06-11) | Dormant — prefer `googleauth` |
|
|
153
|
+
| webauthn-ruby | 3.4.3 | 2026-01-15 (clone log) | Healthy (cedarcode) |
|
|
154
|
+
| webauthn-rails | 0.1.2 | 2025-10-10 (rubygems via search, accessed 2026-06-10) | New, active, omakase-native |
|
|
155
|
+
|
|
156
|
+
## Implications for the sessions gem
|
|
157
|
+
|
|
158
|
+
### (a) Recommended taxonomy (grounded in what each flow exposes)
|
|
159
|
+
|
|
160
|
+
Two columns + one JSON blob on the session/login-event record:
|
|
161
|
+
|
|
162
|
+
- **`auth_method`** (string enum): `password`, `oauth`, `google_one_tap`, `passkey`, `magic_link`, `otp`, `sso` (SAML/OIDC enterprise), `token` (API/PAT), `unknown`. Apple is **not** a method — web Sign in with Apple arrives as `oauth` + `provider: "apple"` via omniauth-apple (§5); reserving method values for transport-distinct flows keeps the enum stable. One Tap *is* a distinct method: different endpoint, different artifact (GIS JWT POST, no OAuth dance), and its own sub-detail.
|
|
163
|
+
- **`auth_provider`** (nullable string): omniauth strategy name normalized (`google_oauth2` → `google`), `apple`, `github`, IdP entity for `sso`, `nil` for `password`/`passkey`/`magic_link`.
|
|
164
|
+
- **`auth_detail`** (jsonb): per-method extras actually available at session creation:
|
|
165
|
+
- oauth: `{ origin:, scopes:, email_verified:, hd: }` (from `omniauth.origin` + AuthHash credentials/extra, §1.2)
|
|
166
|
+
- google_one_tap: `{ select_by: }` (§4 — distinguishes `fedcm_auto` auto-sign-in from `btn` clicks)
|
|
167
|
+
- passkey: `{ credential_id:, user_verified:, backed_up:, sign_count: }`; AAGUID lives on the credential record from registration (§6.1)
|
|
168
|
+
- magic_link/otp: `{ delivery: "email" }`; token ids if app provides.
|
|
169
|
+
|
|
170
|
+
### (b) Interception matrix — where we see "session created via X"
|
|
171
|
+
|
|
172
|
+
| Flow | Omakase (Rails 8 auth gen) | Devise |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| Password | Hook `start_new_session_for` (prepend on `Authentication` concern, `authentication.rb.tt:41-46`) or AR `after_create` on app's `Session`. Default label `password` when no other signal present | Warden `after_set_user` (excluding `:fetch`): event `:authentication` + `winning_strategy` = `DatabaseAuthenticatable` (`warden/proxy.rb:339`) |
|
|
175
|
+
| OAuth (any omniauth provider, incl. Apple) | Same `start_new_session_for` hook; classify because `request.env['omniauth.auth']` is present → `oauth` + `auth.provider` + `omniauth.origin` (§1.2, §2.1) | Warden `after_set_user` with event `:set_user`, `winning_strategy` nil, `env['omniauth.auth']` present (§2.2) |
|
|
176
|
+
| google_sign_in gem | Session created in app's `proceed_to` action; detect `flash[:google_sign_in]` present → `oauth`/`google` (§3) | same (gem is Devise-agnostic) |
|
|
177
|
+
| Google One Tap | App-written controller verifying `params[:credential]`; **no automatic signal** → `Sessions.tag(request, method: :google_one_tap, detail: { select_by: params[:select_by] })` before/around session creation (§4) | same — tag inside the One Tap action even when it then calls `sign_in` |
|
|
178
|
+
| Passkey | webauthn-rails funnels into `start_new_session_for`; controller-class heuristic possible but brittle → annotate API (or first-party webauthn-rails integration) | devise-passkeys: warden strategy class detectable; vanilla webauthn-ruby: annotate API |
|
|
179
|
+
| Magic link | Tutorial pattern hits `start_new_session_for` → annotate API | devise-passwordless: `winning_strategy` = `MagicLinkAuthenticatable` → automatic (§7) |
|
|
180
|
+
| OTP / SSO / token | annotate API | annotate API (or strategy-class mapping table, kept extensible) |
|
|
181
|
+
|
|
182
|
+
Design: one **classification pipeline** at session-creation time — (1) explicit `Sessions.tag(request, ...)` (stored in `request.env['sessions.auth_method']`) wins; (2) `env['omniauth.auth']` → oauth+provider; (3) warden `winning_strategy` class → mapping table (database_authenticatable→password, magic_link→magic_link, …); (4) `flash[:google_sign_in]` → oauth/google; (5) fallback `password` in the generator's `SessionsController#create`, else `unknown`.
|
|
183
|
+
|
|
184
|
+
### (c) Failed attempts: what's realistically capturable
|
|
185
|
+
|
|
186
|
+
- **OAuth — good coverage.** Wrap `OmniAuth.config.on_failure` (compose with the existing endpoint — Devise's proc at `devise/lib/devise/omniauth.rb:15-20`, default `FailureEndpoint` otherwise; install in a late-running initializer). Capturable: `env['omniauth.error.type']` (e.g. `:invalid_credentials`, `:access_denied` = user hit Cancel at provider, `:authenticity_error` = CSRF), provider via `env['omniauth.error.strategy'].name`, `env['omniauth.origin']`, IP/UA. **Not** capturable: which local user (no uid in most failures), and nothing if the user abandons at the provider without redirecting back.
|
|
187
|
+
- **Passkey — only via app cooperation.** Failures surface as `WebAuthn::Error` rescues in app code (`webauthn-ruby/README.md:215-217, :271-277`); offer `Sessions.record_failed_attempt(request, method: :passkey, error: e.class.name)`; credential id from `params` can sometimes identify the targeted user. `SignCountVerificationError` is a possible-cloning signal worth flagging distinctly.
|
|
188
|
+
- **One Tap** — verification raises (e.g. `Google::Auth::IDTokens::SignatureError`/`AudienceMismatchError`, googleauth docs §4); plus `g_csrf_token` mismatches. Same explicit-record API.
|
|
189
|
+
- **Password** — Devise: warden failure app hook (covered in Devise/Warden memo); omakase: the `else` branch of `SessionsController#create` has no hook — document the explicit API, optionally offer a `User.authenticate_by` wrapper.
|
|
190
|
+
|
|
191
|
+
### (d) Posture
|
|
192
|
+
|
|
193
|
+
Ship: (1) session-creation hook + classification pipeline (zero-config for password/OAuth/devise-passwordless), (2) `Sessions.tag` / `record_failed_attempt` public API for One Tap/passkeys/magic links/OTP, (3) an `on_failure` composer for OAuth failures, (4) generator-time integrations ("if webauthn-rails detected, inject tag into its controllers"). Store `auth_method`+`auth_provider` as indexed columns — "show me all sessions started via Google" and "alert on first passkey login from new device" are the queries this product exists for.
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
# Prior art: authtrail, authie, authentication-zero, rodauth
|
|
2
|
+
|
|
3
|
+
> Research date: 2026-06-11. Clones at `/tmp/sessions-research/` (read-only). All `path:line` cites are relative to that root. Versions inspected: authtrail 1.0.0 (2026-04-04), authie 5.0.x (last commit 2025-12-17), authentication-zero 4.0.3 (last commit 2024-12-05), rodauth 2.44.0 (last commit 2026-06-10), rodauth-rails (last commit 2026-05-11).
|
|
4
|
+
|
|
5
|
+
## Top findings
|
|
6
|
+
|
|
7
|
+
1. **Nobody owns the middle ground.** Authtrail is an append-only *log* with zero linkage to live sessions (no session id column at all: `authtrail/lib/generators/authtrail/templates/login_activities_migration.rb.tt:1-22`). Authie and Rodauth own a live *registry* but log nothing about failures. Authentication-zero generates both (sessions + events) but it's a one-shot code dump with no upgrades, no touching, no failure log. A gem that decorates *existing* auth with **log + registry + revocation + UI** has no direct competitor.
|
|
8
|
+
2. **Authtrail's whole integration is two Warden hooks registered at `require` time** (`authtrail/lib/authtrail.rb:71-77`) — no Railtie, no Engine. The model is generated *into the app* and referenced lazily by name. That's why it's frictionless for Devise and useless for everything else.
|
|
9
|
+
3. **Rodauth active_sessions is the security gold standard**: random 32-byte key in the Rack session, **HMAC-SHA256 stored in DB** (`rodauth/lib/rodauth/features/active_sessions.rb:200`, `rodauth/lib/rodauth/features/base.rb:861`), per-request liveness check that *also* prunes expired rows and bumps `last_use` in one UPDATE (`active_sessions.rb:42-54`). But its registry stores **no IP, no user-agent** — it can't render a "your devices" page.
|
|
10
|
+
4. **Rodauth audit_logging cannot capture unknown-identity failures**: it only logs when an account row exists (`rodauth/lib/rodauth/features/audit_logging.rb:34`; FK `null: false` at `rodauth/README.rdoc:486`). Authtrail is the only one that records *attempted* identities (`authtrail/lib/authtrail.rb:23-30`).
|
|
11
|
+
5. **Authentication-zero's design became Rails 8's omakase auth** (signed permanent cookie holding a `sessions` row id; `user_agent`/`ip_address`(`ip_address:string user_agent:string`) columns — compare `authentication-zero/.../controllers/html/sessions_controller.rb.tt` `cookies.signed.permanent[:session_token]` with rails-stable `railties/.../concerns/authentication.rb.tt` `cookies.signed.permanent[:session_id]`). It already ships a user-facing **"Devices & Sessions"** page (`erb/sessions/index.html.erb.tt:3`) — but sessions have **no last-activity tracking**, so the page shows creation-time data forever.
|
|
12
|
+
6. **Authie's per-request `around_action` touch writes to the DB on every request** (`authie/lib/authie/controller_extension.rb:12`, `session.rb:97-111`) and its engine force-includes itself into **every controller** (`authie/lib/authie/engine.rb:13-16`). Powerful (live `last_activity_at`, request counter, path) but invasive — a key DX lesson.
|
|
13
|
+
7. **Nobody does**: UA/device parsing, Hotwire Native awareness, new-device email, admin UI, or automated retention (authie has a `cleanup` you must cron: `authie/lib/authie/session_model.rb:140-149`; rodauth self-prunes only the active-sessions table inline: `active_sessions.rb:45`).
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. AuthTrail — full walkthrough
|
|
18
|
+
|
|
19
|
+
Tiny: 4 lib files + 1 generator + 4 templates. Gemspec deps: only `railties >= 7.2` + `warden` (`authtrail/authtrail.gemspec:20-21`). 566 GitHub stars; "Battle-tested at Instacart" (`README.md:5`).
|
|
20
|
+
|
|
21
|
+
### 1.1 Warden hooks (the entire integration)
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
# authtrail/lib/authtrail.rb:71-77
|
|
25
|
+
Warden::Manager.after_set_user except: :fetch do |user, auth, opts|
|
|
26
|
+
AuthTrail::Manager.after_set_user(user, auth, opts)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
Warden::Manager.before_failure do |env, opts|
|
|
30
|
+
AuthTrail::Manager.before_failure(env, opts) if opts[:message]
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `except: :fetch` — session restores from cookie are **not** tracked; only fresh authentications (`set_user` events). So one row ≈ one login, not one request.
|
|
35
|
+
- `before_failure` is guarded by `opts[:message]` — failures without a Devise failure message (e.g. plain unauthenticated redirects) produce no row.
|
|
36
|
+
- Hooks are registered at **require time**, top-level, no Railtie/Engine anywhere in the gem.
|
|
37
|
+
|
|
38
|
+
### 1.2 Success/failure capture & identity extraction
|
|
39
|
+
|
|
40
|
+
`Manager.after_set_user` wraps `auth.env` in `ActionDispatch::Request` and calls `AuthTrail.track(success: true, user: user, scope: opts[:scope].to_s, strategy: detect_strategy(auth), ...)` (`authtrail/lib/auth_trail/manager.rb:4-15`). `before_failure` does the same with `success: false, failure_reason: opts[:message].to_s` (`manager.rb:17-28`) — `opts` here is Warden's `env["warden.options"]` (carries `:scope`, `:message`, `:attempted_path`).
|
|
41
|
+
|
|
42
|
+
Identity (works even when no user exists):
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# authtrail/lib/authtrail.rb:23-30
|
|
46
|
+
self.identity_method = lambda do |request, opts, user|
|
|
47
|
+
if user
|
|
48
|
+
user.try(:email)
|
|
49
|
+
else
|
|
50
|
+
scope = opts[:scope]
|
|
51
|
+
request.params[scope] && request.params[scope][:email] rescue nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Strategy detection handles OmniAuth first (`auth.env["omniauth.auth"]["provider"]`), then Warden's `winning_strategy` class name underscored, falling back to reverse lookup in `Warden::Strategies._strategies` and finally `"database_authenticatable"` (`manager.rb:32-46`; the `_strategies` rescue was the 0.7.1 fix, `CHANGELOG.md:5-7`).
|
|
57
|
+
|
|
58
|
+
### 1.3 Track pipeline & config surface
|
|
59
|
+
|
|
60
|
+
`AuthTrail.track` builds `{strategy, scope, identity, success, failure_reason, user, ip: request.remote_ip, user_agent, referrer}` + `context: "controller#action"` (`authtrail/lib/authtrail.rb:32-47`), then:
|
|
61
|
+
1. `transform_method.call(data, request)` — mutate/add fields (`authtrail.rb:51`; request passed because `exclude_method` doesn't get it, comment at `authtrail.rb:49-50`).
|
|
62
|
+
2. `exclude_method.call(data)` — skip row; exceptions are swallowed by `AuthTrail.safely` and default to **not excluding** (`authtrail.rb:53-54, 61-68`).
|
|
63
|
+
3. `track_method.call(data)` — default builds `LoginActivity` with **tolerant assignment** `login_activity.try("#{k}=", v)` then `save!`, and enqueues `GeocodeJob.perform_later` if `AuthTrail.geocode` (`authtrail.rb:15-22`). The `try(=)` means users can drop/add columns freely — schema is duck-typed.
|
|
64
|
+
|
|
65
|
+
Geocoding: `AuthTrail::GeocodeJob < ActiveJob::Base`, `queue_as { AuthTrail.job_queue }` (`authtrail/lib/auth_trail/geocode_job.rb:2-5`), calls `Geocoder.search(login_activity.ip).first` and raises a helpful error if the `geocoder` gem is missing (`geocode_job.rb:9-12`); writes `city/region/country/country_code/latitude/longitude` via `try(=)` (`geocode_job.rb:19-30`). **Quirk:** the migration has no `country_code` column (`login_activities_migration.rb.tt`), so that value is silently discarded — masked by the tolerant-assignment pattern.
|
|
66
|
+
|
|
67
|
+
### 1.4 Migration & model templates (verbatim)
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
# authtrail/lib/generators/authtrail/templates/login_activities_migration.rb.tt:3-20
|
|
71
|
+
create_table :login_activities<%= primary_key_type %> do |t|
|
|
72
|
+
t.string :scope
|
|
73
|
+
t.string :strategy
|
|
74
|
+
<%= identity_column %> # string indexed | lockbox ciphertext+bidx (install_generator.rb:35-46)
|
|
75
|
+
t.boolean :success
|
|
76
|
+
t.string :failure_reason
|
|
77
|
+
t.references :user<%= foreign_key_type %>, polymorphic: true
|
|
78
|
+
t.string :context
|
|
79
|
+
<%= ip_column %> # string indexed | lockbox ciphertext+bidx (install_generator.rb:48-55)
|
|
80
|
+
t.text :user_agent
|
|
81
|
+
t.text :referrer
|
|
82
|
+
t.string :city
|
|
83
|
+
t.string :region
|
|
84
|
+
t.string :country
|
|
85
|
+
t.float :latitude
|
|
86
|
+
t.float :longitude
|
|
87
|
+
t.datetime :created_at # append-only: no updated_at
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Generator requires `--encryption=lockbox|activerecord|none` (`install_generator.rb:9, 57-64`); MySQL identity gets `limit: 510` for AR encryption (`install_generator.rb:40-42`); respects `primary_key_type` config (`install_generator.rb:74-84`). Model templates: AR-encryption variant uses `encrypts :identity, :ip, deterministic: true`; both encrypted variants round lat/lng to 1 decimal "to protect IP" via `reduce_precision` (`model_activerecord.rb.tt`, `model_lockbox.rb.tt`); plain variant is just `belongs_to :user, polymorphic: true, optional: true` (`model_none.rb.tt`).
|
|
92
|
+
|
|
93
|
+
### 1.5 README guidance, 1.0.0, limitations
|
|
94
|
+
|
|
95
|
+
- README's recommended uses: "use this information to detect suspicious behavior" (`README.md:42`), store user on failed attempts via `transform_method` (`README.md:74-80`), LB-header geocoding (`README.md:189-197`), manual retention queries (`README.md:203-213`), and pairing with Devise `Lockable` + `Rack::Attack` (`README.md:217`). **There is no "notify on suspicious login" example in the README** — no notification code ships at all; the closest is the "Hardening Devise" blog link (`README.md:219`).
|
|
96
|
+
- **1.0.0 (2026-04-04) changed nothing functional**: "Removed support for Rails < 7.2 and Ruby < 3.3" (`CHANGELOG.md:1-3`). No Rails-8-specific features, no breaking API change — it's a maturity stamp on a finished design.
|
|
97
|
+
|
|
98
|
+
**Verified limitations** (each checked against source):
|
|
99
|
+
- Warden/Devise-only: hard `require "warden"` (`authtrail.rb:2`) + gemspec dependency; no path for Rails 8 omakase auth, which has no Warden.
|
|
100
|
+
- Append-only log, no live-session linkage: no session id/token column; nothing to revoke.
|
|
101
|
+
- No revocation, no devices UI, no admin UI: gem contains zero controllers/views/routes (file list).
|
|
102
|
+
- Logs logins only, not logouts/password changes/2FA events (only two hooks exist).
|
|
103
|
+
- No UA/device parsing: raw `t.text :user_agent`.
|
|
104
|
+
- No Hotwire/Turbo Native awareness: `grep -ri "native\|turbo"` over `lib/` → nothing.
|
|
105
|
+
- Geocoding via geocoder gem only (`geocode_job.rb:10`), else DIY headers.
|
|
106
|
+
- Retention manual (`README.md:206`).
|
|
107
|
+
- Session restores invisible (`except: :fetch`), message-less failures invisible (`authtrail.rb:76`).
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 2. Authie — DB-backed session ownership
|
|
112
|
+
|
|
113
|
+
245 stars. Active-ish: v5.0.0 2025-02-20 (Rails ≥ 7.1, `CHANGELOG.md:3-9`), configurable IP lookup added 2025-12-17 (`git log`: `feat(session/config): add configurable request ip lookup method (#52)`). Design goals: server-side invalidation, "see who is logged in", inactivity expiry, temporary vs persistent sessions (`README.md:28-34`).
|
|
114
|
+
|
|
115
|
+
### 2.1 Schema (accreted via 9 migrations, `authie/db/migrate/`)
|
|
116
|
+
|
|
117
|
+
Base table (`20141012174250_create_authie_sessions.rb`): `token`, `browser_id`, `user_id`, `active` (default true), `data` (serialized Hash), `expires_at`, `login_at`, `login_ip`, `last_activity_at`, `last_activity_ip`, `last_activity_path`, `user_agent`, timestamps. Later: `user_type` (polymorphic by hand), `parent_id` (impersonation), `two_factored_at/_ip`, `requests` counter, `password_seen_at` (sudo), **`token_hash`** (2017), `host`, `skip_two_factor`, and `login_ip_country`/`two_factored_ip_country`/`last_activity_ip_country` (2023). Note the plaintext `token` column was never dropped — only abandoned.
|
|
118
|
+
|
|
119
|
+
### 2.2 Token security & validation
|
|
120
|
+
|
|
121
|
+
- Token: `SecureRandom.alphanumeric(64)` kept only in `attr_accessor :temporary_token`; DB stores `Digest::SHA256.hexdigest(token)` (`authie/lib/authie/session_model.rb:123-126, 151-154`). Lookup: `active.where(token_hash: hash_token(token))` (`session_model.rb:133-137`).
|
|
122
|
+
- Cookie: hardcoded `cookies[:user_session]` httponly/secure-if-ssl with `expires: @session.expires_at` (`session.rb:162-171`).
|
|
123
|
+
- Browser binding: a separate 5-year `browser_id` UUID cookie set by `before_action set_browser_id` (`controller_delegate.rb:26-42`); `validate_browser_id` invalidates the session and raises `BrowserMismatch` if the cookie doesn't match the row (`session.rb:177-185`). Starting a session **invalidates all other active sessions for the same browser_id** (`session.rb:245-247`).
|
|
124
|
+
- `validate` = browser_id → active → expiry → inactivity → host, each raising a typed error and invalidating the row (`session.rb:55-62, 187-226`). Expiry semantics: persistent sessions expire by `expires_at` (default length 2 months); transient ones by `last_activity_at < 12.hours.ago` (`session_model.rb:45-54`, defaults `config.rb:33-43`).
|
|
125
|
+
|
|
126
|
+
### 2.3 Controller integration & per-request behavior
|
|
127
|
+
|
|
128
|
+
`Authie::Engine` initializer includes `ControllerExtension` into **all** of ActionController on load (`authie/lib/authie/engine.rb:7-16`), which installs `before_action :set_browser_id, :validate_auth_session` and `around_action :touch_auth_session` plus delegated helpers `current_user / logged_in? / create_auth_session / invalidate_auth_session` (`controller_extension.rb:8-22`). `touch` writes `last_activity_at/_ip/_path`, increments `requests`, optionally re-extends expiry + re-sets cookie (`session.rb:97-111, 228-236`) — **one UPDATE per authenticated request**; opt-out is per-controller `skip_touch_auth_session!` (`controller_extension.rb:31-33`).
|
|
129
|
+
|
|
130
|
+
Extras worth stealing: `invalidate_others!` (logout-everywhere: `session_model.rb:83-85`), sudo via `recently_seen_password?` (10-min window, `session_model.rb:88-90`, `config.rb:36`), anomaly primitives `first_session_for_browser?` / `first_session_for_ip?` (`session_model.rb:98-105`), `parent_id` for impersonation (`session_model.rb:13`), 13 `ActiveSupport::Notifications` events (`config.rb:56-58`, e.g. `session.rb:109,147,179`), pluggable `lookup_ip_country_backend` + `ip_lookup_method` (`config.rb:19-31`).
|
|
131
|
+
|
|
132
|
+
### 2.4 Key lesson (DX)
|
|
133
|
+
|
|
134
|
+
Authie **replaces** cookie auth: you must write your own login UI and call `create_auth_session(user)`; it is "just a session manager" (`README.md:55`). Consequences: it can't coexist with Devise/Rails-8 auth (both would fight over who is the session of record), it force-instruments every controller, it writes per request, and it ships **no UI** (no `app/` dir in repo). That's why it stayed niche at 245 stars despite having the best live-session model of its era. Decorate, don't replace.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 3. Authentication-zero — generated auth with a devices page
|
|
139
|
+
|
|
140
|
+
1872 stars (GitHub API). Initial commit 2022-02-14; last commit 2024-12-05 (dormant ~18 months). Philosophy: "generating code into the user's application instead of using a library" → total freedom, **but** "it will not be updated after it's been generated" (`authentication-zero/README.md:3, 30`).
|
|
141
|
+
|
|
142
|
+
### 3.1 CLI options
|
|
143
|
+
|
|
144
|
+
`rails generate authentication` with flags: `--api`, `--pwned`, `--sudoable`, `--lockable`, `--passwordless`, `--omniauthable`, `--trackable` (activity log), `--two-factor`, `--webauthn` (requires two_factor, `authentication_generator.rb:232-234`), `--invitable`, `--masqueradable`, `--tenantable` (`lib/generators/authentication/authentication_generator.rb:6-17`). Most HTML-only flags are disabled under `--api` (`authentication_generator.rb:220-246`).
|
|
145
|
+
|
|
146
|
+
### 3.2 Sessions: schema, cookie, controller, view
|
|
147
|
+
|
|
148
|
+
Migration (`templates/migrations/create_sessions_migration.rb.tt`): `t.references :user, null: false, foreign_key: true; t.string :user_agent; t.string :ip_address; [t.datetime :sudo_at if sudoable]; t.timestamps`. Model fills UA/IP from `Current` in `before_create` (`models/session.rb.tt:4-8`); `Current` is set by a `before_action` (`controllers/html/application_controller.rb.tt:17-20`).
|
|
149
|
+
|
|
150
|
+
Cookie = **signed permanent cookie containing the row id**: `cookies.signed.permanent[:session_token] = { value: @session.id, httponly: true }` (`controllers/html/sessions_controller.rb.tt`, create action); auth = `Session.find_by_id(cookies.signed[:session_token])` (`application_controller.rb.tt:9-14`). Nothing secret stored in DB; revocation = `destroy` the row. API flavor returns `@session.signed_id` in an `X-Session-Token` header instead (`controllers/api/sessions_controller.rb.tt:19`).
|
|
151
|
+
|
|
152
|
+
Routes: `resources :sessions, only: [:index, :show, :destroy]` (`authentication_generator.rb:200`). **It generates a user-facing devices page**:
|
|
153
|
+
|
|
154
|
+
```erb
|
|
155
|
+
<%# templates/erb/sessions/index.html.erb.tt:3-27 %>
|
|
156
|
+
<h1>Devices & Sessions</h1>
|
|
157
|
+
...
|
|
158
|
+
<p><strong>User Agent:</strong> <%= session.user_agent %></p>
|
|
159
|
+
<p><strong>Ip Address:</strong> <%= session.ip_address %></p>
|
|
160
|
+
<p><strong>Created at:</strong> <%= session.created_at %></p>
|
|
161
|
+
<%= button_to "Log out", session, method: :delete %>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
with `SessionsController#index` = `Current.user.sessions.order(created_at: :desc)` and `#destroy` scoped through `Current.user.sessions.find` (`sessions_controller.rb.tt`). **No `last_activity` touching exists anywhere** — the page shows login-time data forever, and a row's `updated_at` never moves.
|
|
165
|
+
|
|
166
|
+
### 3.3 Sudo, events, mailers, logout-others
|
|
167
|
+
|
|
168
|
+
- Sudo: `before_action :require_sudo` redirects to a password re-entry screen carrying `proceed_to_url`; success does `session_record.touch(:sudo_at)`; window is `sudo_at > 30.minutes.ago` (`controllers/html/sessions/sudos_controller.rb.tt`, `application_controller.rb.tt:22-26`, `models/session.rb.tt:15-17`).
|
|
169
|
+
- Events (`--trackable`): `events` table (`user_id, action (null:false), user_agent, ip_address, timestamps`, `migrations/create_events_migration.rb.tt`); actions are only **signed_in / signed_out** (Session callbacks, `models/session.rb.tt:11-13`) and **email_verification_requested / password_changed / email_verified** (User callbacks, `models/user.rb.tt:60-71`). **No failed-attempt logging.** User-facing "Activity Log" page at `authentications/events` (`erb/authentications/events/index.html.erb.tt:1`).
|
|
170
|
+
- Mailers: only `password_reset`, `email_verification`, plus optional `passwordless` and `invitation_instructions` (`mailers/user_mailer.rb.tt`). **No new-device / sign-in alert email.**
|
|
171
|
+
- Log out other sessions — done implicitly on password change, *deleting* (not signing out gracefully) all but current:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
# templates/models/user.rb.tt:57-59
|
|
175
|
+
after_update if: :password_digest_previously_changed? do
|
|
176
|
+
sessions.where.not(id: Current.session).delete_all
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### 3.4 Relationship to the Rails 8 generator (verified)
|
|
181
|
+
|
|
182
|
+
Auth-zero predates it (initial commit 2022-02-14 vs Rails 8.0 in late 2024). The Rails 8.1-stable generator is a strict subset of the same design: `generate "migration", "CreateSessions", "user:references ip_address:string user_agent:string"` (`rails-stable/railties/lib/rails/generators/rails/authentication/authentication_generator.rb:55`), `cookies.signed.permanent[:session_id] = { value: session.id, httponly: true, same_site: :lax }` and `start_new_session_for`/`terminate_session`/`resume_session` in a concern (`rails-stable/.../templates/app/controllers/concerns/authentication.rb.tt:40-52`), bare `Session < ApplicationRecord` model. Rails generates only `resource :session` (singular) — **no index page, no per-device revocation, no events, no touching**: exactly the gap a sessions gem fills *without* fighting the omakase structure (the `sessions` table with `ip_address`/`user_agent` already exists in every Rails 8 app!).
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 4. Rodauth — the feature gold standard
|
|
187
|
+
|
|
188
|
+
1914 stars, 54 features (`ls rodauth/lib/rodauth/features | wc -l`), commit activity same-day as this research. Taxonomy reference: `otp.rb`, `sms_codes.rb`, `webauthn.rb`(+autofill/login/verify variants), `recovery_codes.rb`, `remember.rb`, `lockout.rb`, `password_pepper.rb`, `session_expiration.rb`, `single_session.rb`, `active_sessions.rb`, `audit_logging.rb` (features dir listing).
|
|
189
|
+
|
|
190
|
+
### 4.1 active_sessions
|
|
191
|
+
|
|
192
|
+
Table (`rodauth/README.rdoc:577-583`):
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
create_table(:account_active_session_keys) do
|
|
196
|
+
foreign_key :account_id, :accounts, type: :Bignum
|
|
197
|
+
String :session_id
|
|
198
|
+
Time :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
|
|
199
|
+
Time :last_use, null: false, default: Sequel::CURRENT_TIMESTAMP
|
|
200
|
+
primary_key [:account_id, :session_id]
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
- **Stored hashed**: on login `add_active_session` generates `random_key` (= `SecureRandom.urlsafe_base64(32)`, `base.rb:713-715`), puts the raw key in the Rack session under `:active_session_id`, inserts `compute_hmac(key)` (HMAC-SHA256 with `hmac_secret`, `base.rb:855-862`) into the table (`active_sessions.rb:70-77, 199-201`). DB leak ⇒ no usable session ids; supports secret rotation via `compute_hmacs` (old+new, `base.rb:290-298`, used at `active_sessions.rb:47`).
|
|
205
|
+
- **Per-request check**: app calls `rodauth.check_active_session` in the route block; `currently_active_session?` first `remove_inactive_sessions` (self-pruning), then matches the HMAC'd id — and when an inactivity deadline is configured the *match itself* is an `UPDATE ... SET last_use = CURRENT_TIMESTAMP` returning rowcount (`active_sessions.rb:42-54, 203-211, 232-234`). One statement: validate + touch + prune trigger.
|
|
206
|
+
- Deadlines: `session_inactivity_deadline` 86400s, `session_lifetime_deadline` 30 days, OR'd into the prune condition (`active_sessions.rb:18-20, 213-230`); both nil-able.
|
|
207
|
+
- Semantics: `remove_current_session` on logout, `remove_all_active_sessions` for **global logout** — exposed as a checkbox injected into the logout form (`logout_additional_form_tags` + `global_logout_param`, `active_sessions.rb:16-17, 120-122, 168-176`); `remove_all_active_sessions_except_current` runs from `clear_tokens` (i.e., password change/reset flows) and after fresh 2FA setup (`active_sessions.rb:130-133, 148-166`); `remove_active_session(session_id)` is documented as the hook "for implementing session revoking" (`rodauth/doc/active_sessions.rdoc:57`). No UA/IP/device columns, no UI — registry only.
|
|
208
|
+
|
|
209
|
+
### 4.2 audit_logging
|
|
210
|
+
|
|
211
|
+
Table (`rodauth/README.rdoc:484-494`): `id`, `account_id` FK **`null: false`**, `at` timestamp default now, `String :message, null: false`, `metadata` (jsonb on Postgres / json / String fallback — mirrored in `rodauth-rails/lib/generators/rodauth/migration/active_record/audit_logging.erb:10-17`), indexes `[account_id, at]` and `at`.
|
|
212
|
+
|
|
213
|
+
Mechanism: every feature's `before_*`/`after_*` hook funnels through generated methods that call `hook_action(hook_type, action)` (`rodauth/lib/rodauth.rb:274`); audit_logging overrides it:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
# rodauth/lib/rodauth/features/audit_logging.rb:31-37
|
|
217
|
+
def hook_action(hook_type, action)
|
|
218
|
+
super
|
|
219
|
+
# In after_logout, session is already cleared, so use before_logout in that case
|
|
220
|
+
if (hook_type == :after || action == :logout) && (id = account ? account_id : session_value)
|
|
221
|
+
add_audit_log(id, action)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
So **all** auth events get logged automatically — login, logout, password change, otp setup, login_failure (`after_login_failure` exists at `rodauth/lib/rodauth/features/login.rb:81`), etc. Message defaults to the action name (`audit_logging.rb:59-61`); per-action overrides via DSL `audit_log_message_for :login_failure { "Login failure on domain #{request.host}" }` and `audit_log_metadata_for :login_failure { {'ip'=>request.ip} }` (`rodauth/doc/audit_logging.rdoc:19-24`) — **note: IP/UA are NOT captured by default, only via metadata blocks**. Setting a message to nil skips logging that action. Clever ops touch: on Postgres it appends `RETURNING NULL` so the app DB user needs INSERT but not SELECT on the log table (`audit_logging.rb:83-96`). **Gap:** failures for unknown identities are unlogged (guard above + FK null:false) — no attempted-identity capture.
|
|
227
|
+
|
|
228
|
+
### 4.3 single_session
|
|
229
|
+
|
|
230
|
+
One-session-per-account via `account_session_keys (id FK PK, key)` (`README.rdoc:571-574`): a per-account `key` is regenerated on each login (`update_single_session_key`, `single_session.rb:66-80`), stored HMAC'd in the session when `hmac_secret` set (`single_session.rb:99-102`), compared timing-safely on `check_single_session` (`single_session.rb:28-56`); logout just rotates the key, kicking everyone (`single_session.rb:94-97`). Doc notes active_sessions is "a more flexible version" (general knowledge of the docs; mechanism above verified in source).
|
|
231
|
+
|
|
232
|
+
### 4.4 rodauth-rails & the DX lesson
|
|
233
|
+
|
|
234
|
+
Install (`rodauth-rails/lib/generators/rodauth/install_generator.rb:21-24` options `--prefix/--argon2/--json/--jwt`) generates: migration, initializer, **`app/misc/rodauth_app.rb`** (a Roda app with a `route` block where you call `rodauth.require_account` for protected path prefixes — `templates/app/misc/rodauth_app.rb.tt`), **`app/misc/rodauth_main.rb`** (an `enable :create_account, :verify_account, ... :login, :logout, :remember` DSL config wired to Sequel **reusing the AR connection**: `db Sequel.postgres(extensions: :activerecord_connection, keep_reference: false)`, `templates/app/misc/rodauth_main.rb.tt:1-27`), mailer + 40+ view templates (bootstrap & tailwind variants). Rodauth runs as **Rack middleware** ahead of Rails routes, wrapped per-request for reloadability (`rodauth-rails/lib/rodauth/rails/middleware.rb:10-21`).
|
|
235
|
+
|
|
236
|
+
Why it stays niche despite being technically best: auth lives in a Roda app inside `app/misc/`, configured via a 600-symbol DSL, persisted via Sequel — three foreign idioms stacked on the hot path of a Rails app. The features are the reference; the integration shape is the cautionary tale. A lighter-touch gem should deliver rodauth's *table designs and semantics* through plain AR models, a Rails concern, and zero new routing layers.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## 5. Comparative feature matrix
|
|
241
|
+
|
|
242
|
+
| Capability | rails8 omakase gen | devise +trackable | authtrail | authie | authentication-zero | rodauth (active_sessions + audit_logging) |
|
|
243
|
+
|---|---|---|---|---|---|---|
|
|
244
|
+
| Live session registry | partial — rows, no UI | ✗ — last/current only (`devise/lib/devise/models/trackable.rb:10-14`) | ✗ — log only | ✓ — authie_sessions | ✓ — sessions table | ✓ — hashed keys, no UA/IP |
|
|
245
|
+
| Per-session remote revocation | ✗ — current only | ✗ | ✗ | ✓ — `invalidate!`, no UI | ✓ — destroy + button | ✓ — `remove_active_session`, no UI |
|
|
246
|
+
| Logout-everywhere | ✗ — DIY delete | ✗ | ✗ | ✓ — `invalidate_others!` | partial — on pw change only | ✓ — global_logout checkbox |
|
|
247
|
+
| Failed-attempt log | ✗ | ✗ (lockable counts only) | ✓ — with reason | ✗ | ✗ — events lack failures | partial — known accounts only |
|
|
248
|
+
| Attempted-identity capture | ✗ | ✗ | ✓ — params dig | ✗ | ✗ | ✗ — FK requires account |
|
|
249
|
+
| Device/UA parsing | ✗ — raw string | ✗ | ✗ — raw text | ✗ — raw, 255-truncated | ✗ — raw string | ✗ — not stored |
|
|
250
|
+
| Geolocation | ✗ | ✗ | ✓ — geocoder job | partial — country callback | ✗ | ✗ |
|
|
251
|
+
| Last-active touching | ✗ — created_at only | partial — at sign-in | n/a — append log | ✓ — every request | ✗ — created_at only | ✓ — on check, 1 UPDATE |
|
|
252
|
+
| Token hashing at rest | n/a — signed id cookie | n/a | n/a | ✓ — SHA-256 | n/a — signed id cookie | ✓ — HMAC-SHA256 |
|
|
253
|
+
| End-user UI shipped | ✗ | ✗ | ✗ | ✗ | ✓ — devices + log pages | ✗ — auth views only |
|
|
254
|
+
| Admin UI / scopes | ✗ | ✗ | ✗ — data only | partial — scopes only | ✗ | ✗ |
|
|
255
|
+
| New-device email | ✗ | ✗ (pw/email change only) | ✗ | ✗ | ✗ | ✗ (pw-change notify only) |
|
|
256
|
+
| Retention / pruning | ✗ | n/a | ✗ — manual SQL | partial — cron `cleanup` | ✗ | ✓ auto (sessions); ✗ logs |
|
|
257
|
+
| Multi-auth-system support | own only | devise only | warden only | own only | own only | own only |
|
|
258
|
+
| Hotwire Native awareness | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
|
|
259
|
+
| API/token sessions | ✗ | partial (DIY) | partial — logs any warden strategy | ✗ — cookie hardcoded | ✓ — `--api` signed_id header | ✓ — json/jwt/jwt_refresh |
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## 6. Steal / Improve / Avoid
|
|
264
|
+
|
|
265
|
+
### Steal
|
|
266
|
+
- **Two-callback Warden integration + `except: :fetch`** for the Devise adapter, verbatim semantics (`authtrail/lib/authtrail.rb:71-77`).
|
|
267
|
+
- **Lambda config surface** — `exclude_method` / `transform_method` / `track_method` / `identity_method`, with `safely` error isolation so tracking never breaks login (`authtrail.rb:12-30, 53-57, 61-68`).
|
|
268
|
+
- **Tolerant column assignment** `try("#{k}=", v)` so users add/drop columns without gem releases (`authtrail.rb:17-19`).
|
|
269
|
+
- **Polymorphic `user` + `scope` column** for multi-model auth (`login_activities_migration.rb.tt:9`, `README.md:104-110`).
|
|
270
|
+
- **Geocode-in-a-job** with pluggable queue + lat/lng precision reduction for privacy (`geocode_job.rb:5`, `model_activerecord.rb.tt` `reduce_precision`) — map onto trackdown instead of geocoder.
|
|
271
|
+
- **HMAC-SHA256 session ids at rest + secret rotation** (`rodauth/lib/rodauth/features/active_sessions.rb:47,200`, `base.rb:290-298,855-862`).
|
|
272
|
+
- **Validate+touch+prune in one UPDATE** (`active_sessions.rb:42-54`) — last-active freshness without authie's every-request write amplification.
|
|
273
|
+
- **Dual deadline model** (inactivity + absolute lifetime, both nil-able) (`active_sessions.rb:18-20, 225-230`).
|
|
274
|
+
- **`hook_action` funnel** — one chokepoint where every auth event becomes an audit row; per-event message/metadata override DSL (`audit_logging.rb:31-37`, `doc/audit_logging.rdoc:19-24`).
|
|
275
|
+
- **"Logout everywhere" as a logout-form checkbox** and **kill-others on password change / 2FA enrollment** (`active_sessions.rb:120-122, 130-133, 153-166`).
|
|
276
|
+
- **Auth-zero's devices page shape** — `Devices & Sessions`, per-row `button_to "Log out"`, destroy scoped via `Current.user.sessions.find` (`erb/sessions/index.html.erb.tt`, `sessions_controller.rb.tt`) — ship it as real engine views.
|
|
277
|
+
- **Sudo recipe**: `sudo_at` timestamp on the session + `require_sudo` + `proceed_to_url` round-trip (`sudos_controller.rb.tt`; authie equivalent `recently_seen_password?`, `authie/lib/authie/session_model.rb:88-90`).
|
|
278
|
+
- **Anomaly primitives** `first_session_for_browser?` / `first_session_for_ip?` — the cheap basis for "new device?" (`session_model.rb:98-105`).
|
|
279
|
+
- **AS::Notifications on every lifecycle event** (`authie/lib/authie/config.rb:56-58`).
|
|
280
|
+
- **`browser_id` long-lived cookie** as a device identifier across sessions (`authie/lib/authie/controller_delegate.rb:26-42`) — great for device continuity, make it optional.
|
|
281
|
+
- **Postgres `RETURNING NULL` insert-only audit writes** (`audit_logging.rb:83-96`).
|
|
282
|
+
|
|
283
|
+
### Improve (gaps no one fills)
|
|
284
|
+
- **Link log ↔ live session**: add `session_id` to login_activity rows so a suspicious login can be revoked in one click (authtrail has no linkage; rodauth's registry has no context).
|
|
285
|
+
- **Capture attempted identity on failures even for unknown accounts** (authtrail does; rodauth can't — `audit_logging.rb:34` + FK `README.rdoc:486`).
|
|
286
|
+
- **Store UA/IP on the registry** (rodauth omits) and **parse UA into device/browser/OS** (nobody does) for human-readable device names.
|
|
287
|
+
- **Touch last-active sanely**: auth-zero/rails8 never touch (`create_sessions_migration.rb.tt` has only timestamps); authie touches every request (`session.rb:97-111`). Improve with throttled touch (e.g. ≥1/min) à la rodauth's conditional UPDATE.
|
|
288
|
+
- **New-device/new-location notification**: zero competitors ship one; combine authie's `first_session_for_*` + authtrail-style geo + a mailer.
|
|
289
|
+
- **Automated retention** for both registry and log (authtrail: manual SQL `README.md:206`; authie: un-cron'd `cleanup` `session_model.rb:140-149`).
|
|
290
|
+
- **Adapter layer over existing auth** instead of one system: detect Rails 8 `Session` model / Devise+Warden / OAuth callbacks — the Rails 8 generator already creates a `sessions` table with `ip_address`/`user_agent` (`rails-stable/.../authentication_generator.rb:55`) begging to be decorated.
|
|
291
|
+
- **Hotwire Native awareness** (UA `Turbo Native`/bridge detection, device naming for app installs) — absent in all five codebases (grepped).
|
|
292
|
+
- **Graceful revocation semantics**: auth-zero `delete_all`s rows on password change (`user.rb.tt:57-59`) with no event trail; emit events + reason codes instead (rodauth's `set_error_reason :inactive_session`, `active_sessions.rb:65`).
|
|
293
|
+
|
|
294
|
+
### Avoid
|
|
295
|
+
- **Owning auth-session storage / replacing the auth system** — authie's fate: no UI, every-controller injection (`engine.rb:13-16`), per-request writes, 245 stars. Decorate the session of record; don't become it.
|
|
296
|
+
- **Foreign-idiom integration** — rodauth-rails' Roda-app-in-`app/misc` + Sequel-on-AR + DSL config (`rodauth_app.rb.tt`, `rodauth_main.rb.tt:14-21`, `middleware.rb`) is the documented adoption barrier.
|
|
297
|
+
- **Devise-only coupling** — authtrail's hard `require "warden"` (`authtrail.rb:2`) made it instantly irrelevant for Rails 8 omakase apps.
|
|
298
|
+
- **One-shot generated code** — auth-zero's own README admits generated code "will not be updated" (`README.md:30`); ship an engine + migrations, generate only the thin config.
|
|
299
|
+
- **Schema columns the code silently ignores** — authtrail's `country_code` written by the job but missing from the migration (`geocode_job.rb:23` vs `login_activities_migration.rb.tt`); validate schema↔writer drift in CI.
|
|
300
|
+
- **Plaintext or leftover token columns** — authie's abandoned `token` column lingers next to `token_hash` (`db/migrate/20141012174250` vs `20170417170000`); never store raw tokens, and clean up migrations.
|
|
301
|
+
- **Silent truncation of forensic data** — authie chops UA to 255 chars (`session_model.rb:118-121`); use `text`.
|
|
302
|
+
- **Failure tracking gated on framework internals** — authtrail misses message-less failures (`authtrail.rb:76`); define our own failure taxonomy.
|
|
303
|
+
- **Per-request unthrottled DB writes** (authie `touch`) and **global before_actions injected into every controller** — make instrumentation opt-in per scope.
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Implications for the sessions gem
|
|
308
|
+
|
|
309
|
+
1. **Positioning**: be the layer every auth system lacks — *registry + audit log + revocation + UI* — over Rails 8 omakase (decorate its existing `sessions` table), Devise (Warden two-hook adapter, authtrail-compatible columns for migration stories), and OAuth/passwordless (strategy column à la authtrail). Authtrail's 4.1M downloads prove demand for the log; its 1.0.0 freeze (`CHANGELOG.md:1-3`) and Warden-only design leave the field open.
|
|
310
|
+
2. **Two tables, linked**: `sessions`-registry (HMAC'd token or adopted host-app session id, UA/IP, device fields, `last_seen_at`, `revoked_at`, parent_id for impersonation) + `login_activities`-style append-only log with `session_id` FK. Rodauth proves the deadline/prune/touch mechanics (`active_sessions.rb:42-54`); authtrail proves the log schema; nobody has the join.
|
|
311
|
+
3. **DX bar**: `bundle add` + one generator + one initializer of lambdas (authtrail's config surface), engine-shipped `/sessions` devices page (auth-zero's view, done properly with last-active + friendly device names), `revoke!`/`revoke_all_others!` model API (authie naming), logout-everywhere on password change (rodauth/auth-zero semantics), notifications via AS::Notifications + optional mailer. Geolocation = optional trackdown hook mirroring `AuthTrail.geocode` + job-queue config (`authtrail.rb:21`, `geocode_job.rb:5`).
|
|
312
|
+
4. **Security defaults**: never store raw tokens (SHA-256 minimum, HMAC+rotation ideal), optional AR-encryption/lockbox path for identity+IP (authtrail generator flags), lat/lng precision rounding, append-only log with insert-only DB grants on PG, built-in retention job — each default lifted from a cited precedent above.
|