Module: Sessions::Classifier

Defined in:
lib/sessions/classifier.rb

Overview

Classifies HOW a session was started — password, OAuth (which provider), passkey, magic link… — from whatever signals the request carries at session-creation time. First match wins (→ docs/research/05-oauth.md):

1. An explicit `Sessions.tag(request, …)` (the universal escape hatch —
   One Tap, passkeys, custom SSO flows can't self-identify).
2. `env["omniauth.auth"]` — any OmniAuth callback, on either auth stack.
3. The winning Warden strategy class (Devise password and remember-me
   logins, devise-passwordless magic links, custom strategies via
   `config.strategy_methods`).
4. `flash[:google_sign_in]` — Basecamp's google_sign_in gem hands the
   id_token to the app through the flash.
5. A credentials POST (the omakase SessionsController#create shape and
   any custom password form: a password param was just exchanged for a
   session).
6. :unknown — never guess.

Output: { method:, provider:, detail: } matching the auth_method / auth_provider / auth_detail columns. Methods are reserved for transport-distinct flows (Sign in with Apple is ‘oauth` + provider “apple”, NOT its own method) so the taxonomy stays stable.

Constant Summary collapse

METHODS =
%w[password oauth google_one_tap passkey magic_link otp sso token unknown].freeze
TAG_ENV_KEY =

The rack env key ‘Sessions.tag` writes.

"sessions.auth"
STRATEGY_METHODS =

Built-in Warden strategy → method mapping. Keys are matched as substrings of the strategy class name, so Devise’s ‘Devise::Strategies::DatabaseAuthenticatable` and a host’s custom subclass both classify. ‘config.strategy_methods` entries are consulted first and may override these.

devise-two-factor is SINGLE-PHASE (password + OTP validated together in one strategy — its TwoFactorAuthenticatable SUBCLASSES Devise’s DatabaseAuthenticatable and consumes params[“otp_attempt”] before deferring to password validation, see devise-two-factor lib/devise_two_factor/strategies/two_factor_authenticatable.rb — so warden signs in once, at full auth). Its method is therefore :password — the second factor rides auth_detail (see from_warden).

Passkey first-factor strategies classify as :passkey out of the box: devise-passkeys registers Devise::Strategies::PasskeyAuthenticatable (lib/devise/passkeys/strategy.rb) and its PasskeyReauthentication subclass; bare warden-webauthn registers Warden::WebAuthn::Strategy (lib/warden/webauthn/strategy.rb) — both names match by substring.

{
  "DatabaseAuthenticatable" => :password,
  "Rememberable" => :password,
  "MagicLinkAuthenticatable" => :magic_link,
  "TwoFactorAuthenticatable" => :password,
  "TwoFactorBackupable" => :password,
  "Passkey" => :passkey,
  "WebAuthn" => :passkey
}.freeze
PROVIDER_ALIASES =

OmniAuth strategy names normalized to recognizable providers (“google_oauth2” → “google”). Unlisted strategies pass through as-is.

{
  "google_oauth2" => "google",
  "google_oauth2_hd" => "google",
  "azure_activedirectory_v2" => "microsoft",
  "microsoft_graph" => "microsoft"
}.freeze

Class Method Summary collapse

Class Method Details

.blankObject



87
88
89
# File 'lib/sessions/classifier.rb', line 87

def blank
  { method: "unknown", provider: nil, detail: {} }
end

.classify(request) ⇒ Object

Never raises — classification is best-effort decoration on the login hot path; an exotic env degrades to :unknown.



73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/sessions/classifier.rb', line 73

def classify(request)
  return blank if request.nil?

  from_tag(request) ||
    from_omniauth(request) ||
    from_warden(request) ||
    (request) ||
    from_password_post(request) ||
    blank
rescue StandardError => e
  Sessions.warn("auth classification failed: #{e.class}: #{e.message}")
  blank
end

.from_google_sign_in(request) ⇒ Object



168
169
170
171
172
173
174
175
176
177
# File 'lib/sessions/classifier.rb', line 168

def (request)
  flash = request.respond_to?(:flash) ? request.flash : nil
  return unless flash && flash["google_sign_in"].present?

  { method: "oauth", provider: "google", detail: {} }
rescue StandardError
  # Requests outside the Flash middleware (rack tests, API stacks) raise
  # when the flash hash is unavailable — there's just no signal here.
  nil
end

.from_omniauth(request) ⇒ Object



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/sessions/classifier.rb', line 102

def from_omniauth(request)
  auth = request.env["omniauth.auth"]
  return unless auth

  detail = {}
  detail["origin"] = request.env["omniauth.origin"] if request.env["omniauth.origin"]
  # AuthHash is a Hashie::Mash; plain hashes from tests work too.
  credentials = auth["credentials"] if auth.respond_to?(:[])
  detail["scopes"] = credentials["scope"] if credentials.respond_to?(:[]) && credentials["scope"]
  info = auth["info"] if auth.respond_to?(:[])
  detail["email_verified"] = info["email_verified"] if info.respond_to?(:[]) && !info["email_verified"].nil?
  extra = auth["extra"] if auth.respond_to?(:[])
  id_info = extra["id_info"] if extra.respond_to?(:[])
  detail["hd"] = id_info["hd"] if id_info.respond_to?(:[]) && id_info["hd"]

  { method: "oauth", provider: normalize_provider(auth["provider"]), detail: detail }
end

.from_password_post(request) ⇒ Object

A POST that exchanged a password for a session IS a password login —covers the omakase SessionsController and hand-rolled password forms.



181
182
183
184
185
186
187
188
189
190
191
# File 'lib/sessions/classifier.rb', line 181

def from_password_post(request)
  return unless request.respond_to?(:post?) && request.post?

  params = request.params
  return unless params.is_a?(Hash) || params.respond_to?(:[])
  return unless password_param?(params)

  { method: "password", provider: nil, detail: {} }
rescue StandardError
  nil
end

.from_tag(request) ⇒ Object



91
92
93
94
95
96
97
98
99
100
# File 'lib/sessions/classifier.rb', line 91

def from_tag(request)
  tag = request.env[TAG_ENV_KEY]
  return unless tag.is_a?(Hash)

  {
    method: normalize_method(tag[:method]),
    provider: tag[:provider]&.to_s,
    detail: (tag[:detail] || {}).to_h
  }
end

.from_warden(request) ⇒ Object



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/sessions/classifier.rb', line 120

def from_warden(request)
  warden = request.env["warden"]
  return unless warden.respond_to?(:winning_strategy)

  strategy = warden.winning_strategy
  return unless strategy

  strategy_name = strategy.class.name.to_s
  method = method_for_strategy(strategy_name)
  return unless method

  detail = {}
  detail["remembered"] = true if strategy_name.include?("Rememberable")

  # devise-two-factor: a backup-code win IS a second factor; the main
  # strategy also serves users without 2FA, so the OTP only counts when
  # an otp_attempt actually rode the request.
  if strategy_name.include?("TwoFactorBackupable")
    detail["second_factor"] = "backup_code"
  elsif strategy_name.include?("TwoFactorAuthenticatable") && otp_attempted?(request)
    detail["second_factor"] = "totp"
  end

  { method: method.to_s, provider: nil, detail: detail }
end

.method_for_strategy(strategy_name) ⇒ Object



155
156
157
158
159
160
161
162
163
164
165
166
# File 'lib/sessions/classifier.rb', line 155

def method_for_strategy(strategy_name)
  # Host entries are consulted FIRST (Hash#merge keeps the receiver's
  # keys in front, so config substrings win the iteration order) and the
  # block makes them WIN on a shared key too — a bare `merge` would let
  # the built-in value silently clobber a host override of, say,
  # "Rememberable".
  mappings = Sessions.config.strategy_methods.merge(STRATEGY_METHODS) { |_key, custom, _builtin| custom }
  mappings.each do |substring, method|
    return method if strategy_name.include?(substring)
  end
  nil
end

.normalize_method(method) ⇒ Object



202
203
204
205
# File 'lib/sessions/classifier.rb', line 202

def normalize_method(method)
  name = method.to_s
  METHODS.include?(name) ? name : name.presence || "unknown"
end

.normalize_provider(provider) ⇒ Object



207
208
209
210
211
212
# File 'lib/sessions/classifier.rb', line 207

def normalize_provider(provider)
  return nil if provider.nil?

  name = provider.to_s
  PROVIDER_ALIASES.fetch(name, name)
end

.otp_attempted?(request) ⇒ Boolean

Returns:

  • (Boolean)


146
147
148
149
150
151
152
153
# File 'lib/sessions/classifier.rb', line 146

def otp_attempted?(request)
  params = request.params
  return true if params["otp_attempt"].present?

  params.each_value.any? { |value| value.is_a?(Hash) && value["otp_attempt"].present? }
rescue StandardError
  false
end

.password_param?(params) ⇒ Boolean

Returns:

  • (Boolean)


193
194
195
196
197
198
199
200
# File 'lib/sessions/classifier.rb', line 193

def password_param?(params)
  return true if params["password"].present?

  # Devise nests credentials under the scope: user[password]
  params.each_value.any? { |value| value.is_a?(Hash) && value["password"].present? }
rescue StandardError
  false
end