standard_id 0.40.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 +52 -1
- data/README.md +32 -0
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +12 -3
- data/app/models/standard_id/client_application.rb +18 -2
- data/db/migrate/20260915000000_remove_refresh_token_lifetime_from_standard_id_client_applications.rb +23 -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 +37 -1
- data/lib/standard_id/oauth/audience_scope_resolver.rb +179 -0
- 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/testing/factories/oauth.rb +0 -1
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +1 -0
- metadata +4 -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,57 @@ 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
|
+
|
|
45
|
+
## [0.41.0] - 2026-09-15
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- **Per-audience scope vocabulary hook.** An app can now declare, per audience, the set of scopes a client targeting that audience may request and be granted — the mechanism sidekick-web needs to express its per-audience MCP scope vocabularies (`mcp`, `mcp:read`, `mcp:eval:run`, …) through the gem instead of hard-coding a single flat `scopes_supported` list shared by every audience. This is the scope-side counterpart to the existing `audience_profile_types` binding: that binds an audience to a required profile, this binds an audience to a grantable scope vocabulary, and the two are configured and read the same way on purpose.
|
|
50
|
+
|
|
51
|
+
`c.oauth.audience_scopes` is a static `audience => Array<String>` map; `c.oauth.audience_scope_resolver` is an optional callable `->(audience:, client:, configured_scopes:) { … }` for apps that compute the vocabulary dynamically (per-client entitlements), filtered by arity like every other gem callable and returning `nil` to fall back to the static map. `StandardId::Oauth::AudienceScopeResolver` reads them and exposes `scopes_for`, `configured_for?`, `permits?`, `filter` (narrow a grant to the vocabulary), `disallowed`, and `assert!` (fail-closed, raising `InvalidScopeError` / RFC 6749 `invalid_scope`, naming only the client's offending scopes so the endpoint is not a vocabulary-enumeration oracle).
|
|
52
|
+
|
|
53
|
+
An **unconfigured** audience fails **open** everywhere — `filter`/`assert!` pass requested scopes through unchanged — exactly like `audience_profile_types` skips its check when unmapped, so an app that does not model per-audience vocabularies sees no behaviour change. The resolver ships ahead of any mint-/registration-time wiring so the hook API can be settled in review first, mirroring how `AudienceProfileResolver#resolve!` shipped before its strict path was wired.
|
|
54
|
+
|
|
55
|
+
### Removed
|
|
56
|
+
|
|
57
|
+
- **The per-client `refresh_token_lifetime` column is dropped** (`standard_id_client_applications`), resolving the #765 asymmetry. It was never honoured: `TokenLifetimeResolver.refresh_token_lifetime` resolves the refresh-token lifetime **globally** from `oauth.refresh_token_lifetime` and has no per-client branch, so the column advertised a knob that did nothing. Refresh-token lifetime is a global policy by design — a client's re-authorization cadence is governed by **revocation**, not by a per-client lifetime (see the 0.39.x linked-session reasoning: "Revocation is the property the estate wants; lifetime remains `refresh_token_lifetime`'s job, which already exists and is already configured per host"). Access- and authorization-code lifetimes remain per-client.
|
|
58
|
+
|
|
59
|
+
**Migration:** consumers pick up `20260915000000_remove_refresh_token_lifetime_from_standard_id_client_applications` (idempotent, reversible). The global `oauth.refresh_token_lifetime` config is unchanged and continues to govern refresh-token lifetime for every client.
|
|
60
|
+
|
|
10
61
|
## [0.40.0] - 2026-09-05
|
|
11
62
|
|
|
12
63
|
### Fixed
|
|
@@ -1121,7 +1172,7 @@ An explicit per-grant `profile_id` parameter is intentionally out of scope for t
|
|
|
1121
1172
|
|
|
1122
1173
|
### Security
|
|
1123
1174
|
|
|
1124
|
-
- **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)
|
|
1125
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)
|
|
1126
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)
|
|
1127
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)
|
data/README.md
CHANGED
|
@@ -235,6 +235,38 @@ end
|
|
|
235
235
|
|
|
236
236
|
Resolvers receive keyword arguments with the context containing `client`, `account`, and `request`, so you can reference only what you need. This lets you, for example, pull organization info off the client application or decorate claims with account attributes.
|
|
237
237
|
|
|
238
|
+
### Per-Audience Scope Vocabulary
|
|
239
|
+
|
|
240
|
+
Declare, per audience, the set of scopes a client targeting that audience may request and be granted. This lets each audience expose its own MCP scope vocabulary rather than sharing one flat `scopes_supported` list. It is the scope-side counterpart to the `audience_profile_types` binding: that binds an audience to a required profile, this binds it to a grantable scope vocabulary.
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
StandardId.configure do |config|
|
|
244
|
+
config.oauth.audience_scopes = {
|
|
245
|
+
"harness" => %w[mcp mcp:read mcp:eval:run mcp:prompt:write],
|
|
246
|
+
"companion_kit" => %w[mcp mcp:read],
|
|
247
|
+
"admin_kit" => %w[mcp mcp:read mcp:admin]
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
# Optional: compute a vocabulary dynamically (e.g. per-client entitlements).
|
|
251
|
+
# Return an Array<String>, or nil to fall back to the static map above.
|
|
252
|
+
config.oauth.audience_scope_resolver = ->(audience:, client:, configured_scopes:) {
|
|
253
|
+
Entitlements.mcp_scopes_for(client, audience) || configured_scopes
|
|
254
|
+
}
|
|
255
|
+
end
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`StandardId::Oauth::AudienceScopeResolver` reads this config:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
R = StandardId::Oauth::AudienceScopeResolver
|
|
262
|
+
R.scopes_for(audience: "companion_kit") # => ["mcp", "mcp:read"]
|
|
263
|
+
R.permits?(scope: "mcp:admin", audience: "companion_kit") # => false
|
|
264
|
+
R.filter(requested: %w[mcp mcp:admin], audience: "companion_kit") # => ["mcp"] (narrows, never raises)
|
|
265
|
+
R.assert!(requested: %w[mcp mcp:admin], audience: "companion_kit") # => raises InvalidScopeError
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
An **unconfigured** audience fails **open** — `filter` and `assert!` pass the requested scopes through unchanged — so an app that does not model per-audience vocabularies sees no behaviour change; narrowing only bites once an audience has an explicit vocabulary.
|
|
269
|
+
|
|
238
270
|
### Social Login Setup
|
|
239
271
|
|
|
240
272
|
The `social.google_*` and `social.apple_*` fields are declared by the **provider
|
|
@@ -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
|
|
@@ -19,8 +30,13 @@ module StandardId
|
|
|
19
30
|
validates :scopes, presence: true
|
|
20
31
|
validates :code_challenge_methods, presence: true, if: :require_pkce?
|
|
21
32
|
|
|
22
|
-
# Lifecycle validations
|
|
23
|
-
|
|
33
|
+
# Lifecycle validations.
|
|
34
|
+
#
|
|
35
|
+
# Refresh-token lifetime is deliberately NOT per-client: it is resolved
|
|
36
|
+
# globally by TokenLifetimeResolver from `oauth.refresh_token_lifetime`, so
|
|
37
|
+
# there is no `refresh_token_lifetime` column to validate here (removed in
|
|
38
|
+
# migration 20260915000000, the #765 asymmetry fix).
|
|
39
|
+
validates :access_token_lifetime, :authorization_code_lifetime,
|
|
24
40
|
presence: true, numericality: { greater_than: 0 }
|
|
25
41
|
|
|
26
42
|
# Security: public clients cannot opt out of PKCE. Public clients run in
|
data/db/migrate/20260915000000_remove_refresh_token_lifetime_from_standard_id_client_applications.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class RemoveRefreshTokenLifetimeFromStandardIdClientApplications < ActiveRecord::Migration[7.1]
|
|
2
|
+
# Drops the per-client `refresh_token_lifetime` column. It was never honoured:
|
|
3
|
+
# `TokenLifetimeResolver.refresh_token_lifetime` resolves the refresh-token
|
|
4
|
+
# lifetime GLOBALLY from `oauth.refresh_token_lifetime` and has no per-client
|
|
5
|
+
# branch, so the column advertised a knob that did nothing (the #765
|
|
6
|
+
# asymmetry). Refresh-token lifetime is a global policy by design — a client's
|
|
7
|
+
# session cadence is governed by revocation, not by a per-client lifetime
|
|
8
|
+
# (see CHANGELOG, "Revocation is the property the estate wants") — so the
|
|
9
|
+
# column is removed rather than wired up.
|
|
10
|
+
#
|
|
11
|
+
# Access- and authorization-code lifetimes remain per-client.
|
|
12
|
+
def up
|
|
13
|
+
return unless column_exists?(:standard_id_client_applications, :refresh_token_lifetime)
|
|
14
|
+
|
|
15
|
+
remove_column :standard_id_client_applications, :refresh_token_lifetime
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def down
|
|
19
|
+
return if column_exists?(:standard_id_client_applications, :refresh_token_lifetime)
|
|
20
|
+
|
|
21
|
+
add_column :standard_id_client_applications, :refresh_token_lifetime, :integer, default: 2592000
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -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
|
|
@@ -341,6 +344,39 @@ StandardId::ConfigSchema.define do
|
|
|
341
344
|
# and prefers an `active?`-responding record if multiple match.
|
|
342
345
|
field :audience_profile_resolver, type: :any, default: nil
|
|
343
346
|
|
|
347
|
+
# Audience → scope-vocabulary binding (the per-audience MCP scope hook).
|
|
348
|
+
#
|
|
349
|
+
# Maps each configured audience string to the Array<String> of scopes a
|
|
350
|
+
# client targeting that audience may request and be granted. This is the
|
|
351
|
+
# scope-side counterpart to `audience_profile_types`: that binds an audience
|
|
352
|
+
# to a required profile, this binds an audience to a grantable scope
|
|
353
|
+
# vocabulary. `StandardId::Oauth::AudienceScopeResolver` reads it.
|
|
354
|
+
#
|
|
355
|
+
# Values may be a single space-delimited String or an Array<String>.
|
|
356
|
+
#
|
|
357
|
+
# Example:
|
|
358
|
+
# c.oauth.audience_scopes = {
|
|
359
|
+
# "harness" => %w[mcp mcp:read mcp:eval:run mcp:prompt:write],
|
|
360
|
+
# "companion_kit" => %w[mcp mcp:read],
|
|
361
|
+
# "admin_kit" => %w[mcp mcp:read mcp:admin]
|
|
362
|
+
# }
|
|
363
|
+
#
|
|
364
|
+
# When empty (default) or the matched audience is absent from the map, the
|
|
365
|
+
# vocabulary is empty and the resolver's enforcement helpers fail OPEN
|
|
366
|
+
# (requested scopes pass through unchanged) — back-compat with apps that do
|
|
367
|
+
# not model per-audience scope vocabularies.
|
|
368
|
+
field :audience_scopes, type: :hash, default: -> { {} }
|
|
369
|
+
|
|
370
|
+
# Optional resolver for computing an audience's scope vocabulary
|
|
371
|
+
# dynamically (e.g. per-client entitlements). Called with keyword arguments
|
|
372
|
+
# `(audience:, client:, configured_scopes:)` — any subset is accepted,
|
|
373
|
+
# arguments are filtered by arity — where `configured_scopes` is the
|
|
374
|
+
# `Array<String>` from `audience_scopes` for that audience. Must return an
|
|
375
|
+
# Array<String> (or nil to fall back to the static `audience_scopes` map).
|
|
376
|
+
#
|
|
377
|
+
# When nil (default), only the static `audience_scopes` map is consulted.
|
|
378
|
+
field :audience_scope_resolver, type: :any, default: nil
|
|
379
|
+
|
|
344
380
|
# JWT signing configuration (for asymmetric algorithms)
|
|
345
381
|
# If nil, uses HS256 with Rails.application.secret_key_base
|
|
346
382
|
field :signing_key, type: :any, default: nil
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Oauth
|
|
3
|
+
# Resolves the scope VOCABULARY a given audience is allowed to express —
|
|
4
|
+
# the set of scope strings a client targeting that audience may request and
|
|
5
|
+
# be granted. This is the hook an app uses to declare its per-audience MCP
|
|
6
|
+
# scope vocabulary through the gem, rather than hard-coding a single flat
|
|
7
|
+
# `scopes_supported` list that every audience shares.
|
|
8
|
+
#
|
|
9
|
+
# It is the scope-side counterpart to `AudienceProfileResolver`
|
|
10
|
+
# (`c.oauth.audience_profile_types` + `c.oauth.audience_profile_resolver`):
|
|
11
|
+
# that one binds an audience to the PROFILE an account must hold; this one
|
|
12
|
+
# binds an audience to the SCOPES a client may carry. The two are read the
|
|
13
|
+
# same way and configured the same way, on purpose.
|
|
14
|
+
#
|
|
15
|
+
# Configuration (both optional, both default to "unconfigured"):
|
|
16
|
+
#
|
|
17
|
+
# # Static map: audience string => Array<String> of grantable scopes.
|
|
18
|
+
# c.oauth.audience_scopes = {
|
|
19
|
+
# "harness" => %w[mcp mcp:read mcp:eval:run mcp:prompt:write],
|
|
20
|
+
# "companion_kit" => %w[mcp mcp:read],
|
|
21
|
+
# "admin_kit" => %w[mcp mcp:read mcp:admin]
|
|
22
|
+
# }
|
|
23
|
+
#
|
|
24
|
+
# # Optional callable, for apps that compute the vocabulary dynamically
|
|
25
|
+
# # (e.g. per-client entitlements). Receives keyword args
|
|
26
|
+
# # `(audience:, client:, configured_scopes:)` — any subset is accepted,
|
|
27
|
+
# # arguments are filtered by arity — and must return an Array<String>
|
|
28
|
+
# # (or nil to fall back to the static map for that audience).
|
|
29
|
+
# c.oauth.audience_scope_resolver = ->(audience:, client:, **) {
|
|
30
|
+
# Entitlements.mcp_scopes_for(client, audience)
|
|
31
|
+
# }
|
|
32
|
+
#
|
|
33
|
+
# When an audience is UNCONFIGURED (absent from the map and the resolver
|
|
34
|
+
# returns nil / is unset), the vocabulary is empty and the enforcement
|
|
35
|
+
# helpers below fail OPEN — they pass the requested scopes through
|
|
36
|
+
# unchanged. This mirrors `audience_profile_types`: an app that does not
|
|
37
|
+
# model per-audience scope vocabularies sees no behaviour change. Narrowing
|
|
38
|
+
# only bites once an audience has an explicit vocabulary.
|
|
39
|
+
#
|
|
40
|
+
# @example
|
|
41
|
+
# StandardId::Oauth::AudienceScopeResolver.filter(
|
|
42
|
+
# requested: %w[mcp mcp:admin openid],
|
|
43
|
+
# audience: "companion_kit"
|
|
44
|
+
# ) # => ["mcp"] (mcp:admin + openid are not in companion_kit's vocabulary)
|
|
45
|
+
module AudienceScopeResolver
|
|
46
|
+
class << self
|
|
47
|
+
# The scope vocabulary for `audience` as an Array<String>.
|
|
48
|
+
#
|
|
49
|
+
# Resolution order:
|
|
50
|
+
# 1. the configured callable `audience_scope_resolver`, when set and
|
|
51
|
+
# it returns a non-nil value — filtered by arity like every other
|
|
52
|
+
# gem callable, and handed the static-map value as
|
|
53
|
+
# `configured_scopes:` so it can extend rather than replace it;
|
|
54
|
+
# 2. otherwise the static `audience_scopes` map entry;
|
|
55
|
+
# 3. otherwise `[]` (unconfigured).
|
|
56
|
+
#
|
|
57
|
+
# Always returns a de-duplicated Array<String> with blanks removed.
|
|
58
|
+
#
|
|
59
|
+
# @param audience [String, Symbol, nil]
|
|
60
|
+
# @param client [Object, nil] the ClientApplication in play, when known;
|
|
61
|
+
# passed through to the resolver callable for per-client vocabularies.
|
|
62
|
+
# @return [Array<String>]
|
|
63
|
+
def scopes_for(audience:, client: nil)
|
|
64
|
+
return [] if audience.blank?
|
|
65
|
+
|
|
66
|
+
configured = static_scopes_for(audience)
|
|
67
|
+
|
|
68
|
+
resolver = StandardId.config.oauth.audience_scope_resolver
|
|
69
|
+
if resolver.respond_to?(:call)
|
|
70
|
+
filtered = StandardId::Utils::CallableParameterFilter.filter(
|
|
71
|
+
resolver,
|
|
72
|
+
{ audience: audience.to_s, client: client, configured_scopes: configured }
|
|
73
|
+
)
|
|
74
|
+
resolved = resolver.call(**filtered)
|
|
75
|
+
return normalize(resolved) unless resolved.nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
configured
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# True when `audience` has a non-empty scope vocabulary. Callers use
|
|
82
|
+
# this to distinguish "audience is unconfigured, pass everything" from
|
|
83
|
+
# "audience is configured with an empty vocabulary, allow nothing".
|
|
84
|
+
def configured_for?(audience, client: nil)
|
|
85
|
+
scopes_for(audience: audience, client: client).any?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# True when `scope` is within `audience`'s vocabulary. An unconfigured
|
|
89
|
+
# audience permits every scope (fail-open) — see the module note.
|
|
90
|
+
#
|
|
91
|
+
# @param scope [String, Symbol]
|
|
92
|
+
def permits?(scope:, audience:, client: nil)
|
|
93
|
+
return false if scope.blank?
|
|
94
|
+
|
|
95
|
+
vocabulary = scopes_for(audience: audience, client: client)
|
|
96
|
+
return true if vocabulary.empty? # unconfigured -> fail open
|
|
97
|
+
|
|
98
|
+
vocabulary.include?(scope.to_s)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The subset of `requested` that `audience`'s vocabulary permits, in the
|
|
102
|
+
# requested order, de-duplicated. An unconfigured audience returns the
|
|
103
|
+
# requested scopes unchanged (fail-open). Use this to NARROW a grant
|
|
104
|
+
# down to what the audience allows without raising.
|
|
105
|
+
#
|
|
106
|
+
# @param requested [Array<String>, String] space-delimited String or Array
|
|
107
|
+
# @return [Array<String>]
|
|
108
|
+
def filter(requested:, audience:, client: nil)
|
|
109
|
+
req = normalize(requested)
|
|
110
|
+
vocabulary = scopes_for(audience: audience, client: client)
|
|
111
|
+
return req if vocabulary.empty? # unconfigured -> pass through
|
|
112
|
+
|
|
113
|
+
allowed = vocabulary.to_set
|
|
114
|
+
req.select { |s| allowed.include?(s) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The scopes in `requested` that `audience`'s vocabulary does NOT permit.
|
|
118
|
+
# Empty for an unconfigured audience (nothing is out of vocabulary when
|
|
119
|
+
# there is no vocabulary).
|
|
120
|
+
#
|
|
121
|
+
# @return [Array<String>]
|
|
122
|
+
def disallowed(requested:, audience:, client: nil)
|
|
123
|
+
req = normalize(requested)
|
|
124
|
+
vocabulary = scopes_for(audience: audience, client: client)
|
|
125
|
+
return [] if vocabulary.empty?
|
|
126
|
+
|
|
127
|
+
allowed = vocabulary.to_set
|
|
128
|
+
req.reject { |s| allowed.include?(s) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Strict, fail-closed variant for mint / registration enforcement.
|
|
132
|
+
#
|
|
133
|
+
# Returns the requested scopes (normalized) when every one is within the
|
|
134
|
+
# audience's vocabulary, and raises `StandardId::InvalidScopeError` —
|
|
135
|
+
# which renders as RFC 6749 `invalid_scope` — the moment one is not. An
|
|
136
|
+
# unconfigured audience is a no-op pass-through (fail-open), so wiring
|
|
137
|
+
# this into a mint path does not change behaviour for audiences that
|
|
138
|
+
# have not opted in.
|
|
139
|
+
#
|
|
140
|
+
# The error message names the offending scopes (which are the CLIENT's
|
|
141
|
+
# own request, not internal taxonomy) but never the full vocabulary, so
|
|
142
|
+
# the endpoint does not become a scope-enumeration oracle.
|
|
143
|
+
#
|
|
144
|
+
# @raise [StandardId::InvalidScopeError]
|
|
145
|
+
# @return [Array<String>]
|
|
146
|
+
def assert!(requested:, audience:, client: nil)
|
|
147
|
+
bad = disallowed(requested: requested, audience: audience, client: client)
|
|
148
|
+
return normalize(requested) if bad.empty?
|
|
149
|
+
|
|
150
|
+
raise StandardId::InvalidScopeError,
|
|
151
|
+
"Scope(s) not permitted for audience '#{audience}': #{bad.join(', ')}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def static_scopes_for(audience)
|
|
157
|
+
mapping = StandardId.config.oauth.audience_scopes || {}
|
|
158
|
+
return [] if mapping.empty?
|
|
159
|
+
|
|
160
|
+
normalize(mapping[audience.to_s] || mapping[audience.to_sym])
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Coerce a String (space-delimited) or Array into a clean, de-duplicated
|
|
164
|
+
# Array<String>. Matches the space-delimited scope convention used by
|
|
165
|
+
# ClientApplication#scopes_array and RFC 6749.
|
|
166
|
+
def normalize(value)
|
|
167
|
+
list =
|
|
168
|
+
case value
|
|
169
|
+
when nil then []
|
|
170
|
+
when Array then value
|
|
171
|
+
else value.to_s.split(/\s+/)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
list.map { |s| s.to_s.strip }.reject(&:blank?).uniq
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -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
data/lib/standard_id.rb
CHANGED
|
@@ -29,6 +29,7 @@ require "standard_id/api/token_manager"
|
|
|
29
29
|
require "standard_id/api/authentication_guard"
|
|
30
30
|
require "standard_id/utils/callable_parameter_filter"
|
|
31
31
|
require "standard_id/oauth/audience_profile_resolver"
|
|
32
|
+
require "standard_id/oauth/audience_scope_resolver"
|
|
32
33
|
require "standard_id/oauth/base_request_flow"
|
|
33
34
|
require "standard_id/oauth/token_lifetime_resolver"
|
|
34
35
|
require "standard_id/oauth/oauth_session_persistence"
|
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.
|
|
4
|
+
version: 0.41.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -230,6 +230,8 @@ files:
|
|
|
230
230
|
- db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb
|
|
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
|
+
- 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
|
|
233
235
|
- lib/generators/standard_id/install/install_generator.rb
|
|
234
236
|
- lib/generators/standard_id/install/templates/standard_id.rb
|
|
235
237
|
- lib/standard_id.rb
|
|
@@ -261,6 +263,7 @@ files:
|
|
|
261
263
|
- lib/standard_id/http_client.rb
|
|
262
264
|
- lib/standard_id/jwt_service.rb
|
|
263
265
|
- lib/standard_id/oauth/audience_profile_resolver.rb
|
|
266
|
+
- lib/standard_id/oauth/audience_scope_resolver.rb
|
|
264
267
|
- lib/standard_id/oauth/authorization_code_authorization_flow.rb
|
|
265
268
|
- lib/standard_id/oauth/authorization_code_flow.rb
|
|
266
269
|
- lib/standard_id/oauth/authorization_flow.rb
|