standard_id 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e4d6c462fcdc585b2eefb2c5224e92c7b2a9fe276c2a41ccbfd6bf7b1fee162
4
- data.tar.gz: 91dde301a0e98b0ec60f704ff5beb03706f5f1dbf36ebdfddfc3e21cf322f5ea
3
+ metadata.gz: 1cc415579c94e298230fc9d647869d7c2ea2b422b1130eac38bd05bba262956a
4
+ data.tar.gz: 653d1cd02170d5ba6933973635cb50d62729be158b0037812da8b2f832fb0c0e
5
5
  SHA512:
6
- metadata.gz: f01caba2626cf9e4c8f4950aeeece214277492d74701f6359268da79cdf4234853f86a5490d33ab250dfe2cdda04e73c9c86a2896dc7ff76e3ab36e529da3cce
7
- data.tar.gz: 519d061ab666d5d7a197819545bc9dcd5c304946074b80eaf89dca57898e7c24d6ccbde1b94155095e2ca382340b9179a62c2d2791331345d572ab826b88c8c7
6
+ metadata.gz: 16fa94e1dde5fa46e92999ef2fb24b5bb683ff5f5ab3e6549e91f8c738e4cdd42d8e872ab0d13705659ec6ab2bb4c4e184f4947c1d95bf5e00afbc3b9dfe0328
7
+ data.tar.gz: b730ef767275f8d71aa9b93a8c8fca55a1935c84e1e4e6eaf022ec935d2d152d7b72e99576b5075e3dffba8269b07be66abf43f4fd96e1f7728858d64244f7e2
@@ -4,6 +4,7 @@ module StandardId
4
4
 
5
5
  included do
6
6
  before_action :set_current_request_details
7
+ after_action :clear_rails_event_context
7
8
  end
8
9
 
9
10
  private
@@ -14,6 +15,38 @@ module StandardId
14
15
  ::Current.request_id = request.request_id if ::Current.respond_to?(:request_id=)
15
16
  ::Current.ip_address = StandardId::Utils::IpNormalizer.normalize(request.remote_ip) if ::Current.respond_to?(:ip_address=)
16
17
  ::Current.user_agent = request.user_agent if ::Current.respond_to?(:user_agent=)
18
+
19
+ set_rails_event_context
20
+ end
21
+
22
+ # Mirror request details into the Rails 8.1+ structured event reporter so
23
+ # that `Rails.event.notify` calls made during this request automatically
24
+ # carry request_id / ip_address / user_agent. Feature-detected: on older
25
+ # Rails versions this is a no-op. Reads straight from `::Current` — setters
26
+ # and getters on `ActiveSupport::CurrentAttributes` are paired, so the
27
+ # `respond_to?(:foo=)` checks above also guarantee the getter exists.
28
+ def set_rails_event_context
29
+ return unless defined?(::Current) && rails_event_available?
30
+
31
+ Rails.event.set_context(
32
+ request_id: (::Current.request_id if ::Current.respond_to?(:request_id)),
33
+ ip_address: (::Current.ip_address if ::Current.respond_to?(:ip_address)),
34
+ user_agent: (::Current.user_agent if ::Current.respond_to?(:user_agent))
35
+ )
36
+ end
37
+
38
+ # Rails 8.1 clears fiber-local state between requests via middleware, but
39
+ # thread-pooled servers (Puma, Falcon) can reuse the same fiber across
40
+ # requests. An explicit clear ensures a denied-upstream value cannot leak
41
+ # into the next request handled by the same worker.
42
+ def clear_rails_event_context
43
+ return unless rails_event_available?
44
+
45
+ Rails.event.clear_context
46
+ end
47
+
48
+ def rails_event_available?
49
+ Rails.respond_to?(:event) && Rails.event.respond_to?(:set_context)
17
50
  end
18
51
  end
19
52
  end
@@ -26,15 +26,19 @@ module StandardId
26
26
 
27
27
  private
28
28
 
29
+ # All Session subclasses live on the same STI table and therefore
30
+ # always respond to these columns — the prior `respond_to?` guards
31
+ # were defensive overhead that allocated per record. Direct access
32
+ # is both cheaper and clearer.
29
33
  def serialize_session(session)
