Class: StandardId::Session

Inherits:
ApplicationRecord show all
Defined in:
app/models/standard_id/session.rb

Direct Known Subclasses

BrowserSession, DeviceSession, ServiceSession

Defined Under Namespace

Classes: RevocationResult

Constant Summary collapse

DIGEST_PREFIX =

Keyed digest of an opaque session token — the default scheme.

HMAC-SHA256 under secret_key_base, domain-separated from lookup_hash by both construction (HMAC vs plain SHA256) and an explicit versioned prefix, so the two stored values can never coincide even though both derive from the same token and secret.

Why not BCrypt (which this was, and which token_digest_cost still selects): BCrypt's cost factor exists to make brute-force of a LOW-entropy secret expensive. Session tokens are SecureRandom.urlsafe_base64(32) — 256 bits — so guessing one is infeasible regardless of how fast the hash is, and the stretching bought nothing while costing a measured ~181 ms of CPU on EVERY authenticated request (cost 12). That is the same reasoning under which RefreshToken digests with plain SHA256 and lookup_hash with SHA256; this brings session tokens in line with both.

"standard_id.session.token_digest.v1:".freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#tokenObject (readonly)

Returns the value of attribute token.



215
216
217
# File 'app/models/standard_id/session.rb', line 215

def token
  @token
end

Class Method Details

.authenticate_by_token(token) ⇒ StandardId::Session?

Authenticate an opaque session token.

