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 +4 -4
- data/app/controllers/concerns/standard_id/set_current_request_details.rb +33 -0
- data/app/controllers/standard_id/api/sessions_controller.rb +7 -3
- data/app/models/standard_id/refresh_token.rb +23 -27
- data/app/models/standard_id/session.rb +8 -1
- data/lib/standard_id/api/authentication_guard.rb +26 -18
- data/lib/standard_id/config/schema.rb +14 -0
- data/lib/standard_id/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1cc415579c94e298230fc9d647869d7c2ea2b422b1130eac38bd05bba262956a
|
|
4
|
+
data.tar.gz: 653d1cd02170d5ba6933973635cb50d62729be158b0037812da8b2f832fb0c0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
35
|
-
ip_address: session.
|
|
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.
|
|
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
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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,
|
data/lib/standard_id/version.rb
CHANGED