Module: Sessions

Defined in:
lib/sessions.rb,
lib/sessions/device.rb,
lib/sessions/engine.rb,
lib/sessions/errors.rb,
lib/sessions/macros.rb,
lib/sessions/current.rb,
lib/sessions/version.rb,
lib/sessions/classifier.rb,
lib/sessions/ip_address.rb,
lib/sessions/middleware.rb,
lib/sessions/geolocation.rb,
lib/sessions/models/event.rb,
lib/sessions/configuration.rb,
lib/sessions/adapters/warden.rb,
lib/sessions/adapters/omakase.rb,
lib/sessions/adapters/omniauth.rb,
lib/sessions/jobs/geolocate_job.rb,
app/helpers/sessions/engine_helper.rb,
lib/sessions/models/concerns/model.rb,
lib/generators/sessions/views_generator.rb,
lib/generators/sessions/madmin_generator.rb,
lib/generators/sessions/install_generator.rb,
lib/sessions/models/concerns/has_sessions.rb,
app/controllers/sessions/devices_controller.rb,
lib/sessions/models/concerns/device_display.rb,
app/controllers/sessions/application_controller.rb

Overview

Sessions

Every session, every device, every login — tracked, revocable, visible. The missing session layer for Rails.

The public surface is intentionally tiny:

Sessions.configure { |config| ... }       # one block, in an initializer
has_sessions                              # on your auth model

current_user.sessions.active              # live devices
session.device_name                       # => "Chrome on macOS"
session.revoke!                           # remote logout, effective next request
current_user.revoke_other_sessions!       # GitHub's "sign out everywhere else"
current_user.session_history.failed_logins # the trail, identity-matched failures included

Plus a handful of request-side seams for flows that can’t self-identify:

Sessions.tag(request, method: :passkey)   # label the upcoming login
Sessions.skip!(request)                   # "neither a login nor a failure" (2FA handoffs)
Sessions.current(request)                 # this request's session row
Sessions.(request)              # how this browser last signed in ("Last used" badge)
Sessions.record_failed_attempt(request, identity: params[:email], reason: :invalid_password)
Sessions.(user, request, method: :sso)

Everything else (adapters, the devices page, the sweep) ships with the engine and stays out of your way. One rule above all: tracking NEVER breaks authentication — every recording path in this gem is error-isolated (see Sessions.safely).

Defined Under Namespace

Modules: Adapters, Classifier, DeviceDisplay, EngineHelper, Generators, Geolocation, HasSessions, IpAddress, Macros, Model Classes: ApplicationController, Configuration, ConfigurationError, Current, Device, DevicesController, Engine, Error, Event, EventResource, GeolocateJob, Middleware, UnknownAuthSystemError

Constant Summary collapse

:sessions_device_id
SKIP_ENV_KEY =

The rack env flag ‘Sessions.skip!` sets — every recording seam checks it before writing anything for the request.

"sessions.skip"
VERSION =
"0.1.0"

Class Method Summary collapse

Class Method Details

.configObject Also known as: configuration

— Configuration ——————————————————–



71
72
73
# File 'lib/sessions.rb', line 71

def config
  @config ||= Configuration.new
end

.configure {|config| ... } ⇒ Object

Yields:



77
78
79
80
81
# File 'lib/sessions.rb', line 77

def configure
  yield config if block_given?
  config.validate!
  config
end

.current(request = Sessions::Current.request, scope: nil) ⇒ Object

The registry row for this request — works on both adapters: omakase (Current.session / the signed session cookie) and Devise/Warden (the per-scope token stashed in the warden session). Returns nil when the request carries no live tracked session.

Multi-scope Devise apps (user + admin signed in on one rack session) carry one tracked row per scope; pass ‘scope:` to pick — without it you get the first live row found, which is unambiguous for the single-scope majority:

Sessions.current(request, scope: :admin_user)


146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/sessions.rb', line 146

def current(request = Sessions::Current.request, scope: nil)
  return nil unless request

  safely("current") do
    if scope
      # An explicit scope is a Warden concept — answering it from
      # Current.session (the omakase shortcut) would ignore it.
      warden_current(request, scope: scope)
    else
      omakase_current(request) || warden_current(request) || cookie_current(request)
    end
  end
