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 +4 -4
- data/CHANGELOG.md +36 -1
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +12 -3
- data/app/models/standard_id/client_application.rb +11 -0
- data/db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb +115 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +3 -2
- data/lib/standard_id/config/schema.rb +4 -1
- data/lib/standard_id/oauth/oauth_session_persistence.rb +66 -24
- data/lib/standard_id/oauth/refresh_token_flow.rb +22 -1
- data/lib/standard_id/passwordless.rb +13 -1
- data/lib/standard_id/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eda13bb222bf26a20444378b4fe6d0e7661f86a0058388986fb2710e5f01802d
|
|
4
|
+
data.tar.gz: 96835ff10cd72cc01c8654ddf86d5cb44555079eafad8f521b8c62061a63dde9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
217
|
-
|
|
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
|
|
229
|
-
#
|
|
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.
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
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 =
|
|
57
|
-
if existing
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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 :
|
|
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
|
data/lib/standard_id/version.rb
CHANGED
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.
|
|
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
|