standard_id 0.38.0 → 0.40.0

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: ea8084bb866fc4da82d81ec18d2b24676e3209ac8d21975c20deb9aa334160fc
4
- data.tar.gz: 1cc922486ca4204c8308ff489dc5f425424c06e758a1a4b77276b6670a637f10
3
+ metadata.gz: 89d297a5e9c69566991b97cca501fd5a714b2d2e1200d2e740b3e45684981aab
4
+ data.tar.gz: d93fc7206735373f14ac2cd51c4612aaf7b1d19244022d884f0c84757d8f78c6
5
5
  SHA512:
6
- metadata.gz: 3e030d75f3f08d7961ac1cab6ede04da0940ee5471191bfc5c06ef832469f6da2a9af44539fa04e889801defded08c8bdf138c7a8bd1661d8c0628c5b707d08f
7
- data.tar.gz: b5301f0db610433bcb542699f95beddcd9e91e93dda098f940edafc9af5c70ab264ef6c70b427e84ac34a9edae861316cf3d608f4f5c2dad6996eb6b9780cbcf
6
+ metadata.gz: 1ce536a136af167e65ec9d01e370a187d71f77a2f60f0c5af47ee5c23a9b58cb87444b95191a019fa06689902765f51eaff20656aeedcdef288e34f577c99c91
7
+ data.tar.gz: f825db9970c937a76592c421fb71a9607f3ea0667dfafa105ff2d6ce78bbd754f5ca01f873cf3321962876c3ab0f8c7f7314ec95604cf5900d56291cede6ec57
data/CHANGELOG.md CHANGED
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.40.0] - 2026-09-05
11
+
12
+ ### Fixed
13
+
14
+ - **`sign_in_account` now supersedes a nil memoised earlier in the same request.** With the `session_resolved` / `account_resolved` memo introduced in the entry below, a guard or shared prop that read `current_account` before the sign-in action ran would have kept returning nil for the rest of that request. Sign-in now memoises the new session and resets the account memo, so the next `current_account` re-derives the account through `load_current_account` — `config.account_scope` and `strict_loading!(false)` apply exactly as on an ordinary authenticated request (caught in review of #326 and #327).
15
+
16
+ - **An anonymous request no longer queries the session table on every `current_account` call, and no longer writes an empty Rails session.** `Web::SessionManager#current_session` memoised with `Current.session ||= …`, which never memoises a nil answer, so a visitor with no session paid the `BrowserSession.by_token(nil)` lookup on every read — shared props, guards, locale selection, nav helpers — five or six times per page. On one consumer's marketing homepage that was ~6 session-table queries per anonymous request and the app's single largest query by total time (fundbright/delivery-ops#598). Worse, the "no session" branch called `clear_session!`, whose `session.delete` loads the Rack session, and Rack persists every loaded session: a new empty session row and a `Set-Cookie` on every anonymous response, which is what stops a CDN from caching a public page.
17
+
18
+ Two new `Current` attributes, `session_resolved` and `account_resolved`, record that the question was answered (nil included) for the rest of the request. `load_session_from_session_token` returns without a query when there is no token, and only falls back to `session[:session_token]` when the Rails session is already loaded or its cookie is actually present on the request (`request.session_options[:key]`) — so a bare GET never allocates one. `load_session_from_remember_token` returns early without a `remember_token` cookie. `clear_session!` only touches the Rails session when it is loaded, and the no-session branch only clears at all when there is stale state to clear (a `session_token`/`remember_token` cookie, or session keys). Hosts that include `StandardId::CurrentAttributes` get the new attributes automatically; a host that hand-rolls `Current` must add them.
19
+
20
+ ## [0.39.0] - 2026-08-10
21
+
22
+ ### Added
23
+
24
+ - **A refresh whose RESPONSE never arrived no longer costs the user their session.** Rotation assumes the client receives the new token. When it does not — a timeout, a dead radio, the process dying between our `COMMIT` and the client's write — the server has rotated and the client still holds the previous token. Its next refresh presents an already-rotated token, which is indistinguishable from an attacker replaying a stolen one.
25
+
26
+ Treating both as an attack costs a healthy session, and the cost is total: `revoke_family!` also kills the **successor the client never received**, so the session cannot be recovered by retrying — every attempt re-presents the same dead token against an already-revoked family. `spec/lib/standard_id/refresh_rotation_lost_response_spec.rb` pins that behaviour before the fix, because it is the part that surprises.
27
+
28
+ `config.oauth.refresh_token_reuse_leeway` (default **`0` — off**) allows a replayed token to rotate from its successor instead, but ONLY while that successor is untouched: it must still be active and never itself have been rotated. A **used** successor proves the legitimate client received it, so anything presented afterwards is a genuine replay and the family dies exactly as before. The value is clamped to `RefreshTokenFlow::MAX_REUSE_LEEWAY_SECONDS` (120).
29
+
30
+ - **`OAUTH_REFRESH_TOKEN_REUSE_GRACED`** is published when the leeway fires. Graced replays are the quiet half of the feature: without an event, the only evidence the leeway is load-bearing is an absence of complaints.
31
+
32
+ ### Notes for hosts
33
+
34
+ - **Default-off means this release is inert on upgrade for every consumer.** Existing reuse-detection specs pass unchanged. A host opts in explicitly.
35
+
36
+ - **Enabling it is a deliberate security narrowing, and worth understanding before you do.** It does not claim to distinguish an attacker from an unlucky client — the server cannot. It bounds the damage. An attacker replaying inside the window gets a session, but the real client still holds the successor, and the moment it refreshes, that token is revoked-and-reused: the family dies and the user re-authenticates. Exposure is one refresh interval, against the current guarantee that the *honest* client loses its session.
37
+
38
+ - **Re-delivering the successor — the ideal, idempotent answer — is not possible here.** Only its digest is stored, by design, so the token string is unrecoverable. Rotating from it is the closest safe equivalent; the unused successor is retired rather than left live.
39
+
10
40
  ## [0.38.0] - 2026-08-06
11
41
 
12
42
  ### Added
@@ -285,6 +285,13 @@ StandardId::ConfigSchema.define do
285
285
  scope :oauth do
286
286
  field :default_token_lifetime, type: :integer, default: 3600 # 1 hour in seconds
287
287
  field :refresh_token_lifetime, type: :integer, default: 2592000 # 30 days in seconds
288
+ # Seconds for which a just-rotated refresh token is still honoured, so a
289
+ # client that never RECEIVED its successor (dropped response, killed process)
290
+ # can retry instead of losing the session to reuse detection. 0 = off, which
291
+ # is the default: this trades a bounded replay window for resilience and no
292
+ # host should get it by upgrading. Clamped to
293
+ # RefreshTokenFlow::MAX_REUSE_LEEWAY_SECONDS.
294
+ field :refresh_token_reuse_leeway, type: :integer, default: 0
288
295
  field :token_lifetimes, type: :hash, default: -> { {} }
289
296
  field :client_id, type: :string, default: nil
290
297
  field :client_secret, type: :string, default: nil
@@ -4,6 +4,10 @@ module StandardId
4
4
 
5
5
  included do
6
6
  attribute :session, :account, :request_id, :ip_address, :user_agent, :scope
7
+ # Set once Web::SessionManager has answered "which session / account is
8
+ # this request?" — including when the answer is nil — so the lookup is
9
+ # never repeated within a request. Reset with the rest of Current.
10
+ attribute :session_resolved, :account_resolved
7
11
  end
8
12
  end
9
13
  end
@@ -41,6 +41,7 @@ module StandardId
41
41
  OAUTH_CODE_CONSUMED = "oauth.code.consumed"
42
42
  OAUTH_TOKEN_REVOKED = "oauth.token.revoked"
43
43
  OAUTH_REFRESH_TOKEN_REUSE_DETECTED = "oauth.refresh_token.reuse_detected"
44
+ OAUTH_REFRESH_TOKEN_REUSE_GRACED = "oauth.refresh_token.reuse_graced"
44
45
  OAUTH_AUDIENCE_MISMATCH = "oauth.audience.mismatch"
45
46
 
46
47
  PASSWORDLESS_CODE_REQUESTED = "passwordless.code.requested"
@@ -116,6 +117,7 @@ module StandardId
116
117
  OAUTH_CODE_CONSUMED,
117
118
  OAUTH_TOKEN_REVOKED,
118
119
  OAUTH_REFRESH_TOKEN_REUSE_DETECTED,
120
+ OAUTH_REFRESH_TOKEN_REUSE_GRACED,
119
121
  OAUTH_AUDIENCE_MISMATCH
120
122
  ].freeze
121
123
 
@@ -1,6 +1,11 @@
1
1
  module StandardId
2
2
  module Oauth
3
3
  class RefreshTokenFlow < TokenGrantFlow
4
+ # Ceiling on `oauth.refresh_token_reuse_leeway`. A long leeway is a long
5
+ # window in which a stolen token still works, so the setting is clamped
6
+ # rather than trusted: this is a resilience allowance, not a lifetime.
7
+ MAX_REUSE_LEEWAY_SECONDS = 120
8
+
4
9
  expect_params :refresh_token, :client_id
5
10
  permit_params :client_secret, :scope, :audience
6
11
 
@@ -58,10 +63,30 @@ module StandardId
58
63
  end
59
64
 
60
65
  if @current_refresh_token_record.revoked?
61
- # Reuse detected: this token was already rotated. Revoke entire family.
62
- @current_refresh_token_record.revoke_family!
63
- emit_reuse_detected_event
64
- raise StandardId::InvalidGrantError, "Refresh token reuse detected"
66
+ # A rotated token can be presented for two reasons the server cannot tell
67
+ # apart: an attacker replaying a stolen one, or an honest client that
68
+ # never RECEIVED the successor — a timeout, a dead radio, the process
69
+ # dying between our COMMIT and the client's write. Rotation assumes
70
+ # delivery; nothing guarantees it.
71
+ #
72
+ # Treating both as an attack costs a healthy session: revoke_family!
73
+ # also kills the successor the client never saw, so a single dropped
74
+ # response becomes a forced re-login that no client-side retry or
75
+ # failure-budget can recover from (see
76
+ # spec/lib/standard_id/refresh_rotation_lost_response_spec.rb).
77
+ #
78
+ # So, within a short leeway, rotate from the successor instead — but
79
+ # ONLY while that successor is untouched (see #graced_successor_for).
80
+ # Disabled by default: a host must opt in.
81
+ if (successor = graced_successor_for(@current_refresh_token_record))
82
+ emit_reuse_graced_event(@current_refresh_token_record, successor)
83
+ @current_refresh_token_record = successor
84
+ else
85
+ # Reuse detected: this token was already rotated. Revoke entire family.
86
+ @current_refresh_token_record.revoke_family!
87
+ emit_reuse_detected_event
88
+ raise StandardId::InvalidGrantError, "Refresh token reuse detected"
89
+ end
65
90
  end
66
91
 
67
92
  unless @current_refresh_token_record.active?
@@ -151,6 +176,77 @@ module StandardId
151
176
  raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
152
177
  end
153
178
 
179
+ # The successor to a revoked token, when re-issuing from it is safer than
180
+ # revoking the family — otherwise nil, and reuse detection proceeds unchanged.
181
+ #
182
+ # Every condition here narrows the window in which a replayed token is
183
+ # honoured:
184
+ #
185
+ # - **Opt-in.** Zero (the default) disables this entirely, so no existing
186
+ # host changes behaviour by upgrading.
187
+ # - **Recently rotated.** Outside the leeway a replay is not a plausible
188
+ # in-flight retry.
189
+ # - **Successor still active, and never itself rotated.** This is the load-
190
+ # bearing one. If the successor has been USED, the legitimate client
191
+ # demonstrably received it, so the token being replayed now is a replay,
192
+ # not a lost response — and the family dies as before.
193
+ #
194
+ # What this deliberately does NOT do is claim to distinguish an attacker
195
+ # from an unlucky client; the server cannot. It bounds the damage instead.
196
+ # An attacker replaying inside the window gets a session, but the real
197
+ # client still holds the successor, and the moment it refreshes, that token
198
+ # is revoked-and-reused: the family dies and the user re-authenticates.
199
+ # The exposure is therefore one refresh interval, not indefinite — versus
200
+ # today, where the honest client is guaranteed to lose its session.
201
+ #
202
+ # The successor cannot simply be RE-DELIVERED, which would be the ideal
203
+ # (idempotent) answer: only its digest is stored, by design, so its token
204
+ # string is unrecoverable. Rotating from it is the closest safe equivalent.
205
+ def graced_successor_for(revoked_record)
206
+ leeway = reuse_leeway_seconds
207
+ return nil unless leeway.positive?
208
+ return nil if revoked_record.revoked_at.blank?
209
+ return nil if revoked_record.revoked_at < leeway.seconds.ago
210
+
211
+ successor = StandardId::RefreshToken.find_by(previous_token_id: revoked_record.id)
212
+ return nil unless successor&.active?
213
+ return nil if StandardId::RefreshToken.exists?(previous_token_id: successor.id)
214
+
215
+ successor
216
+ end
217
+
218
+ # Seconds for which a just-rotated token is still honoured. Absent or
219
+ # non-positive config means OFF — reuse detection behaves exactly as it did
220
+ # before this existed.
221
+ def reuse_leeway_seconds
222
+ config = StandardId.config.oauth
223
+ return 0 unless config.respond_to?(:refresh_token_reuse_leeway)
224
+
225
+ value = config.refresh_token_reuse_leeway
226
+ seconds =
227
+ case value
228
+ when ActiveSupport::Duration then value.to_i
229
+ when Numeric, String then value.to_i
230
+ else 0
231
+ end
232
+ return 0 unless seconds.positive?
233
+
234
+ [seconds, MAX_REUSE_LEEWAY_SECONDS].min
235
+ end
236
+
237
+ # Graced replays are the quiet half of this feature: nothing fails, so
238
+ # without an event the only evidence a host has that the leeway is load-
239
+ # bearing (or mis-tuned) is its absence of complaints.
240
+ def emit_reuse_graced_event(replayed, successor)
241
+ StandardId::Events.publish(
242
+ StandardId::Events::OAUTH_REFRESH_TOKEN_REUSE_GRACED,
243
+ account_id: @refresh_payload[:sub],
244
+ client_id: @refresh_payload[:client_id],
245
+ refresh_token_id: replayed.id,
246
+ successor_refresh_token_id: successor.id
247
+ )
248
+ end
249
+
154
250
  def emit_reuse_detected_event
155
251
  StandardId::Events.publish(
156
252
  StandardId::Events::OAUTH_REFRESH_TOKEN_REUSE_DETECTED,
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.38.0"
2
+ VERSION = "0.40.0"
3
3
  end
@@ -11,12 +11,25 @@ module StandardId
11
11
  @reset_session = reset_session
12
12
  end
13
13
 
14
+ # Both readers memoise their answer for the rest of the request — including
15
+ # a nil answer. `Current.session ||= …` never did: an anonymous visitor has
16
+ # no session, so every `current_account` call (shared props, before_actions,
17
+ # locale selection, nav helpers — five or six per page) re-ran the lookup.
18
+ # On one consumer's marketing homepage that was ~6 session-table queries
19
+ # per anonymous request, the app's single largest query by total time
20
+ # (fundbright/delivery-ops#598).
14
21
  def current_session
15
- Current.session ||= load_current_session
22
+ return Current.session if Current.session_resolved
23
+
24
+ Current.session_resolved = true
25
+ load_current_session
16
26
  end
17
27
 
18
28
  def current_account
19
- Current.account ||= load_current_account
29
+ return Current.account if Current.account_resolved
30
+
31
+ Current.account_resolved = true
32
+ Current.account = load_current_account
20
33
  end
21
34
 
22
35
  def sign_in_account(account, scope_name: nil)
@@ -41,7 +54,18 @@ module StandardId
41
54
  scopes << scope_name.to_s unless scopes.include?(scope_name.to_s)
42
55
  session[:standard_id_scopes] = scopes
43
56
  end
57
+ # Sign-in supersedes whatever this request already resolved: a guard
58
+ # or shared prop that asked `current_account` before the sign-in
59
+ # action ran memoised nil, and that memo must not outlive the sign-in.
60
+ # The session is known here, so memoise it; the account is NOT
61
+ # assigned directly — the memo is reset so the next `current_account`
62
+ # re-derives it through load_current_account, which applies
63
+ # `config.account_scope` and `strict_loading!(false)` exactly as an
64
+ # ordinary authenticated request would (review of #327).
44
65
  Current.session = browser_session
66
+ Current.session_resolved = true
67
+ Current.account = nil
68
+ Current.account_resolved = false
45
69
  emit_session_created(browser_session, account, "browser")
46
70
  end
47
71
  end
@@ -61,8 +85,18 @@ module StandardId
61
85
 
62
86
  def clear_session!
63
87
  # TODO: make token key names configurable
64
- session.delete(:session_token)
65
- session.delete(:standard_id_scopes)
88
+ #
89
+ # Only touch the Rails session when it is already loaded. Reading or
90
+ # deleting a key on an unloaded Rack session loads it, and Rack commits
91
+ # every loaded session on the way out — so on an anonymous request this
92
+ # used to write a brand-new empty session to the store and stamp a
93
+ # session cookie on the response, which is what stops any CDN from
94
+ # caching a public page. If the session was never loaded there is nothing
95
+ # in it to clear.
96
+ if rails_session_loaded?
97
+ session.delete(:session_token)
98
+ session.delete(:standard_id_scopes)
99
+ end
66
100
  # Delete the cookie outright. Assigning `cookies.encrypted[:session_token] = nil`
67
101
  # writes a fresh encrypted blob through the jar's default options (no httponly)
68
102
  # on every unauthenticated request, leaving a confusing non-HttpOnly
@@ -72,10 +106,32 @@ module StandardId
72
106
  cookies.delete(:remember_token)
73
107
 
74
108
  Current.session = nil
109
+ Current.account = nil
110
+ Current.session_resolved = true
111
+ Current.account_resolved = true
75
112
  end
76
113
 
77
114
  private
78
115
 
116
+ # A Rack session reports `loaded?`; the plain Hash the specs (and some
117
+ # hosts' test doubles) hand in does not, and a Hash is always "loaded".
118
+ def rails_session_loaded?
119
+ !session.respond_to?(:loaded?) || session.loaded?
120
+ end
121
+
122
+ # Whether the request carries the host app's Rails session cookie at all.
123
+ # Reading `session[...]` when it does not would make Rack allocate and
124
+ # then persist an empty session (see clear_session!). Without a request
125
+ # that can answer, assume it does — the old behaviour.
126
+ def rails_session_cookie_present?
127
+ return true unless request.respond_to?(:session_options) && request.respond_to?(:cookies)
128
+
129
+ key = request.session_options[:key]
130
+ return true if key.blank?
131
+
132
+ request.cookies.key?(key.to_s)
133
+ end
134
+
79
135
  # Persist the session token in an encrypted cookie whose lifetime matches
80
136
  # the DB session's expires_at, so an authenticated session survives a full
81
137
  # browser restart (a bare session cookie would be cleared on browser close,
@@ -117,19 +173,32 @@ module StandardId
117
173
  clear_session!
118
174
  end
119
175
  else
120
- clear_session!
176
+ # Nothing identified a session. Clear stale state only when there is
177
+ # some — a bare anonymous GET has no cookies to delete and no session
178
+ # to touch, and touching it is what makes the response uncacheable.
179
+ clear_session! if stale_session_state?
121
180
  end
122
181
 
123
182
  Current.session
124
183
  end
125
184
 
185
+ def stale_session_state?
186
+ cookies.encrypted[:session_token].present? || cookies[:session_token].present? || cookies[:remember_token].present? ||
187
+ (rails_session_loaded? && (session[:session_token].present? || session[:standard_id_scopes].present?))
188
+ end
189
+
126
190
  def load_session_from_session_token
127
191
  # Try encrypted cookie first (for Action Cable), then fall back to session (for backward compatibility)
128
- session_token = cookies.encrypted[:session_token] || session[:session_token]
192
+ session_token = cookies.encrypted[:session_token]
193
+ session_token ||= session[:session_token] if rails_session_loaded? || rails_session_cookie_present?
194
+ return if session_token.blank?
195
+
129
196
  StandardId::BrowserSession.eager_load(:account).by_token(session_token).first
130
197
  end
131
198
 
132
199
  def load_session_from_remember_token
200
+ return if cookies[:remember_token].blank?
201
+
133
202
  password_credential = StandardId::PasswordCredential.find_by_token_for(:remember_me, cookies[:remember_token])
134
203
  return if password_credential.blank?
135
204
 
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.38.0
4
+ version: 0.40.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim