Class: StandardId::Session

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

Direct Known Subclasses

BrowserSession, DeviceSession, ServiceSession

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.



100
101
102
# File 'app/models/standard_id/session.rb', line 100

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



38
39
40
41
42
43
44
45
# File 'app/models/standard_id/session.rb', line 38

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



64
65
66
67
68
# File 'app/models/standard_id/session.rb', line 64

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

Instance Method Details

#active?Boolean

Returns:

  • (Boolean)


105
106
107
# File 'app/models/standard_id/session.rb', line 105

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.



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

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)


109
110
111
# File 'app/models/standard_id/session.rb', line 109

def expired?
  expires_at <= Time.current
end

#revoke!(reason: nil) ⇒ Object



117
118
119
120
121
122
123
124
125
# File 'app/models/standard_id/session.rb', line 117

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)


113
114
115
# File 'app/models/standard_id/session.rb', line 113

def revoked?
  revoked_at.present?
end