30
34
  {
31
35
  id: session.id,
32
36
  type: session.type&.demodulize,
33
37
  created_at: session.created_at.iso8601,
34
- last_refreshed_at: session.respond_to?(:last_refreshed_at) ? session.last_refreshed_at&.iso8601 : nil,
35
- ip_address: session.respond_to?(:ip_address) ? session.ip_address : nil,
38
+ last_refreshed_at: session.last_refreshed_at&.iso8601,
39
+ ip_address: session.ip_address,
36
40
  # user_agent is the API-facing name for the device_agent model attribute
37
- user_agent: session.respond_to?(:device_agent) ? session.device_agent : nil
41
+ user_agent: session.device_agent
38
42
  }.compact
39
43
  end
40
44
  end
@@ -48,34 +48,30 @@ module StandardId
48
48
 
49
49
  private
50
50
 
51
- # Find the root of this token's family and return all descendants.
52
- # Backward traversal uses a visited set for cycle detection in case
53
- # of corrupted data. Forward traversal collects all descendants.
51
+ # Collect every token in this token's family (all ancestors + all
52
+ # descendants reachable via previous_token_id) in a single recursive
53
+ # CTE. Previously we walked the chain in Ruby with one query per
54
+ # generation — fine for small families, O(depth) under reuse-detection
55
+ # storms. The CTE is one round trip.
56
+ #
57
+ # `UNION` (not `UNION ALL`) deduplicates against the full accumulator
58
+ # at each step — so a row already emitted earlier in the traversal is
59
+ # skipped, preventing infinite loops on cyclic data. Supported by
60
+ # PostgreSQL, SQLite 3.8+, and MySQL 8+.
54
61
  def family_tokens
55
- root = self
56
- visited = Set.new([root.id])
57
- while root.previous_token.present?
58
- break if visited.include?(root.previous_token_id)
59
- visited.add(root.previous_token_id)
60
- root = root.previous_token
61
- end
62
-
63
- self.class.where(id: collect_family_ids(root.id))
64
- end
65
-
66
- def collect_family_ids(root_id)
67
- ids = [root_id]
68
- current_ids = [root_id]
69
-
70
- loop do
71
- next_ids = self.class.where(previous_token_id: current_ids).pluck(:id)
72
- break if next_ids.empty?
73
-
74
- ids.concat(next_ids)
75
- current_ids = next_ids
76
- end
77
-
78
- ids
62
+ table = self.class.quoted_table_name
63
+ sql = <<~SQL.squish
64
+ WITH RECURSIVE family AS (
65
+ SELECT id, previous_token_id FROM #{table} WHERE id = :id
66
+ UNION
67
+ SELECT rt.id, rt.previous_token_id
68
+ FROM #{table} rt
69
+ JOIN family f ON rt.id = f.previous_token_id OR rt.previous_token_id = f.id
70
+ )
71
+ SELECT id FROM family
72
+ SQL
73
+ family_ids = self.class.connection.select_values(self.class.sanitize_sql([sql, { id: id }]))
74
+ self.class.where(id: family_ids)
79
75
  end
80
76
  end
81
77
  end
@@ -53,7 +53,14 @@ module StandardId
53
53
  end
54
54
 
55
55
  def generate_token_digest
56
- self.token_digest = BCrypt::Password.create(token)
56
+ configured_cost = StandardId.config.session.token_digest_cost
57
+ self.token_digest =
58
+ if configured_cost.nil?
59
+ BCrypt::Password.create(token)
60
+ else
61
+ cost = configured_cost.clamp(BCrypt::Engine::MIN_COST, BCrypt::Engine::MAX_COST)
62
+ BCrypt::Password.create(token, cost: cost)
63
+ end
57
64
  end
58
65
 
59
66
  def generate_lookup_hash
@@ -8,14 +8,14 @@ module StandardId
8
8
  if api_session.blank?
9
9
  raise StandardId::NotAuthenticatedError, "Invalid or missing access token"
10
10
  elsif api_session.respond_to?(:expired?) && api_session.expired?
11
- emit_session_expired(api_session)
11
+ emit_session_expired(api_session, session_manager: session_manager)
12
12
  raise StandardId::ExpiredSessionError, "Session has expired"
13
13
  elsif api_session.respond_to?(:revoked?) && api_session.revoked?
14
14
  session_manager.clear_session!
