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
- .blank ⇒ Object
-
.classify(request) ⇒ Object
Never raises — classification is best-effort decoration on the login hot path; an exotic env degrades to :unknown.
- .from_google_sign_in(request) ⇒ Object
- .from_omniauth(request) ⇒ Object
-
.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.
- .from_tag(request) ⇒ Object
- .from_warden(request) ⇒ Object
- .method_for_strategy(strategy_name) ⇒ Object
- .normalize_method(method) ⇒ Object
- .normalize_provider(provider) ⇒ Object
- .otp_attempted?(request) ⇒ Boolean
- .password_param?(params) ⇒ Boolean
Class Method Details
.blank ⇒ Object
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) || from_google_sign_in(request) || from_password_post(request) || blank rescue StandardError => e Sessions.warn("auth classification failed: #{e.class}: #{e.}") blank end |
.from_google_sign_in(request) ⇒ Object
162 163 164 165 166 167 168 169 170 171 |
# File 'lib/sessions/classifier.rb', line 162 def from_google_sign_in(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.
175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/sessions/classifier.rb', line 175 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 |
# File 'lib/sessions/classifier.rb', line 155 def method_for_strategy(strategy_name) Sessions.config.strategy_methods.merge(STRATEGY_METHODS).each do |substring, method| return method if strategy_name.include?(substring) end nil end |
.normalize_method(method) ⇒ Object
196 197 198 199 |
# File 'lib/sessions/classifier.rb', line 196 def normalize_method(method) name = method.to_s METHODS.include?(name) ? name : name.presence || "unknown" end |
.normalize_provider(provider) ⇒ Object
201 202 203 204 205 206 |
# File 'lib/sessions/classifier.rb', line 201 def normalize_provider(provider) return nil if provider.nil? name = provider.to_s PROVIDER_ALIASES.fetch(name, name) end |
.otp_attempted?(request) ⇒ 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
187 188 189 190 191 192 193 194 |
# File 'lib/sessions/classifier.rb', line 187 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 |