end

.forget(user, identity: nil) ⇒ Object

Right-to-erasure helper: destroy every live session, delete the trail, and null the typed identity on any retained failure rows that match user‘s email — so honoring a GDPR deletion request is one call.



251
252
253
254
255
256
257
258
259
260
261
# File 'lib/sessions.rb', line 251

def forget(user, identity: nil)
  safely("forget") do
    session_model.where(user: user).destroy_all if session_model_table?
    Sessions::Event.where(authenticatable: user).delete_all

    typed = identity || user.try(:email_address) || user.try(:email)
    Sessions::Event.where(identity: Sessions::Event.normalize_identity(typed)).update_all(identity: nil) if typed

    true
  end
end

.generate_tokenObject



293
294
295
# File 'lib/sessions.rb', line 293

def generate_token
  SecureRandom.hex(32)
end

.last_login(request) ⇒ Object

The most recent login EVENT from THIS BROWSER — works on the login page, signed out, because the browser-continuity cookie (the same one that deduplicates devices) survives logout by design. This is the one-lookup answer behind the “Last used” badge next to your sign-in buttons:

<% if (last = Sessions.last_login(request))&.auth_provider == "google" %>
  <span class="badge">Last used</span>
<% end %>

The event carries auth_method / auth_provider / auth_method_label / occurred_at (“last used 2 days ago”). Device-scoped, not account-scoped: it reflects whoever last signed in from this browser — exactly what a login page can honestly know. Returns nil for browsers that never signed in, cleared cookies, or tampered values (the cookie is signed). Read-only: never mints the cookie.



176
177
178
179
180
181
182
183
184
185
186
# File 'lib/sessions.rb', line 176

def (request)
  return nil unless request.respond_to?(:cookie_jar)

  safely("last_login") do
    device_id = request.cookie_jar.signed[DEVICE_COOKIE]
    next nil if device_id.blank?

    Sessions::Event.logins.where(device_id: device_id.to_s[0, 36])
                   .order(occurred_at: :desc).first
  end
end

.loggerObject



281
282
283
# File 'lib/sessions.rb', line 281

def logger
  defined?(::Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil
end

.notify_event(event) ⇒ Object

The catch-all ‘config.events` tee, error-isolated.



308
309
310
# File 'lib/sessions.rb', line 308

def notify_event(event)
  safely("events hook") { config.events.call(event) }
end

.record_failed_attempt(request, scope: nil, identity: nil, reason: nil, method: nil, provider: nil, detail: {}, metadata: {}) ⇒ Object

Record a failed login attempt from a custom controller — the manual seam for flows outside Warden’s failure app and the omakase SessionsController (a native-app sign-in branch, passkey verification rescues, One Tap token errors…).

Sessions.record_failed_attempt(request, scope: :user,
                               identity: params[:email],
                               reason: :invalid_password)

Never raises; returns the Sessions::Event or nil.



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/sessions.rb', line 198

def record_failed_attempt(request, scope: nil, identity: nil, reason: nil,
                          method: nil, provider: nil, detail: {}, metadata: {})
  return nil unless config.track_failed_logins

  safely("record_failed_attempt") do
    tag(request, method: method, provider: provider, detail: detail) if method

    Sessions::Event.record_failure(
      request,
      scope: scope,
      identity: identity,
      reason: reason,
      metadata: 
    )
  end
end

.reset!Object

Reset all global state. Used by the test suite to keep examples isolated; also handy in a console when experimenting.



85
86
87
88
# File 'lib/sessions.rb', line 85

def reset!
  @config = Configuration.new
  self
end

.safely(context = nil) ⇒ Object

The error-isolation chokepoint: this gem sits on the authentication hot path, where a tracking bug may lose a log row but must NEVER 500 a sign-in (authtrail’s ‘safely` pattern, ecosystem rule). Everything the adapters and model callbacks do goes through here.



269
270
271
272
273
274
# File 'lib/sessions.rb', line 269

def safely(context = nil)
  yield
