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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89d297a5e9c69566991b97cca501fd5a714b2d2e1200d2e740b3e45684981aab
4
- data.tar.gz: d93fc7206735373f14ac2cd51c4612aaf7b1d19244022d884f0c84757d8f78c6
3
+ metadata.gz: eda13bb222bf26a20444378b4fe6d0e7661f86a0058388986fb2710e5f01802d
4
+ data.tar.gz: 96835ff10cd72cc01c8654ddf86d5cb44555079eafad8f521b8c62061a63dde9
5
5
  SHA512:
6
- metadata.gz: 1ce536a136af167e65ec9d01e370a187d71f77a2f60f0c5af47ee5c23a9b58cb87444b95191a019fa06689902765f51eaff20656aeedcdef288e34f577c99c91
7
- data.tar.gz: f825db9970c937a76592c421fb71a9607f3ea0667dfafa105ff2d6ce78bbd754f5ca01f873cf3321962876c3ab0f8c7f7314ec95604cf5900d56291cede6ec57
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.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
@@ -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
- validates :access_token_lifetime, :refresh_token_lifetime, :authorization_code_lifetime,
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
@@ -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 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
@@ -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. 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
@@ -12,7 +12,6 @@ FactoryBot.define do
12
12
  require_pkce { true }
13
13
  code_challenge_methods { "S256" }
14
14
  access_token_lifetime { 3600 }
15
- refresh_token_lifetime { 2_592_000 }
16
15
  authorization_code_lifetime { 600 }
17
16
  active { true }
18
17
 
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.40.0"
2
+ VERSION = "0.41.1"
3
3
  end
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.40.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