sessions 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.rubocop.yml +61 -0
  3. data/.simplecov +54 -0
  4. data/AGENTS.md +5 -0
  5. data/Appraisals +26 -0
  6. data/CHANGELOG.md +26 -0
  7. data/CLAUDE.md +5 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +454 -0
  10. data/Rakefile +32 -0
  11. data/app/assets/stylesheets/sessions.css +50 -0
  12. data/app/controllers/sessions/application_controller.rb +159 -0
  13. data/app/controllers/sessions/devices_controller.rb +48 -0
  14. data/app/helpers/sessions/engine_helper.rb +126 -0
  15. data/app/views/sessions/_device.html.erb +40 -0
  16. data/app/views/sessions/_devices.html.erb +34 -0
  17. data/app/views/sessions/_event.html.erb +13 -0
  18. data/app/views/sessions/_history.html.erb +20 -0
  19. data/app/views/sessions/devices/history.html.erb +5 -0
  20. data/app/views/sessions/devices/index.html.erb +15 -0
  21. data/config/locales/en.yml +59 -0
  22. data/config/locales/es.yml +59 -0
  23. data/config/routes.rb +17 -0
  24. data/docs/PRD.md +743 -0
  25. data/docs/research/01-carhey.md +250 -0
  26. data/docs/research/02-ecosystem.md +261 -0
  27. data/docs/research/03-rails-core.md +220 -0
  28. data/docs/research/04-devise-warden.md +249 -0
  29. data/docs/research/05-oauth.md +193 -0
  30. data/docs/research/06-prior-art.md +312 -0
  31. data/docs/research/07-device-detection.md +250 -0
  32. data/docs/research/08-rails8-landscape.md +216 -0
  33. data/docs/research/09-market-security.md +450 -0
  34. data/gemfiles/rails_7.1.gemfile +34 -0
  35. data/gemfiles/rails_7.2.gemfile +34 -0
  36. data/gemfiles/rails_8.0.gemfile +34 -0
  37. data/gemfiles/rails_8.1.gemfile +34 -0
  38. data/lib/generators/sessions/install_generator.rb +230 -0
  39. data/lib/generators/sessions/madmin_generator.rb +95 -0
  40. data/lib/generators/sessions/templates/add_sessions_columns.rb.erb +81 -0
  41. data/lib/generators/sessions/templates/create_sessions.rb.erb +101 -0
  42. data/lib/generators/sessions/templates/create_sessions_events.rb.erb +108 -0
  43. data/lib/generators/sessions/templates/initializer.rb +201 -0
  44. data/lib/generators/sessions/templates/madmin/event_resource.rb +77 -0
  45. data/lib/generators/sessions/templates/madmin/session_events_controller.rb +21 -0
  46. data/lib/generators/sessions/templates/madmin/session_resource.rb +65 -0
  47. data/lib/generators/sessions/templates/madmin/sessions_controller.rb +30 -0
  48. data/lib/generators/sessions/templates/session.rb.erb +14 -0
  49. data/lib/generators/sessions/templates/sessions_sweep_job.rb +20 -0
  50. data/lib/generators/sessions/views_generator.rb +33 -0
  51. data/lib/sessions/adapters/omakase.rb +195 -0
  52. data/lib/sessions/adapters/omniauth.rb +64 -0
  53. data/lib/sessions/adapters/warden.rb +293 -0
  54. data/lib/sessions/classifier.rb +208 -0
  55. data/lib/sessions/configuration.rb +441 -0
  56. data/lib/sessions/current.rb +20 -0
  57. data/lib/sessions/device.rb +411 -0
  58. data/lib/sessions/engine.rb +120 -0
  59. data/lib/sessions/errors.rb +24 -0
  60. data/lib/sessions/geolocation.rb +111 -0
  61. data/lib/sessions/ip_address.rb +56 -0
  62. data/lib/sessions/jobs/geolocate_job.rb +58 -0
  63. data/lib/sessions/macros.rb +26 -0
  64. data/lib/sessions/middleware.rb +41 -0
  65. data/lib/sessions/models/concerns/device_display.rb +134 -0
  66. data/lib/sessions/models/concerns/has_sessions.rb +116 -0
  67. data/lib/sessions/models/concerns/model.rb +513 -0
  68. data/lib/sessions/models/event.rb +293 -0
  69. data/lib/sessions/version.rb +5 -0
  70. data/lib/sessions.rb +423 -0
  71. metadata +225 -0
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ module Adapters
5
+ # OmniAuth integration.
6
+ #
7
+ # Successes need NO hook here: the OAuth callback always lands in an
8
+ # app-side controller that creates the session through whichever adapter
9
+ # is active, and the classifier sniffs `env["omniauth.auth"]` at that
10
+ # moment (→ docs/research/05-oauth.md §1.2).
11
+ #
12
+ # Failures are the part nobody records: every strategy failure funnels
13
+ # through the swappable `OmniAuth.config.on_failure` rack endpoint. We
14
+ # COMPOSE-wrap it (record, then call the original — Devise's dispatcher
15
+ # or OmniAuth's FailureEndpoint both keep working) from
16
+ # `config.after_initialize`, so it wraps whatever the app's own
17
+ # initializers installed. Captured: the error type symbol
18
+ # (:invalid_credentials, :access_denied = the user hit Cancel,
19
+ # :authenticity_error = CSRF), the provider, the originating page, and
20
+ # IP/UA. Not capturable (documented): which local user, and
21
+ # abandonments at the provider.
22
+ module Omniauth
23
+ module_function
24
+
25
+ def install!
26
+ return if @installed
27
+ return unless defined?(::OmniAuth) && ::OmniAuth.respond_to?(:config)
28
+
29
+ @installed = true
30
+ original = ::OmniAuth.config.on_failure
31
+ ::OmniAuth.config.on_failure = lambda do |env|
32
+ Sessions::Adapters::Omniauth.record_failure(env)
33
+ original.call(env)
34
+ end
35
+ end
36
+
37
+ def installed?
38
+ !!@installed
39
+ end
40
+
41
+ def reset_installation!
42
+ @installed = false
43
+ end
44
+
45
+ def record_failure(env)
46
+ Sessions.safely("omniauth.failure") do
47
+ next unless Sessions.config.track_failed_logins
48
+
49
+ request = ActionDispatch::Request.new(env)
50
+ strategy = env["omniauth.error.strategy"]
51
+ provider = strategy.respond_to?(:name) ? strategy.name.to_s : nil
52
+
53
+ Sessions.record_failed_attempt(
54
+ request,
55
+ reason: env["omniauth.error.type"],
56
+ method: :oauth,
57
+ provider: Sessions::Classifier.normalize_provider(provider),
58
+ detail: { origin: env["omniauth.origin"] }.compact
59
+ )
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ module Adapters
5
+ # The Devise/Warden adapter — four class-level Warden hooks, registered
6
+ # from the engine ONLY when `::Warden::Manager` is already loaded
7
+ # (Bundler.require precedes initializers, so the check is decisive; the
8
+ # gem never `require`s warden itself and stays inert in non-Warden apps).
9
+ #
10
+ # The revocation mechanism generalizes devise-security's proven
11
+ # `session_limitable` (a complete 55-line template whose only structural
12
+ # flaw is one-token-per-user): the token moves from a users-table column
13
+ # to a sessions-table ROW, turning "exactly one session" into "N devices,
14
+ # each individually revocable" (→ docs/research/04-devise-warden.md §5).
15
+ #
16
+ # login — mint a random token, store [row_id, raw_token] in the
17
+ # per-scope warden session (it survives Warden's :renew SID
18
+ # rotation and is deleted by Warden itself on logout; we
19
+ # never key on the Rack SID), persist only the SHA-256
20
+ # digest on the row.
21
+ # fetch — per-request liveness check: row exists + digest matches
22
+ # (constant-time) → throttled touch; row gone (revoked!) →
23
+ # the proven session_limitable kick: clear, logout, throw.
24
+ # failure — record the failed attempt with the typed identity.
25
+ # logout — destroy the row, labeled as a logout.
26
+ module Warden
27
+ # Key inside `warden.session(scope)` holding [row_id, raw_token].
28
+ SESSION_KEY = "sessions"
29
+
30
+ # Sticky per-scope flag: a login recorded with `sessions_skip: true`
31
+ # must not be kicked by the fetch validation later (session_limitable's
32
+ # third skip layer).
33
+ SKIP_SESSION_KEY = "sessions.skip"
34
+
35
+ # Request-wide skip: `request.env["sessions.skip"] = true`.
36
+ SKIP_ENV_KEY = "sessions.skip" # = Sessions::SKIP_ENV_KEY (set by Sessions.skip!)
37
+
38
+ # The `throw :warden` message on revoked sessions — Devise's failure
39
+ # app surfaces it like :timeout/:session_limited (add a
40
+ # `devise.failure.session_revoked` translation for custom copy).
41
+ THROW_MESSAGE = :session_revoked
42
+
43
+ module_function
44
+
45
+ def install!
46
+ return if @installed
47
+
48
+ @installed = true
49
+
50
+ ::Warden::Manager.after_set_user(except: :fetch) do |record, warden, opts|
51
+ Sessions::Adapters::Warden.record_login(record, warden, opts)
52
+ end
53
+
54
+ ::Warden::Manager.after_set_user(only: :fetch) do |record, warden, opts|
55
+ Sessions::Adapters::Warden.validate_session(record, warden, opts)
56
+ end
57
+
58
+ ::Warden::Manager.before_failure do |env, opts|
59
+ Sessions::Adapters::Warden.record_failure(env, opts)
60
+ end
61
+
62
+ ::Warden::Manager.before_logout do |record, warden, opts|
63
+ Sessions::Adapters::Warden.record_logout(record, warden, opts)
64
+ end
65
+ end
66
+
67
+ # Test seam.
68
+ def installed?
69
+ !!@installed
70
+ end
71
+
72
+ def reset_installation!
73
+ @installed = false
74
+ end
75
+
76
+ # --- Hook 1: any fresh login (form, remember-me, OmniAuth, sign-up
77
+ # auto-login, post-password-reset) ----------------------------------------
78
+
79
+ def record_login(record, warden, opts)
80
+ Sessions.safely("warden.login") do
81
+ scope = opts[:scope]
82
+ # Guard set lifted from Devise's own hooks. The `store: false`
83
+ # check is CRITICAL: token/HTTP-Basic strategies fire this hook on
84
+ # EVERY request with store: false — without it we'd mint a session
85
+ # row per API call.
86
+ next unless warden.authenticated?(scope)
87
+ next if opts[:store] == false
88
+ next if warden.request.env[SKIP_ENV_KEY]
89
+ next if record.respond_to?(:sessions_skip?) && record.sessions_skip?
90
+ # Reauthentication (sudo-style confirms) re-runs sign_in
91
+ # MID-SESSION — devise-passkeys' `reauthenticate` calls
92
+ # `sign_in(..., event: :passkey_reauthentication)` (see its
93
+ # controllers/reauthentication_controller_concern.rb), which fires
94
+ # after_set_user like any login. That's the same person proving
95
+ # presence on an already-tracked session, not a new device:
96
+ # minting a row here would orphan the live one mid-request.
97
+ next if opts[:event].to_s.match?(/reauth/i)
98
+
99
+ if opts[:sessions_skip]
100
+ warden.session(scope)[SKIP_SESSION_KEY] = true
101
+ next
102
+ end
103
+
104
+ next unless row_accepts?(record)
105
+
106
+ create_row_for(record, warden, scope)
107
+ end
108
+ end
109
+
110
+ def create_row_for(record, warden, scope, suppress_login_event: false)
111
+ token = Sessions.generate_token
112
+ request = warden.request
113
+
114
+ row = Sessions.session_model.new(
115
+ user: record,
116
+ scope: scope.to_s,
117
+ ip_address: Sessions::IpAddress.resolve(request),
118
+ user_agent: request.user_agent,
119
+ token_digest: Sessions.token_digest(token)
120
+ )
121
+ row.sessions_suppress_login_event = suppress_login_event
122
+ Sessions.with_request(request) { row.save! }
123
+
124
+ warden.session(scope)[SESSION_KEY] = [row.id, token]
125
+ row
126
+ end
127
+
128
+ # --- Hook 2: per-request resume — validate, expire, touch ---------------
129
+
130
+ def validate_session(record, warden, opts)
131
+ scope = opts[:scope]
132
+ return if opts[:store] == false
133
+ return if warden.request.env[SKIP_ENV_KEY]
134
+ return if record.respond_to?(:sessions_skip?) && record.sessions_skip?
135
+
136
+ data = Sessions.safely("warden.fetch") do
137
+ session_data = warden.session(scope)
138
+ next :skip if session_data[SKIP_SESSION_KEY]
139
+
140
+ session_data[SESSION_KEY]
141
+ end
142
+ return if data == :skip
143
+
144
+ if data.nil?
145
+ adopt_preexisting_session(record, warden, scope)
146
+ return
147
+ end
148
+
149
+ # The lookup is NOT wrapped in `safely`: an ERRORED lookup and a
150
+ # MISSING row must be distinguishable. A row that's genuinely gone
151
+ # (or a token that doesn't match) means revocation → kick. A raised
152
+ # lookup — the sessions table unreachable, a timeout, a migration
153
+ # mid-deploy — means the TRACKING layer is down, and tracking must
154
+ # never break authentication: fail OPEN, let the request through
155
+ # untracked, try again next request.
156
+ begin
157
+ id, token = data
158
+ found = Sessions.session_model.find_by(id: id)
159
+ row = found if found&.sessions_token_matches?(token)
160
+ rescue StandardError => e
161
+ Sessions.warn("warden.fetch failed open: #{e.class}: #{e.message}")
162
+ return
163
+ end
164
+
165
+ if row.nil?
166
+ # Revoked (the row is gone) or tampered (digest mismatch): the
167
+ # proven session_limitable sequence — log the scope out and hand
168
+ # control to the failure app. NOT wrapped in `safely`: the throw
169
+ # is control flow, not an error.
170
+ kick!(warden, scope)
171
+ elsif Sessions.safely("warden.expired?") { row.sessions_expired? }
172
+ Sessions.safely("warden.expire") { row.revoke!(reason: :expired) }
173
+ kick!(warden, scope)
174
+ else
175
+ Sessions.safely("warden.touch") { row.touch_last_seen!(warden.request) }
176
+ end
177
+ end
178
+
179
+ # A session that predates the gem (no token in the warden session):
180
+ # adopt it so existing logged-in users appear on their devices page
181
+ # right after deploy — a row is minted with `auth_method: "unknown"`
182
+ # and NO login event (adoption isn't a login; the trail stays honest).
183
+ # Never kicks anyone: adoption failures degrade to "untracked".
184
+ def adopt_preexisting_session(record, warden, scope)
185
+ Sessions.safely("warden.adopt") do
186
+ next unless row_accepts?(record)
187
+
188
+ row = create_row_for(record, warden, scope, suppress_login_event: true)
189
+ row&.update_columns(auth_detail: { "adopted" => true })
190
+ end
191
+ end
192
+
193
+ # SCOPE-PRECISE teardown: only this scope's warden entries go (the
194
+ # serialized user key and our token stash) — an admin scope riding
195
+ # the same rack session, and unrelated host session data (carts,
196
+ # locale, return-to paths), survive a user-scope kick. Deleting the
197
+ # keys BEFORE logout matters: our before_logout hook then finds no
198
+ # token and records nothing (a kick is not a logout — the revocation
199
+ # event was already written by whoever destroyed the row).
200
+ def kick!(warden, scope)
201
+ warden.raw_session.delete("warden.user.#{scope}.key")
202
+ warden.raw_session.delete("warden.user.#{scope}.session")
203
+ warden.logout(scope)
204
+ throw :warden, scope: scope, message: THROW_MESSAGE
205
+ end
206
+
207
+ # --- Hook 3: failed logins ------------------------------------------------
208
+
209
+ def record_failure(env, opts)
210
+ Sessions.safely("warden.failure") do
211
+ next unless Sessions.config.track_failed_logins
212
+ next if env[SKIP_ENV_KEY]
213
+
214
+ request = ActionDispatch::Request.new(env)
215
+ # `before_failure` fires for EVERY warden failure, including plain
216
+ # unauthenticated page-hits and timeouts. A real credential
217
+ # failure is a POST carrying the scope's credentials hash
218
+ # (→ research/04 §3). The password key is never read.
219
+ next unless request.post?
220
+
221
+ # Devise passes scope: explicitly in auth_options; a bare
222
+ # `warden.authenticate!` throws opts WITHOUT it — fall back to the
223
+ # stack's default scope, like Warden itself does.
224
+ scope = opts[:scope] || warden_default_scope(env)
225
+ credentials = request.params[scope.to_s]
226
+ next unless credentials.is_a?(Hash)
227
+
228
+ identity = credentials.values_at("email", "login", "username", "phone").compact.first
229
+
230
+ Sessions::Event.record_failure(
231
+ request,
232
+ scope: scope,
233
+ identity: identity,
234
+ # Devise's message symbol, verbatim — under paranoid mode this
235
+ # stays :invalid; we never infer (or leak) account existence.
236
+ reason: opts[:message],
237
+ metadata: { attempted_path: opts[:attempted_path] }.compact
238
+ )
239
+ end
240
+ end
241
+
242
+ # --- Hook 4: logout ---------------------------------------------------------
243
+
244
+ # Fires once per scope (including forced logouts: timeout, lockout,
245
+ # our own revocation kick). If the row is already gone — revoked from
246
+ # another device — there's nothing to do; the `revoked` event was
247
+ # written by whoever destroyed it.
248
+ #
249
+ # CRITICAL: read the RAW session here, never `warden.session(scope)`.
250
+ # Warden's logout deletes `@users[scope]` BEFORE running before_logout
251
+ # callbacks (proxy.rb#logout), so Proxy#session's authenticated? check
252
+ # would re-deserialize the user → re-fire after_set_user → and when the
253
+ # logout came from a hook that logs out and throws (Devise's
254
+ # activatable on unconfirmed/locked accounts, timeoutable) that loops:
255
+ # activatable → logout → us → re-auth → activatable → … SystemStackError.
256
+ def record_logout(_record, warden, opts)
257
+ Sessions.safely("warden.logout") do
258
+ scope = opts[:scope]
259
+ data = warden.raw_session["warden.user.#{scope}.session"]&.dig(SESSION_KEY)
260
+ next unless data
261
+
262
+ id, token = data
263
+ row = Sessions.session_model.find_by(id: id)
264
+ next unless row&.sessions_token_matches?(token)
265
+
266
+ row.revocation_reason ||= :logout
267
+ row.destroy
268
+ end
269
+ end
270
+
271
+ def warden_default_scope(env)
272
+ warden = env["warden"]
273
+ warden.respond_to?(:config) ? warden.config.default_scope : nil
274
+ rescue StandardError
275
+ nil
276
+ end
277
+
278
+ # Multi-scope safety: with a plain (non-polymorphic) `user`
279
+ # association, rows can only hold the matching class — a second Devise
280
+ # scope on another model stays silently untracked (re-run the install
281
+ # generator with --polymorphic to track every scope).
282
+ def row_accepts?(record)
283
+ reflection = Sessions.session_model.reflect_on_association(:user)
284
+ return false unless reflection
285
+ return true if reflection.polymorphic?
286
+
287
+ record.is_a?(reflection.klass)
288
+ rescue StandardError
289
+ false
290
+ end
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sessions
4
+ # Classifies HOW a session was started — password, OAuth (which provider),
5
+ # passkey, magic link… — from whatever signals the request carries at
6
+ # session-creation time. First match wins (→ docs/research/05-oauth.md):
7
+ #
8
+ # 1. An explicit `Sessions.tag(request, …)` (the universal escape hatch —
9
+ # One Tap, passkeys, custom SSO flows can't self-identify).
10
+ # 2. `env["omniauth.auth"]` — any OmniAuth callback, on either auth stack.
11
+ # 3. The winning Warden strategy class (Devise password and remember-me
12
+ # logins, devise-passwordless magic links, custom strategies via
13
+ # `config.strategy_methods`).
14
+ # 4. `flash[:google_sign_in]` — Basecamp's google_sign_in gem hands the
15
+ # id_token to the app through the flash.
16
+ # 5. A credentials POST (the omakase SessionsController#create shape and
17
+ # any custom password form: a password param was just exchanged for a
18
+ # session).
19
+ # 6. :unknown — never guess.
20
+ #
21
+ # Output: { method:, provider:, detail: } matching the auth_method /
22
+ # auth_provider / auth_detail columns. Methods are reserved for
23
+ # transport-distinct flows (Sign in with Apple is `oauth` + provider
24
+ # "apple", NOT its own method) so the taxonomy stays stable.
25
+ module Classifier
26
+ METHODS = %w[password oauth google_one_tap passkey magic_link otp sso token unknown].freeze
27
+
28
+ # The rack env key `Sessions.tag` writes.
29
+ TAG_ENV_KEY = "sessions.auth"
30
+
31
+ # Built-in Warden strategy → method mapping. Keys are matched as
32
+ # substrings of the strategy class name, so Devise's
33
+ # `Devise::Strategies::DatabaseAuthenticatable` and a host's custom
34
+ # subclass both classify. `config.strategy_methods` entries are
35
+ # consulted first and may override these.
36
+ #
37
+ # devise-two-factor is SINGLE-PHASE (password + OTP validated together
38
+ # in one strategy — its TwoFactorAuthenticatable SUBCLASSES Devise's
39
+ # DatabaseAuthenticatable and consumes params[scope]["otp_attempt"]
40
+ # before deferring to password validation, see devise-two-factor
41
+ # lib/devise_two_factor/strategies/two_factor_authenticatable.rb — so
42
+ # warden signs in once, at full auth). Its method is therefore
43
+ # :password — the second factor rides auth_detail (see from_warden).
44
+ #
45
+ # Passkey first-factor strategies classify as :passkey out of the box:
46
+ # devise-passkeys registers Devise::Strategies::PasskeyAuthenticatable
47
+ # (lib/devise/passkeys/strategy.rb) and its PasskeyReauthentication
48
+ # subclass; bare warden-webauthn registers Warden::WebAuthn::Strategy
49
+ # (lib/warden/webauthn/strategy.rb) — both names match by substring.
50
+ STRATEGY_METHODS = {
51
+ "DatabaseAuthenticatable" => :password,
52
+ "Rememberable" => :password,
53
+ "MagicLinkAuthenticatable" => :magic_link,
54
+ "TwoFactorAuthenticatable" => :password,
55
+ "TwoFactorBackupable" => :password,
56
+ "Passkey" => :passkey,
57
+ "WebAuthn" => :passkey
58
+ }.freeze
59
+
60
+ # OmniAuth strategy names normalized to recognizable providers
61
+ # ("google_oauth2" → "google"). Unlisted strategies pass through as-is.
62
+ PROVIDER_ALIASES = {
63
+ "google_oauth2" => "google",
64
+ "google_oauth2_hd" => "google",
65
+ "azure_activedirectory_v2" => "microsoft",
66
+ "microsoft_graph" => "microsoft"
67
+ }.freeze
68
+
69
+ module_function
70
+
71
+ # Never raises — classification is best-effort decoration on the login
72
+ # hot path; an exotic env degrades to :unknown.
73
+ def classify(request)
74
+ return blank if request.nil?
75
+
76
+ from_tag(request) ||
77
+ from_omniauth(request) ||
78
+ from_warden(request) ||
79
+ from_google_sign_in(request) ||
80
+ from_password_post(request) ||
81
+ blank
82
+ rescue StandardError => e
83
+ Sessions.warn("auth classification failed: #{e.class}: #{e.message}")
84
+ blank
85
+ end
86
+
87
+ def blank
88
+ { method: "unknown", provider: nil, detail: {} }
89
+ end
90
+
91
+ def from_tag(request)
92
+ tag = request.env[TAG_ENV_KEY]
93
+ return unless tag.is_a?(Hash)
94
+
95
+ {
96
+ method: normalize_method(tag[:method]),
97
+ provider: tag[:provider]&.to_s,
98
+ detail: (tag[:detail] || {}).to_h
99
+ }
100
+ end
101
+
102
+ def from_omniauth(request)
103
+ auth = request.env["omniauth.auth"]
104
+ return unless auth
105
+
106
+ detail = {}
107
+ detail["origin"] = request.env["omniauth.origin"] if request.env["omniauth.origin"]
108
+ # AuthHash is a Hashie::Mash; plain hashes from tests work too.
109
+ credentials = auth["credentials"] if auth.respond_to?(:[])
110
+ detail["scopes"] = credentials["scope"] if credentials.respond_to?(:[]) && credentials["scope"]
111
+ info = auth["info"] if auth.respond_to?(:[])
112
+ detail["email_verified"] = info["email_verified"] if info.respond_to?(:[]) && !info["email_verified"].nil?
113
+ extra = auth["extra"] if auth.respond_to?(:[])
114
+ id_info = extra["id_info"] if extra.respond_to?(:[])
115
+ detail["hd"] = id_info["hd"] if id_info.respond_to?(:[]) && id_info["hd"]
116
+
117
+ { method: "oauth", provider: normalize_provider(auth["provider"]), detail: detail }
118
+ end
119
+
120
+ def from_warden(request)
121
+ warden = request.env["warden"]
122
+ return unless warden.respond_to?(:winning_strategy)
123
+
124
+ strategy = warden.winning_strategy
125
+ return unless strategy
126
+
127
+ strategy_name = strategy.class.name.to_s
128
+ method = method_for_strategy(strategy_name)
129
+ return unless method
130
+
131
+ detail = {}
132
+ detail["remembered"] = true if strategy_name.include?("Rememberable")
133
+
134
+ # devise-two-factor: a backup-code win IS a second factor; the main
135
+ # strategy also serves users without 2FA, so the OTP only counts when
136
+ # an otp_attempt actually rode the request.
137
+ if strategy_name.include?("TwoFactorBackupable")
138
+ detail["second_factor"] = "backup_code"
139
+ elsif strategy_name.include?("TwoFactorAuthenticatable") && otp_attempted?(request)
140
+ detail["second_factor"] = "totp"
141
+ end
142
+
143
+ { method: method.to_s, provider: nil, detail: detail }
144
+ end
145
+
146
+ def otp_attempted?(request)
147
+ params = request.params
148
+ return true if params["otp_attempt"].present?
149
+
150
+ params.each_value.any? { |value| value.is_a?(Hash) && value["otp_attempt"].present? }
151
+ rescue StandardError
152
+ false
153
+ end
154
+
155
+ def method_for_strategy(strategy_name)
156
+ Sessions.config.strategy_methods.merge(STRATEGY_METHODS).each do |substring, method|
157
+ return method if strategy_name.include?(substring)
158
+ end
159
+ nil
160
+ end
161
+
162
+ def from_google_sign_in(request)
163
+ flash = request.respond_to?(:flash) ? request.flash : nil
164
+ return unless flash && flash["google_sign_in"].present?
165
+
166
+ { method: "oauth", provider: "google", detail: {} }
167
+ rescue StandardError
168
+ # Requests outside the Flash middleware (rack tests, API stacks) raise
169
+ # when the flash hash is unavailable — there's just no signal here.
170
+ nil
171
+ end
172
+
173
+ # A POST that exchanged a password for a session IS a password login —
174
+ # covers the omakase SessionsController and hand-rolled password forms.
175
+ def from_password_post(request)
176
+ return unless request.respond_to?(:post?) && request.post?
177
+
178
+ params = request.params
179
+ return unless params.is_a?(Hash) || params.respond_to?(:[])
180
+ return unless password_param?(params)
181
+
182
+ { method: "password", provider: nil, detail: {} }
183
+ rescue StandardError
184
+ nil
185
+ end
186
+
187
+ def password_param?(params)
188
+ return true if params["password"].present?
189
+
190
+ # Devise nests credentials under the scope: user[password]
191
+ params.each_value.any? { |value| value.is_a?(Hash) && value["password"].present? }
192
+ rescue StandardError
193
+ false
194
+ end
195
+
196
+ def normalize_method(method)
197
+ name = method.to_s
198
+ METHODS.include?(name) ? name : name.presence || "unknown"
199
+ end
200
+
201
+ def normalize_provider(provider)
202
+ return nil if provider.nil?
203
+
204
+ name = provider.to_s
205
+ PROVIDER_ALIASES.fetch(name, name)
206
+ end
207
+ end
208
+ end