by_token is NOT authentication on its own: it matches the SHA256 lookup_hash, which exists only to find the candidate row from an indexed column. The credential is token_digest (see #authenticate_token for the two schemes it may hold), and every consumer previously had to remember to verify it by hand — and to rescue BCrypt::Errors::InvalidHash. This is that step, done once, here.

Honours the current scope, so callers keep their own filters:

StandardId::Session.api_compatible.active.authenticate_by_token(token)

Parameters:

  • token (String, nil)

    the raw token presented by the client

Returns:

  • (StandardId::Session, nil)

    the session, or nil when the token is blank, matches no row, or fails the digest verification



41
42
43
44
45
46
47
48
# File 'app/models/standard_id/session.rb', line 41

def self.authenticate_by_token(token)
  return nil if token.blank?

  session = by_token(token).first
  return nil if session.nil?

  session.authenticate_token(token) ? session : nil
end

.hmac_token_digest(token) ⇒ Object



67
68
69
70
71
# File 'app/models/standard_id/session.rb', line 67

def self.hmac_token_digest(token)
  OpenSSL::HMAC.hexdigest(
    "SHA256", Rails.configuration.secret_key_base, "#{DIGEST_PREFIX}#{token}"
  )
end

.revoke_all_for!(account, reason: nil) ⇒ RevocationResult

Revoke every not-yet-revoked session for an account, cascading to refresh tokens, in two set-based UPDATEs.

Honours the current scope, so callers narrow as they need:

StandardId::Session.revoke_all_for!(, reason: "password_reset")
StandardId::DeviceSession.active.revoke_all_for!(, reason: "logout")

Why this exists rather than sessions.each(&:revoke!): the loop is O(N) UPDATEs plus O(N) refresh-token cascades, and the call sites are admin bulk actions and password resets.

Events: one SESSION_REVOKED per revoked session, never a single aggregate — subscribers must not need a second code path for bulk revocation. Each is published individually and individually rescued (see .revoke_sessions!).

Parameters:

  • account (ActiveRecord::Base, String, Integer)

    the account, or its id

  • reason (String, nil) (defaults to: nil)

    carried on each SESSION_REVOKED event

Returns:



128
129
130
131
132
133
134
# File 'app/models/standard_id/session.rb', line 128

def self.revoke_all_for!(, reason: nil)
   = .is_a?(ActiveRecord::Base) ? .id : 
  sessions = where(account_id: ).where(revoked_at: nil).to_a
   = .is_a?(ActiveRecord::Base) ?  : nil

  revoke_sessions!(sessions, account: , reason: reason)
end

.revoke_sessions!(sessions, account: nil, reason: nil) ⇒ RevocationResult

Set-based revocation of an explicit collection of sessions.

Bulk-revoke in two queries (one UPDATE per table) instead of issuing session.revoke! per row, which would be O(N) UPDATEs plus another O(N) cascades to refresh_tokens.

Tradeoff: update_all skips ActiveRecord callbacks, so the per-row SESSION_REVOKED event emitted by #revoke! (via its after_commit) does not fire automatically. We re-emit it explicitly below so audit-trail subscribers (account status/locking, etc.) still see one event per revoked session — the semantics are preserved, only the SQL shape has changed.

Parameters:

  • sessions (Enumerable<StandardId::Session>)

    sessions to revoke; all must belong to the same account

  • account (ActiveRecord::Base, nil) (defaults to: nil)

    the shared account, when the caller already has it loaded. Passed through rather than read per row: session.account would issue N extra SELECTs. Falls back to sessions.first.account.

  • reason (String, nil) (defaults to: nil)

    carried on each SESSION_REVOKED event

Returns:



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'app/models/standard_id/session.rb', line 156

def self.revoke_sessions!(sessions, account: nil, reason: nil)
  sessions = sessions.to_a
  return RevocationResult.new(sessions_revoked: 0, refresh_tokens_revoked: 0) if sessions.empty?

  now = Time.current
  session_ids = sessions.map(&:id)
  refresh_tokens_revoked = 0

  ActiveRecord::Base.transaction do
    StandardId::Session.where(id: session_ids).update_all(revoked_at: now)
    refresh_tokens_revoked = StandardId::RefreshToken
      .where(session_id: session_ids, revoked_at: nil)
      .update_all(revoked_at: now)
  end

  publish_session_revocations(sessions, account: , reason: reason, now: now)

  RevocationResult.new(
    sessions_revoked: sessions.size,
    refresh_tokens_revoked: refresh_tokens_revoked
  )
end

Instance Method Details

#active?Boolean

Returns:

  • (Boolean)


220
221
222
# File 'app/models/standard_id/session.rb', line 220

def active?
  !revoked? && !expired?
end

#authenticate_token(token) ⇒ Boolean

Timing-safe verification of token against this session's stored digest.

Handles BOTH schemes, because digests written before the HMAC default — and any written since by an app that sets token_digest_cost — are BCrypt. A BCrypt digest is self-identifying by its $2<x>$ prefix, so the scheme is read off the stored value rather than off configuration; that way a config change never strands existing sessions, and no rewrite of stored digests is needed. Both branches end in a constant-time compare, so the response time carries no information about how much of the digest matched.

Returns:

  • (Boolean)

    false for a blank or malformed digest — never raises.



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'app/models/standard_id/session.rb', line 85

def authenticate_token(token)
  return false if token.blank? || token_digest.blank?

  if token_digest.start_with?("$2")
    stored = BCrypt::Password.new(token_digest)
    ActiveSupport::SecurityUtils.secure_compare(
      stored.to_s,
      BCrypt::Engine.hash_secret(token, stored.salt)
    )
  else
    ActiveSupport::SecurityUtils.secure_compare(
      token_digest, self.class.hmac_token_digest(token)
    )
  end
rescue BCrypt::Errors::InvalidHash, BCrypt::Errors::InvalidSalt
  false
end

#expired?Boolean

Returns:

  • (Boolean)


224
225
226
# File 'app/models/standard_id/session.rb', line 224

def expired?
  expires_at <= Time.current
end

#revoke!(reason: nil) ⇒ Object



232
233
234
235
236
237
238
239
240
# File 'app/models/standard_id/session.rb', line 232

def revoke!(reason: nil)
  @reason = reason
  transaction do
    update!(revoked_at: Time.current)
    # Cascade revocation to refresh tokens. Uses update_all for efficiency;
    # intentionally skips updated_at since revocation is tracked via revoked_at.
    refresh_tokens.active.update_all(revoked_at: Time.current)
  end
end

#revoked?Boolean

Returns:

  • (Boolean)


228
229
230
# File 'app/models/standard_id/session.rb', line 228

def revoked?
  revoked_at.present?
end