standard_id 0.41.0 → 0.41.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: 8cbe67c8d05fe6c7daeeb4d4a44562507e1d5104c32eeba94b701e98e6afe3ea
4
- data.tar.gz: 8f9cb74cf37bb063c66547c7f563005f870a5a86509fe8f89b0bb57893f0ed82
3
+ metadata.gz: eda13bb222bf26a20444378b4fe6d0e7661f86a0058388986fb2710e5f01802d
4
+ data.tar.gz: 96835ff10cd72cc01c8654ddf86d5cb44555079eafad8f521b8c62061a63dde9
5
5
  SHA512:
6
- metadata.gz: e8110399efd07b7b0455f2b8a5ee2c93acfb7dd9bb3847c1a7b43a78aec874b1709aebc05d7be37877007b81f19946322496745793da0418757ecd3de0349121
7
- data.tar.gz: '08736fdbf31a259270c02c232f3a8e163c1dc80ca7f5fd3a4dba2168adb812aa91307bf662b7766bc0078b532bf549be64998342cf90f4e5c54048d31f2becd8'
6
+ metadata.gz: 68698115969ba88727515927a9afaf0d1f14245b215abed59220afbbacb0533667715688fa395df46013c32c384d854e44cc58bb760e83c7f2c1dcac6063bed0
7
+ data.tar.gz: 48918bda97f0345b8b462dde9fbf4c77b6bff7851764fbee998f8af6d779caa8efc6f7e8f68f0971c33c1d8110af8effe69c172a82ad96c068d41b8a38495538
data/CHANGELOG.md CHANGED
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.41.1] - 2026-09-24
11
+
12
+ Bug-fix release. Four of these were found in sidekick-web, which has been carrying host-side prepend patches for three of them; the dummy app now runs with `strict_loading_by_default` so this class of bug fails in the gem's own suite first.
13
+
14
+ ### Upgrade
15
+
16
+ Read this before bumping — the order matters if you have not yet deployed 0.41.0's migration:
17
+
18
+ 1. **Deploy 0.41.1 first, WITHOUT running `20260915000000_remove_refresh_token_lifetime_from_standard_id_client_applications`.** 0.41.1 ignores the column (see Fixed), so running processes stop reading and writing it.
19
+ 2. **Run `20260915000000` in a later deploy.** With strong_migrations it still needs wrapping: `safety_assured { remove_column ... }` inside the copied migration's `up` — the column is already ignored, which is the condition strong_migrations asks you to confirm. Hosts that already ran it under 0.41.0 have nothing to do here.
20
+ 3. **Install and run the new `20260924000000_add_unique_active_device_index_to_standard_id_sessions`** (`bin/rails standard_id:install:migrations`). It is idempotent, builds CONCURRENTLY on Postgres, and needs no `safety_assured` in the host: the one raw-SQL step (detaching duplicate rows) asserts itself safe to StrongMigrations when that gem is loaded. It detaches any existing duplicate active device rows before building the index (see Fixed); nothing is revoked.
21
+ 4. **Hosts carrying the sidekick-web prepend patches can delete them:** `config/initializers/standard_id_refresh_token_strict_loading.rb`, `standard_id_rotation_leeway.rb` and `standard_id_device_session_upsert.rb` (and their specs). The upsert patch's signature guard will keep passing, so it will not remind you — remove it deliberately.
22
+
23
+ ### Fixed
24
+
25
+ - **Signing in again after signing out no longer resurrects the revoked device session.** `OauthSessionPersistence.upsert_device_session!` looked the device's row up with a bare `find_by(account:, device_id:)`. Sign-out (`/oauth/revoke` under the default `:account` `revocation_scope`) revokes every active `DeviceSession`, so the next sign-in reused the revoked row, every refresh token minted afterwards pointed at a revoked parent, and `RefreshTokenFlow#validate_parent_session!` refused the very first refresh. The client was sent back to sign-in each time its access token expired, on every device, forever — sidekick-web's companion app, where the audit trail showed dozens of sign-ins a day and not one successful refresh.
26
+
27
+ Only rows with `revoked_at: nil` are now reused (newest first); a revoked row stays as history and the sign-in gets a new one. Expiry is deliberately still not part of eligibility — an expired-but-unrevoked row is reused with its `expires_at` bumped, as before. The insert runs inside a savepoint, and on `ActiveRecord::RecordNotUnique` the row that won a concurrent first sign-in is reused rather than failing the token request.
28
+
29
+ **New migration `20260924000000`**: a partial unique index on `standard_id_sessions (account_id, device_id) WHERE revoked_at IS NULL AND device_id IS NOT NULL`, so "one active session per device" is a database invariant rather than a property of the account row lock. Before building it, each duplicated group of active rows keeps its newest row as-is and has `:detached:<id>` appended to the others' `device_id`. Detached rows stay active — their refresh tokens keep working until they expire or are revoked — they are just no longer the row a new sign-in reuses. `down` drops the index and leaves the detached suffixes in place (strip `:detached:<id>` to recover the original).
30
+
31
+ - **The refresh-token reuse leeway no longer 500s under strict loading.** When `refresh_token_reuse_leeway` graces a replayed token, `graced_successor_for` loaded the successor without its `:session`, and `validate_parent_session!` then read it lazily — `StrictLoadingViolationError` on exactly the retry the leeway exists to rescue (sidekick-web SIDEKICK-WEB-3E). The successor is now `eager_load(:session)`ed like the primary lookup. The existing grace specs used session-less tokens, where a nil foreign key never queries; the new ones link the family to a `DeviceSession`.
32
+
33
+ - **Two graced retries racing to rotate the same successor no longer log the client out.** Two retries of one lost response can both pass `graced_successor_for` while the successor is untouched, then race to rotate it. The loser landed in `handle_concurrent_reuse!` and revoked the family, killing the token the winner had just issued. A request served from a graced successor that loses the rotation race now gets `invalid_grant` **without** revoking anything; the client keeps the winner's response. A request presenting its token directly and losing the race still revokes the family, and a later replay of the superseded token still finds a used successor and revokes as before — the leeway is not widened.
34
+
35
+ - **A lifecycle hook rejecting a brand-new account no longer 500s under strict loading.** `LifecycleHooks#destroy_newly_created_account` read `account.sessions` / `account.identifiers` lazily, so a `before_sign_in` / `after_sign_in` rejection of a just-created signup raised `StrictLoadingViolationError` instead of redirecting to login, and left the orphaned account behind. The cleanup now reads each association through `.strict_loading(false)`.
36
+
37
+ - **`StandardId::ClientApplication` ignores the dropped `refresh_token_lifetime` column.** 0.41.0 removed the column without ignoring it first, so on a rolling deploy processes still on the old code named it in every INSERT/UPDATE once the migration ran. `self.ignored_columns += %w[refresh_token_lifetime]` makes the deploy-then-drop order in **Upgrade** safe. It will be removed in a future minor.
38
+
39
+ - **The documented per-challenge OTP attempt ceiling was wrong.** `passwordless.max_attempts_per_challenge` is unset by default and falls back to `max_attempts` (default `3`), so an untouched install burns a challenge after 3 wrong codes — but the install generator template, the schema comment and the 0.16.0 entry below all said `5`. They now say `3`, and a spec pins the template's value to the runtime default. The literal `5` in the resolver is a real last resort (both settings nil or zero, since a zero ceiling would burn every challenge on its first wrong code) and is now named `StandardId::Passwordless::FALLBACK_MAX_ATTEMPTS_PER_CHALLENGE`. No behaviour change.
40
+
41
+ ### Changed
42
+
43
+ - **The dummy app runs with `strict_loading_by_default = true`** and `:raise`, as every consumer does. `spec/dummy/config/initializers/strict_loading.rb` exempts the gem models hosts exempt (`Identifier`, `Session`, `Credential`, `PasswordCredential`, `ClientSecretCredential`, `AuthorizationCode`); `RefreshToken`, `ClientApplication`, `ClientGrant`, `CodeChallenge` and `Account` stay strict. `STRICT_LOADING=full bundle exec rspec` drops the exemptions to show the remaining backlog. Test-suite only; no runtime change.
44
+
10
45
  ## [0.41.0] - 2026-09-15
11
46
 
12
47
  ### Added
@@ -1137,7 +1172,7 @@ An explicit per-grant `profile_id` parameter is intentionally out of scope for t
1137
1172
 
1138
1173
  ### Security
1139
1174
 
1140
- - **OTP verification race-condition fix and per-challenge brute-force defenses** — `VerificationService.verify` now wraps the challenge lookup, failed-attempt increment, and consumption in a single `SELECT ... FOR UPDATE` transaction, closing the TOCTOU window between "find active challenge" and "mark it used." Failed-attempt counting is now atomic and scoped to the specific challenge (previously a loose read-modify-write on the account). Events are deferred to post-commit so observers never see rolled-back state. New `config.passwordless.max_attempts_per_challenge` (default `5`) supersedes the now-deprecated account-wide `max_attempts` (kept as a fallback for existing installs). (#169)
1175
+ - **OTP verification race-condition fix and per-challenge brute-force defenses** — `VerificationService.verify` now wraps the challenge lookup, failed-attempt increment, and consumption in a single `SELECT ... FOR UPDATE` transaction, closing the TOCTOU window between "find active challenge" and "mark it used." Failed-attempt counting is now atomic and scoped to the specific challenge (previously a loose read-modify-write on the account). Events are deferred to post-commit so observers never see rolled-back state. New `config.passwordless.max_attempts_per_challenge` (unset by default, so the effective ceiling is `max_attempts`' default of `3`; `5` is only a last-resort fallback when both are unset or zero) supersedes the now-deprecated account-wide `max_attempts` (kept as a fallback for existing installs). (#169)
1141
1176
  - **JWT audience enforcement at decode time** — `JwtService.decode` now accepts an `allowed_audiences:` kwarg and raises `StandardId::InvalidAudienceError` on mismatch. `Api::TokenManager#verify_jwt_token` threads `config.oauth.allowed_audiences` through automatically, so cross-audience JWT replay is now blocked even on controllers that forget to include the `AudienceVerification` concern. Production emits a warning when `allowed_audiences` is unset. (#170, #174)
1142
1177
  - **Web flow polish** — password-reset delivery moved to an async job with a constant-time success response (closes enumeration timing leak); OAuth `redirect_uri` validation tightened to exact scheme+host+port+path match at both registration and authorize time (blocks query-string piggyback); engine logs a warning when the host app has no `secret_key_base` configured so encrypted session cookies can't silently fall back to plaintext. New `reset_password` config scope with `:delivery` (`:custom` default, `:built_in` opt-in) and mailer-sender/subject knobs. `CREDENTIAL_PASSWORD_RESET_INITIATED` event now fires from the job. (#171)
1143
1178
  - **Per-client PKCE enforcement at the authorize endpoint** — honors the existing `require_pkce` column on `ClientApplication`. Requests missing `code_challenge` are rejected with `invalid_request` when the client requires PKCE. Per-client `code_challenge_methods` replaces the global S256-only hardcode (case-insensitive). New validation blocks public clients from opting out (`public_clients_must_require_pkce`). (#175)
@@ -208,13 +208,22 @@ module StandardId
208
208
 
209
209
  # Destroy a newly created account and all its dependents.
210
210
  # Used when after_sign_in rejects a just-created account to avoid orphans.
211
+ #
212
+ # Every association read goes through `.strict_loading(false)`: the account
213
+ # was built in this request, so none of its associations are loaded, and a
214
+ # host running `strict_loading_by_default = true` (most consumers) would
215
+ # otherwise raise StrictLoadingViolationError on `account.sessions` — turning
216
+ # a hook's clean rejection of a new signup into a 500 and leaving the
217
+ # orphaned account behind. Relation-level `strict_loading(false)` also covers
218
+ # the records it loads, so `identifier.credentials` below is safe too.
211
219
  def destroy_newly_created_account(account)
212
220
  return unless account&.persisted?
213
221
 
214
222
  ActiveRecord::Base.transaction do
215
- account.sessions.destroy_all
216
- account.identifiers.each { |i| i.credentials.destroy_all }
217
- account.identifiers.destroy_all
223
+ account.sessions.strict_loading(false).destroy_all
224
+ identifiers = account.identifiers.strict_loading(false).to_a
225
+ identifiers.each { |i| i.credentials.strict_loading(false).destroy_all }
226
+ identifiers.each(&:destroy!)
218
227
  account.destroy
219
228
  end
220
229
  end
@@ -1,6 +1,17 @@
1
1
  module StandardId
2
2
  class ClientApplication < ApplicationRecord
3
3
  self.table_name = "standard_id_client_applications"
4
+
5
+ # 0.41.0 dropped `refresh_token_lifetime` (migration 20260915000000) without
6
+ # ignoring it first, so a rolling deploy broke: processes still running the
7
+ # old code had the column in their cached schema and wrote it on every
8
+ # INSERT/UPDATE once the migration had removed it. Ignoring it here lets
9
+ # hosts ship this release first and drop the column in a LATER deploy —
10
+ # the order strong_migrations requires for remove_column.
11
+ #
12
+ # Remove this line in a future minor, once every host has run 20260915000000.
13
+ self.ignored_columns += %w[refresh_token_lifetime]
14
+
4
15
  belongs_to :owner, polymorphic: true
5
16
 
6
17
  has_many :client_secret_credentials, dependent: :destroy
@@ -0,0 +1,115 @@
1
+ class AddUniqueActiveDeviceIndexToStandardIdSessions < ActiveRecord::Migration[8.0]
2
+ # One ACTIVE DeviceSession per (account, device).
3
+ #
4
+ # `OauthSessionPersistence.upsert_device_session!` keys OAuth-issued device
5
+ # sessions on a stable device_id and reuses the active row for repeat
6
+ # sign-ins. Until now nothing in the schema backed that: the upsert
7
+ # serialised on a row lock, a race or an older gem could still leave two
8
+ # active rows for one device, and the lookup then picked one arbitrarily.
9
+ # This partial unique index makes "one active row per device" a database
10
+ # invariant; the upsert inserts inside a savepoint and, on
11
+ # RecordNotUnique, reuses the row that won.
12
+ #
13
+ # Partial on purpose:
14
+ # - `revoked_at IS NULL` — revoked rows are history (audit trail, admin
15
+ # session list) and a device legitimately accumulates one per sign-out.
16
+ # - `device_id IS NOT NULL` — BrowserSession / ServiceSession rows share
17
+ # the table and carry no device_id.
18
+ #
19
+ # Existing duplicates are resolved BEFORE the index is built, without
20
+ # revoking anything: in each duplicated (account_id, device_id) group the
21
+ # newest active row keeps its device_id and every other row has
22
+ # ":detached:<id>" appended to its own. Detached rows stay active — their
23
+ # refresh tokens keep working until they expire or are revoked — they are
24
+ # simply no longer the row a new sign-in on that device reuses. Not undone
25
+ # by `down`: the original device_id is recoverable by stripping the suffix.
26
+ #
27
+ # Idempotent (if_not_exists / if_exists), CONCURRENTLY on Postgres, same
28
+ # conventions as 20260416180511. StrongMigrations treats a concurrent
29
+ # add_index as safe, but it cannot inspect the raw-SQL detach UPDATE and
30
+ # stops the migration there whenever a host actually has duplicates — so
31
+ # that data fix is asserted safe (see #assert_safe) when the gem is loaded.
32
+ disable_ddl_transaction!
33
+
34
+ INDEX_NAME = "index_standard_id_sessions_on_active_account_device".freeze
35
+ WHERE = "revoked_at IS NULL AND device_id IS NOT NULL".freeze
36
+ DETACHED_MARKER = ":detached:".freeze
37
+
38
+ def up
39
+ pg = connection.adapter_name.downcase.include?("postgres")
40
+ concurrent = pg ? { algorithm: :concurrently } : {}
41
+
42
+ # A CONCURRENTLY build that failed (e.g. a duplicate inserted mid-build)
43
+ # leaves an INVALID index behind, which if_not_exists would then skip.
44
+ # Drop it so a re-run actually rebuilds.
45
+ if pg && invalid_postgres_index?(INDEX_NAME)
46
+ remove_index :standard_id_sessions, name: INDEX_NAME, if_exists: true, **concurrent
47
+ end
48
+
49
+ detach_duplicate_active_device_sessions!
50
+
51
+ add_index :standard_id_sessions,
52
+ [:account_id, :device_id],
53
+ unique: true,
54
+ where: WHERE,
55
+ name: INDEX_NAME,
56
+ if_not_exists: true,
57
+ **concurrent
58
+ end
59
+
60
+ def down
61
+ pg = connection.adapter_name.downcase.include?("postgres")
62
+ concurrent = pg ? { algorithm: :concurrently } : {}
63
+
64
+ remove_index :standard_id_sessions, name: INDEX_NAME, if_exists: true, **concurrent
65
+ end
66
+
67
+ private
68
+
69
+ def detach_duplicate_active_device_sessions!
70
+ rows = select_all(<<~SQL.squish).to_a
71
+ SELECT s.id, s.account_id, s.device_id
72
+ FROM standard_id_sessions s
73
+ INNER JOIN (
74
+ SELECT account_id, device_id
75
+ FROM standard_id_sessions
76
+ WHERE #{WHERE}
77
+ GROUP BY account_id, device_id
78
+ HAVING COUNT(*) > 1
79
+ ) dup ON dup.account_id = s.account_id AND dup.device_id = s.device_id
80
+ WHERE s.revoked_at IS NULL AND s.device_id IS NOT NULL
81
+ ORDER BY s.account_id, s.device_id, s.created_at DESC, s.id DESC
82
+ SQL
83
+
84
+ rows.group_by { |row| [row["account_id"], row["device_id"]] }.each_value do |group|
85
+ # group.first is the newest active row: it keeps the device_id.
86
+ group.drop(1).each do |row|
87
+ detached = "#{row["device_id"]}#{DETACHED_MARKER}#{row["id"]}"
88
+ assert_safe do
89
+ execute(<<~SQL.squish)
90
+ UPDATE standard_id_sessions
91
+ SET device_id = #{connection.quote(detached)}
92
+ WHERE id = #{connection.quote(row["id"])}
93
+ SQL
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ # A single-row UPDATE by primary key, run only for duplicated device rows:
100
+ # a deliberate, bounded data fix. Every known host runs StrongMigrations,
101
+ # which cannot inspect raw SQL; assert safety when it is loaded, and run the
102
+ # block as-is when it is not (the gem does not depend on it).
103
+ def assert_safe(&block)
104
+ respond_to?(:safety_assured) ? safety_assured(&block) : yield
105
+ end
106
+
107
+ def invalid_postgres_index?(name)
108
+ select_value(<<~SQL.squish).present?
109
+ SELECT 1
110
+ FROM pg_index i
111
+ INNER JOIN pg_class c ON c.oid = i.indexrelid
112
+ WHERE c.relname = #{connection.quote(name)} AND NOT i.indisvalid
113
+ SQL
114
+ end
115
+ end
@@ -225,8 +225,9 @@ StandardId.configure do |c|
225
225
  # challenge is burned so further submissions fail fast. Distinct from the
226
226
  # per-IP rate limit (c.rate_limits.otp_verify_per_ip) — this defends
227
227
  # against distributed brute-force against a single challenge.
228
- # Default: nil — falls back to :max_attempts for backwards compatibility.
229
- # c.passwordless.max_attempts_per_challenge = 5
228
+ # Default: nil — falls back to :max_attempts (default 3) for backwards
229
+ # compatibility, so the effective default ceiling is 3.
230
+ # c.passwordless.max_attempts_per_challenge = 3
230
231
 
231
232
  # Default: 3 — deprecated alias for :max_attempts_per_challenge. Retained
232
233
  # for backwards compatibility; new installs should set the newer key.
@@ -162,7 +162,10 @@ StandardId::ConfigSchema.define do
162
162
  # rate limit (config.rate_limits.otp_verify_per_ip) — the per-IP limit
163
163
  # prevents brute-forcing from a single source, while this ceiling defends
164
164
  # against distributed brute-force attempts against the same challenge.
165
- # When nil, falls back to :max_attempts for backwards compatibility.
165
+ # When nil, falls back to :max_attempts for backwards compatibility, so the
166
+ # effective default is 3 (:max_attempts' default), not 5. 5 is only the
167
+ # last resort when both are unset or non-positive
168
+ # (StandardId::Passwordless::FALLBACK_MAX_ATTEMPTS_PER_CHALLENGE).
166
169
  field :max_attempts_per_challenge, type: :integer, default: nil
167
170
 
168
171
  field :retry_delay, type: :integer, default: 30 # 30 seconds
@@ -37,41 +37,83 @@ module StandardId
37
37
  end
38
38
  end
39
39
 
40
+ # Reuse the device's ACTIVE session row, or start a new one.
41
+ #
42
+ # Only a non-revoked row is eligible. Sign-out (/oauth/revoke under the
43
+ # default :account revocation_scope) revokes every active DeviceSession
44
+ # for the account; reusing that revoked row on the next sign-in — which a
45
+ # bare `find_by(account:, device_id:)` did — linked every new refresh
46
+ # token to a revoked parent, so RefreshTokenFlow#validate_parent_session!
47
+ # refused the very first refresh and the client was bounced to sign-in
48
+ # after every access-token expiry, forever. A revoked row is history (the
49
+ # audit trail and admin session lists still read it); a new sign-in gets
50
+ # a new row. Expiry is deliberately NOT part of eligibility: an expired
51
+ # but unrevoked row is reused with its expires_at bumped, as before.
40
52
  def upsert_device_session!(account:, request:, audience:, grant_type:)
41
53
  user_agent = request.user_agent
42
54
  device_id = stable_device_id(account: account, user_agent: user_agent, audience: audience)
43
55
  ip_address = StandardId::Utils::IpNormalizer.normalize(request.remote_ip)
44
56
 
45
- # Serialize concurrent upserts for the same account. There's no
46
- # DB-level unique constraint on (account_id, device_id), so a raw
47
- # find_by + create! would TOCTOU-race two concurrent token requests
48
- # for the same device into two duplicate rows. We acquire a SELECT
49
- # ... FOR UPDATE on the account row to serialize — account.with_lock
50
- # is unavailable because StandardId::AccountLocking overrides lock!
51
- # with a business-level method that takes a :reason kwarg.
52
- # The outer transaction (opened by TokenGrantFlow#generate_token_response)
57
+ # Serialize concurrent upserts for the same account. We acquire a
58
+ # SELECT ... FOR UPDATE on the account row — account.with_lock is
59
+ # unavailable because StandardId::AccountLocking overrides lock! with a
60
+ # business-level method that takes a :reason kwarg. The outer
61
+ # transaction (opened by TokenGrantFlow#generate_token_response)
53
62
  # releases the lock on commit/rollback.
63
+ #
64
+ # The lock alone is not the guarantee: the partial unique index from
65
+ # 20260924000000 (one active row per account + device_id) is. The lock
66
+ # keeps the common case free of unique violations; the savepoint below
67
+ # handles the rest, and keeps hosts that have not yet run that
68
+ # migration no worse off than before.
54
69
  account.class.where(id: account.id).lock.first
55
70
 
56
- existing = StandardId::DeviceSession.find_by(account: account, device_id: device_id)
57
- if existing
58
- existing.update!(
59
- expires_at: StandardId::DeviceSession.expiry,
60
- ip_address: ip_address || existing.ip_address,
61
- device_agent: user_agent || existing.device_agent
62
- )
63
- existing
64
- else
65
- StandardId::DeviceSession.create!(
66
- account: account,
67
- device_id: device_id,
68
- device_agent: user_agent.presence || "OAuth:#{grant_type}",
69
- ip_address: ip_address || "0.0.0.0",
70
- expires_at: StandardId::DeviceSession.expiry
71
- )
71
+ existing = active_device_session(account: account, device_id: device_id)
72
+ return refresh_device_session!(existing, ip_address: ip_address, user_agent: user_agent) if existing
73
+
74
+ begin
75
+ # Savepoint, so a unique violation does not abort the enclosing token
76
+ # transaction (Postgres refuses every later statement in an aborted
77
+ # transaction).
78
+ StandardId::DeviceSession.transaction(requires_new: true) do
79
+ StandardId::DeviceSession.create!(
80
+ account: account,
81
+ device_id: device_id,
82
+ device_agent: user_agent.presence || "OAuth:#{grant_type}",
83
+ ip_address: ip_address || "0.0.0.0",
84
+ expires_at: StandardId::DeviceSession.expiry
85
+ )
86
+ end
87
+ rescue ActiveRecord::RecordNotUnique
88
+ # A concurrent sign-in for the same device committed its row first.
89
+ # Reuse the winner rather than failing the token request.
90
+ winner = active_device_session(account: account, device_id: device_id)
91
+ raise unless winner
92
+
93
+ refresh_device_session!(winner, ip_address: ip_address, user_agent: user_agent)
72
94
  end
73
95
  end
74
96
 
97
+ # Newest first: before the unique index existed, a race could leave two
98
+ # active rows for one device, and an unordered lookup picked one
99
+ # arbitrarily. The migration detaches such duplicates, but hosts that
100
+ # have not run it yet still benefit from a deterministic choice.
101
+ def active_device_session(account:, device_id:)
102
+ StandardId::DeviceSession
103
+ .where(account: account, device_id: device_id, revoked_at: nil)
104
+ .order(created_at: :desc, id: :desc)
105
+ .first
106
+ end
107
+
108
+ def refresh_device_session!(session, ip_address:, user_agent:)
109
+ session.update!(
110
+ expires_at: StandardId::DeviceSession.expiry,
111
+ ip_address: ip_address || session.ip_address,
112
+ device_agent: user_agent || session.device_agent
113
+ )
114
+ session
115
+ end
116
+
75
117
  def stable_device_id(account:, user_agent:, audience:)
76
118
  audience_key = Array(audience).join(",")
77
119
  Digest::SHA256.hexdigest("oauth:#{audience_key}:#{account.id}:#{user_agent}")[0, 36]
@@ -81,6 +81,7 @@ module StandardId
81
81
  if (successor = graced_successor_for(@current_refresh_token_record))
82
82
  emit_reuse_graced_event(@current_refresh_token_record, successor)
83
83
  @current_refresh_token_record = successor
84
+ @served_from_graced_successor = true
84
85
  else
85
86
  # Reuse detected: this token was already rotated. Revoke entire family.
86
87
  @current_refresh_token_record.revoke_family!
@@ -166,9 +167,24 @@ module StandardId
166
167
  raise ActiveRecord::Rollback
167
168
  end
168
169
 
170
+ # Only a request that presented the lost-the-race token ITSELF is treated
171
+ # as reuse. A request served from a graced successor that loses the race
172
+ # is the leeway's own retry colliding with another retry of the same lost
173
+ # response — a client re-sending the superseded token twice in quick
174
+ # succession. Both passed #graced_successor_for while the successor was
175
+ # untouched; one rotated it. Revoking the family here would kill the
176
+ # token the winning request just issued and log the honest client out,
177
+ # which is exactly what the leeway exists to prevent. The loser gets a
178
+ # plain invalid_grant and the client keeps the winner's response.
179
+ #
180
+ # This does not widen the leeway: the winner already consumed the
181
+ # successor, so a later replay of the superseded token finds a USED
182
+ # successor and revokes the family as before.
169
183
  def handle_concurrent_reuse!
170
184
  @current_refresh_token_record&.reload
171
185
  if @current_refresh_token_record&.revoked?
186
+ raise StandardId::InvalidGrantError, "Refresh token is no longer valid" if @served_from_graced_successor
187
+
172
188
  @current_refresh_token_record.revoke_family!
173
189
  emit_reuse_detected_event
174
190
  raise StandardId::InvalidGrantError, "Refresh token reuse detected"
@@ -208,7 +224,12 @@ module StandardId
208
224
  return nil if revoked_record.revoked_at.blank?
209
225
  return nil if revoked_record.revoked_at < leeway.seconds.ago
210
226
 
211
- successor = StandardId::RefreshToken.find_by(previous_token_id: revoked_record.id)
227
+ # eager_load(:session), matching the primary lookup in
228
+ # #validate_refresh_token_record!: the successor becomes
229
+ # @current_refresh_token_record, and #validate_parent_session! reads its
230
+ # :session. A bare find_by left that a lazy read, which raises
231
+ # StrictLoadingViolationError (a 500) under strict loading.
232
+ successor = StandardId::RefreshToken.eager_load(:session).find_by(previous_token_id: revoked_record.id)
212
233
  return nil unless successor&.active?
213
234
  return nil if StandardId::RefreshToken.exists?(previous_token_id: successor.id)
214
235
 
@@ -2,6 +2,12 @@ require "standard_id/passwordless/verification_service"
2
2
 
3
3
  module StandardId
4
4
  module Passwordless
5
+ # Last-resort per-challenge attempt ceiling, used only when neither
6
+ # :max_attempts_per_challenge nor :max_attempts is a positive number. NOT
7
+ # the default: with default config the ceiling is 3 (see
8
+ # .max_attempts_per_challenge).
9
+ FALLBACK_MAX_ATTEMPTS_PER_CHALLENGE = 5
10
+
5
11
  class << self
6
12
  # Public API for verifying a passwordless OTP code.
7
13
  #
@@ -76,12 +82,18 @@ module StandardId
76
82
  # Resolve the per-challenge attempt ceiling, preferring the newer
77
83
  # :max_attempts_per_challenge setting but falling back to :max_attempts
78
84
  # for backwards compatibility with apps that configured the older name.
85
+ #
86
+ # With default config this resolves to 3 (:max_attempts' default).
87
+ # FALLBACK_MAX_ATTEMPTS_PER_CHALLENGE applies only when BOTH settings are
88
+ # unset or non-positive (e.g. `max_attempts = 0` / nil) — a ceiling of
89
+ # zero would burn every challenge on its first wrong code, so it is never
90
+ # honoured.
79
91
  def max_attempts_per_challenge
80
92
  configured = StandardId.config.passwordless.max_attempts_per_challenge
81
93
  return configured.to_i if configured && configured.to_i.positive?
82
94
 
83
95
  legacy = StandardId.config.passwordless.max_attempts.to_i
84
- legacy.positive? ? legacy : 5
96
+ legacy.positive? ? legacy : FALLBACK_MAX_ATTEMPTS_PER_CHALLENGE
85
97
  end
86
98
 
87
99
  # Minimum seconds that must elapse between successive code requests for
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.41.0"
2
+ VERSION = "0.41.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.41.0
4
+ version: 0.41.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -231,6 +231,7 @@ files:
231
231
  - db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb
232
232
  - db/migrate/20260611000000_create_standard_id_client_grants.rb
233
233
  - db/migrate/20260915000000_remove_refresh_token_lifetime_from_standard_id_client_applications.rb
234
+ - db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb
234
235
  - lib/generators/standard_id/install/install_generator.rb
235
236
  - lib/generators/standard_id/install/templates/standard_id.rb
236
237
  - lib/standard_id.rb