rescue StandardError => e
  warn("#{context}: #{e.class}: #{e.message}")
  nil
end

.session_modelObject

The host’s session-of-record model (‘Session` on both supported stacks; `config.session_class` is the escape hatch).



92
93
94
# File 'lib/sessions.rb', line 92

def session_model
  config.session_model
end

.skip!(request) ⇒ Object

Silence tracking for THIS request — the escape hatch for flows that intentionally end with neither a session nor a failure. The canonical case is the password phase of a two-phase 2FA challenge (authentication-zero’s –two-factor, hand-rolled TOTP gates): the password was RIGHT, the controller redirects to the challenge, and recording a failed_login there would be a lie:

if user.otp_required_for_sign_in?
  Sessions.skip!(request)
  session[:challenge_token] = user.signed_id(...)
  redirect_to new_two_factor_authentication_challenge_totp_path
end

Honored by every recording seam (both adapters, the failed-login heuristics). One request only — the challenge completion records normally.



128
129
130
131
132
133
# File 'lib/sessions.rb', line 128

def skip!(request)
  return unless request

  request.env[SKIP_ENV_KEY] = true
  request
end

.sweep!Object

The maintenance pass the generated SessionsSweepJob runs on a schedule: expire idle/over-age sessions (only when timeouts are configured), evict per-user overflow beyond the session cap, and purge trail rows past retention. Each part is independently error-isolated. Returns a Hash of counts.



240
241
242
243
244
245
246
# File 'lib/sessions.rb', line 240

def sweep!
  {
    expired: safely("sweep.expired") { sweep_expired_sessions! } || 0,
    pruned: safely("sweep.pruned") { sweep_session_overflow! } || 0,
    purged_events: safely("sweep.events") { sweep_stale_events! } || 0
  }
end

.tag(request, method:, provider: nil, detail: {}) ⇒ Object

Label the login that’s about to happen on this request — for flows that can’t self-identify at the session-row level (Google One Tap, passkeys, magic links, custom SSO). Call it BEFORE signing the user in; the classification pipeline gives explicit tags top priority.

Sessions.tag(request, method: :google_one_tap, detail: { select_by: params[:select_by] })
Sessions.tag(request, method: :passkey, detail: { user_verified: true })


105
106
107
108
109
110
# File 'lib/sessions.rb', line 105

def tag(request, method:, provider: nil, detail: {})
  return unless request

  request.env[Classifier::TAG_ENV_KEY] = { method: method, provider: provider, detail: detail }
  request
end

.token_digest(token) ⇒ Object

SHA-256 of a session token. High-entropy random input ⇒ a plain digest suffices (no pepper KDF theater); the raw token only ever lives in the user’s own Rack session (OWASP: never persist raw session identifiers).



289
290
291
# File 'lib/sessions.rb', line 289

def token_digest(token)
  OpenSSL::Digest::SHA256.hexdigest(token.to_s)
end

.track_login(user, request, method: nil, provider: nil, detail: {}) ⇒ Object

Fully manual integration: create (and classify, parse, geolocate) a registry row + login event for user outside any adapter. The host owns linking the returned row to its own session mechanism and enforcing revocation. Never raises; returns the session row or nil.



219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/sessions.rb', line 219

def (user, request, method: nil, provider: nil, detail: {})
  safely("track_login") do
    tag(request, method: method, provider: provider, detail: detail) if method

    with_request(request) do
      session_model.create!(
        user: user,
        ip_address: IpAddress.resolve(request),
        user_agent: request&.user_agent
      )
    end
  end
end

.warn(message) ⇒ Object



276
277
278
279
# File 'lib/sessions.rb', line 276

def warn(message)
  logger&.warn("[sessions] #{message}")
  nil
end

.with_request(request) ⇒ Object

Run a block with Sessions::Current.request temporarily set — lets explicit APIs reuse the same model-callback pipeline the adapters use.



299
300
301
302
303
304
305
# File 'lib/sessions.rb', line 299

def with_request(request)
  previous = Sessions::Current.request
  Sessions::Current.request = request
  yield
ensure
  Sessions::Current.request = previous
end