standard_id 0.37.0 → 0.39.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 +4 -4
- data/CHANGELOG.md +42 -0
- data/lib/standard_id/config/schema.rb +35 -0
- data/lib/standard_id/events/definitions.rb +2 -0
- data/lib/standard_id/events.rb +59 -1
- data/lib/standard_id/oauth/refresh_token_flow.rb +100 -4
- 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: aafcc86281b43717bf4afeae67e469f9f773f62bb674137adce6b7924eb24be2
|
|
4
|
+
data.tar.gz: 1236e399e761f545b7c2c749d44bd317f48355650b1f76accbf22704940f00d5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0cbb9b1a8fd120918f0dbf986822b70990105c6e288ede4b8f2fc790b82cfa2f5ded7cbc774ae4e18afc6e03b29f40e93ffc736d8fd7021ce2ae81f10b03a59c
|
|
7
|
+
data.tar.gz: dcbdbea82b00733fb9de47a2ba1b4f44362f819ee27ccac2bbb3b1c10573a225afac8d98c50644648ca9756d5a15ecc4262448ff899632f109e8510f2edf5fcd
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.39.0] - 2026-08-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **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.
|
|
15
|
+
|
|
16
|
+
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.
|
|
17
|
+
|
|
18
|
+
`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).
|
|
19
|
+
|
|
20
|
+
- **`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.
|
|
21
|
+
|
|
22
|
+
### Notes for hosts
|
|
23
|
+
|
|
24
|
+
- **Default-off means this release is inert on upgrade for every consumer.** Existing reuse-detection specs pass unchanged. A host opts in explicitly.
|
|
25
|
+
|
|
26
|
+
- **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.
|
|
27
|
+
|
|
28
|
+
- **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.
|
|
29
|
+
|
|
30
|
+
## [0.38.0] - 2026-08-06
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **Event payloads now carry record identifiers alongside the records — the emitter half of rarebit-one/rarebit-ops#296.** This gem publishes whole ActiveRecord objects on the notification bus (`account:`, `current_account:`, `session:`, `code_challenge:`), and a record serialises with **all** its attributes. That is how `account.password_digest`, `session.token_digest`, `session.lookup_hash` and the plaintext passwordless OTP in `code_challenge.code` reached append-only audit rows across the estate.
|
|
35
|
+
|
|
36
|
+
`standard_audit` 0.11.0 closed its own write path with `dereference_record_metadata`, but it could only ever protect `audit_logs`. Every host subscriber receives the same payload and may put the record into Sentry context, a structured log, or a table of its own — all outside that gem's reach. Curating at the emitter is the only fix that closes the class.
|
|
37
|
+
|
|
38
|
+
`config.events.publish_record_identifiers` (default **`true`**) adds `<key>_id` and `<key>_gid` beside every record-valued key and **removes nothing**.
|
|
39
|
+
|
|
40
|
+
- **`config.events.publish_records` (default `true`) is the opt-out** for hosts ready to stop receiving the records themselves.
|
|
41
|
+
|
|
42
|
+
### Notes for hosts
|
|
43
|
+
|
|
44
|
+
- **This release is additive on purpose — no consumer changes behaviour today.** All five consumers configure an `actor_extractor` reading `payload[:actor] || payload[:current_account] || payload[:account]` and calling `to_global_id` on the result, so swapping records for identifiers in one step would break actor attribution in five apps simultaneously.
|
|
45
|
+
|
|
46
|
+
The migration path is per-host and provable: point your subscribers at the new `_gid` keys, confirm in staging that nothing depends on the records, then set `publish_records` to `false`. The intended end state is for that to default to `false` in a later major, and for the records to go away in the one after.
|
|
47
|
+
|
|
48
|
+
- **Both `_id` and `_gid` are published.** A GlobalID carries the class, and `account_id` alone cannot tell an `Account` from anything else sharing a primary key. The audit gems key on the GID.
|
|
49
|
+
|
|
50
|
+
- **`to_global_id` is rescued.** It raises on an unpersisted record, and publishing sits on the authentication path — an event must never fail because of metadata this method adds to it.
|
|
51
|
+
|
|
10
52
|
## [0.37.0] - 2026-08-05
|
|
11
53
|
|
|
12
54
|
### Fixed
|
|
@@ -108,6 +108,34 @@ StandardId::ConfigSchema.define do
|
|
|
108
108
|
|
|
109
109
|
scope :events do
|
|
110
110
|
field :enable_logging, type: :boolean, default: false
|
|
111
|
+
|
|
112
|
+
# Publish `<key>_id` / `<key>_gid` alongside every record-valued payload key
|
|
113
|
+
# (rarebit-one/rarebit-ops#296).
|
|
114
|
+
#
|
|
115
|
+
# StandardId publishes whole ActiveRecord objects on the notification bus —
|
|
116
|
+
# `account:`, `current_account:`, `session:`, `code_challenge:` — and a
|
|
117
|
+
# record serialises with ALL its attributes. `standard_audit` 0.11.0 stopped
|
|
118
|
+
# that reaching `audit_logs`, but the bus is not the audit gem's to police:
|
|
119
|
+
# a host's own subscribers still receive the full record and may put it in
|
|
120
|
+
# Sentry context, a structured log, or a table of their own.
|
|
121
|
+
#
|
|
122
|
+
# On by default because it only ADDS keys. Nothing that reads `account:`
|
|
123
|
+
# today changes behaviour — including every consumer's `actor_extractor`,
|
|
124
|
+
# which reads the record to call `to_global_id` on it.
|
|
125
|
+
field :publish_record_identifiers, type: :boolean, default: true
|
|
126
|
+
|
|
127
|
+
# The other half of the migration, and deliberately OFF by default.
|
|
128
|
+
#
|
|
129
|
+
# Turning it off stops the records themselves being published, leaving only
|
|
130
|
+
# the identifiers. That IS a breaking payload change — it is the whole point
|
|
131
|
+
# — so it is a host's opt-in, taken once its subscribers read the `_id`/`_gid`
|
|
132
|
+
# keys instead. A host can flip it in staging to prove nothing depends on
|
|
133
|
+
# the records before committing.
|
|
134
|
+
#
|
|
135
|
+
# The intended end state is this defaulting to false in a later major, and
|
|
136
|
+
# the records going away in the one after. Until a host opts in, behaviour
|
|
137
|
+
# is exactly as it was.
|
|
138
|
+
field :publish_records, type: :boolean, default: true
|
|
111
139
|
end
|
|
112
140
|
|
|
113
141
|
scope :passwordless do
|
|
@@ -257,6 +285,13 @@ StandardId::ConfigSchema.define do
|
|
|
257
285
|
scope :oauth do
|
|
258
286
|
field :default_token_lifetime, type: :integer, default: 3600 # 1 hour in seconds
|
|
259
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
|
|
260
295
|
field :token_lifetimes, type: :hash, default: -> { {} }
|
|
261
296
|
field :client_id, type: :string, default: nil
|
|
262
297
|
field :client_secret, type: :string, default: nil
|
|
@@ -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
|
|
data/lib/standard_id/events.rb
CHANGED
|
@@ -132,7 +132,65 @@ module StandardId
|
|
|
132
132
|
enriched[:scope] ||= ::Current.scope if ::Current.respond_to?(:scope) && ::Current.scope.present?
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
-
enriched.merge(payload)
|
|
135
|
+
with_record_identifiers(enriched.merge(payload))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Adds `<key>_id` and `<key>_gid` next to every record-valued key, and —
|
|
139
|
+
# only when a host opts in — removes the records themselves.
|
|
140
|
+
#
|
|
141
|
+
# WHY (rarebit-one/rarebit-ops#296)
|
|
142
|
+
# ---------------------------------
|
|
143
|
+
# This gem publishes whole ActiveRecord objects: `account:`,
|
|
144
|
+
# `current_account:`, `session:`, `code_challenge:`. A record serialises
|
|
145
|
+
# with ALL its attributes, which is how `account.password_digest`,
|
|
146
|
+
# `session.token_digest`, `session.lookup_hash` and the plaintext
|
|
147
|
+
# passwordless OTP in `code_challenge.code` reached append-only audit rows
|
|
148
|
+
# — 3,007 of them on one app, unrepairable because the table refuses
|
|
149
|
+
# UPDATE by design.
|
|
150
|
+
#
|
|
151
|
+
# `standard_audit` 0.11.0 fixed its own write path. It cannot fix the bus:
|
|
152
|
+
# every host subscriber still receives the full record and may put it
|
|
153
|
+
# anywhere. Publishing the identifier alongside is what lets a subscriber
|
|
154
|
+
# stop needing the record at all.
|
|
155
|
+
#
|
|
156
|
+
# WHY ADDITIVE FIRST
|
|
157
|
+
# ------------------
|
|
158
|
+
# Swapping records for identifiers in one step breaks every consumer's
|
|
159
|
+
# `actor_extractor` simultaneously — all five apps configure one, and they
|
|
160
|
+
# read `payload[:actor] || payload[:current_account] || payload[:account]`
|
|
161
|
+
# expecting something they can call `to_global_id` on. So the default adds
|
|
162
|
+
# keys and removes nothing; `config.events.publish_records = false` is the
|
|
163
|
+
# opt-out a host takes once its subscribers read the identifiers.
|
|
164
|
+
#
|
|
165
|
+
# `_gid` as well as `_id` because a GlobalID carries the class, and the
|
|
166
|
+
# audit gems key on it. `to_global_id` is rescued: a new or unpersisted
|
|
167
|
+
# record raises, and an event must never fail because of the metadata this
|
|
168
|
+
# method adds to it.
|
|
169
|
+
def with_record_identifiers(payload)
|
|
170
|
+
return payload unless defined?(::ActiveRecord::Base)
|
|
171
|
+
|
|
172
|
+
config = StandardId.config.events
|
|
173
|
+
return payload unless config.publish_record_identifiers || !config.publish_records
|
|
174
|
+
|
|
175
|
+
payload.each_with_object({}) do |(key, value), out|
|
|
176
|
+
unless value.is_a?(::ActiveRecord::Base)
|
|
177
|
+
out[key] = value
|
|
178
|
+
next
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
out[key] = value if config.publish_records
|
|
182
|
+
|
|
183
|
+
next unless config.publish_record_identifiers
|
|
184
|
+
|
|
185
|
+
out[:"#{key}_id"] ||= value.id
|
|
186
|
+
out[:"#{key}_gid"] ||= safe_global_id(value)
|
|
187
|
+
end.compact
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def safe_global_id(record)
|
|
191
|
+
record.to_global_id.to_s
|
|
192
|
+
rescue StandardError
|
|
193
|
+
nil
|
|
136
194
|
end
|
|
137
195
|
end
|
|
138
196
|
end
|
|
@@ -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
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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,
|
data/lib/standard_id/version.rb
CHANGED