15
15
  raise StandardId::RevokedSessionError, "Session has been revoked"
16
16
  end
17
17
 
18
- emit_session_validated(api_session)
18
+ emit_session_validated(api_session, session_manager: session_manager)
19
19
  api_session
20
20
  end
21
21
 
@@ -62,34 +62,42 @@ module StandardId
62
62
  )
63
63
  end
64
64
 
65
- def emit_session_validated(api_session)
66
- account = if api_session.respond_to?(:account)
67
- api_session.account
68
- elsif api_session.respond_to?(:account_id)
69
- StandardId.account_class.find_by(id: api_session.account_id)
70
- end
71
-
65
+ def emit_session_validated(api_session, session_manager: nil)
72
66
  StandardId::Events.publish(
73
67
  StandardId::Events::SESSION_VALIDATED,
74
68
  session: api_session,
75
- account: account
69
+ account: resolve_account(api_session, session_manager)
76
70
  )
77
71
  end
78
72
 
79
- def emit_session_expired(api_session)
80
- account = if api_session.respond_to?(:account)
81
- api_session.account
82
- elsif api_session.respond_to?(:account_id)
83
- StandardId.account_class.find_by(id: api_session.account_id)
84
- end
85
-
73
+ def emit_session_expired(api_session, session_manager: nil)
86
74
  StandardId::Events.publish(
87
75
  StandardId::Events::SESSION_EXPIRED,
88
76
  session: api_session,
89
- account: account,
77
+ account: resolve_account(api_session, session_manager),
90
78
  expired_at: api_session.respond_to?(:expires_at) ? api_session.expires_at : nil
91
79
  )
92
80
  end
81
+
82
+ # Prefer the session_manager's memoized #current_account (resolved once
83
+ # per request). Fall back to a direct lookup only when a session_manager
84
+ # isn't available (e.g. older call sites) or the session lacks an
85
+ # account_id — the cost is kept to a single query per request instead
86
+ # of one per event.
87
+ #
88
+ # NOTE on the fallback: callers that pass no session_manager still pay
89
+ # the pre-optimization cost — either one AR association load per event
90
+ # (via `api_session.account`) or one `find_by` query. The in-gem call
91
+ # site in `require_session!` always passes the session_manager, so the
92
+ # optimization fires for it. The fallback exists for any external
93
+ # caller that invokes emit_session_* directly without a session_manager.
94
+ def resolve_account(api_session, session_manager)
95
+ return session_manager.current_account if session_manager&.respond_to?(:current_account)
96
+ return api_session.account if api_session.respond_to?(:account)
97
+ return unless api_session.respond_to?(:account_id)
98
+
99
+ StandardId.account_class.find_by(id: api_session.account_id)
100
+ end
93
101
  end
94
102
  end
95
103
  end
@@ -159,6 +159,20 @@ StandardConfig.schema.draw do
159
159
  field :device_session_lifetime, type: :integer, default: 2592000 # 30 days in seconds
160
160
  field :service_session_lifetime, type: :integer, default: 7776000 # 90 days in seconds
161
161
 
162
+ # BCrypt cost factor for the session token digest. Default `nil` means
163
+ # use bcrypt-ruby's built-in default (cost 12 in production, MIN_COST
164
+ # in the test env). Since session tokens are 256-bit random
165
+ # (`SecureRandom.urlsafe_base64(32)`), any cost >= 10 is well beyond
166
+ # realistic brute-force, and dropping from 12 to 10 saves ~200ms of
167
+ # CPU per session creation. Host apps with many logins-per-second can
168
+ # set this to `10`; apps that value hash work over login latency can
169
+ # leave it alone or raise it.
170
+ #
171
+ # When set, value is clamped to BCrypt::Engine::MIN_COST..MAX_COST.
172
+ # Applies only to newly-created sessions; existing token_digests keep
173
+ # their original cost.
174
+ field :token_digest_cost, type: :integer, default: nil
175
+
162
176
  # Callable that resolves the session class to create for a given auth flow.
163
177
  # Receives keyword arguments (request:, account:, flow:) and must return one of:
164
178
  # StandardId::BrowserSession, StandardId::DeviceSession, StandardId::ServiceSession,
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.16.0"
2
+ VERSION = "0.16.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.0
4
+ version: 0.16.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim