standard_id 0.47.0 → 0.48.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: 8c487f3a2265110f88e093a27b30e78742ecc04cbf460e94cbe35df02dbd2621
4
- data.tar.gz: 6ad0ecc9edb3a9c2e5cbc51096ac08f6164b7bcb4671098f85806d3a1f605f6e
3
+ metadata.gz: cdf22579e98c4ceb592cb20766d510d937d0e5d2f7f70c41074f984ddc3b0393
4
+ data.tar.gz: 35fbb21a24d40a307bd55279a86fa0d91549641e30f30d4101d667d9c5d7c793
5
5
  SHA512:
6
- metadata.gz: 818624622fef93866c46945cbe85c5a5e5898aea0d9770dab65596ab2524f03470f3f51b26286cea93a3cca639823be9566a7ef270cdbeec34794c6ebd4772e0
7
- data.tar.gz: cea418b10ae16fa7d90caa16dfc4c394aa9445d4b6085380d38950c15c1d381a17b7bace3e786327fd21ac82b165525a95ad73b72a087b6cbb0b3c78ef13bf4a
6
+ metadata.gz: cd202ffabe7287cb374442e6aeee83d0f2853a17d9cb7654ba816f28f48c4d6970b859b7d4d83581f09ef4a2793689b5fc620b545246bfb515691fda9a4cd1dd
7
+ data.tar.gz: 0e17bfff08af26fc343dedaf0d9500bbef69fe118031c305bdc29f33ede7231c8641c14b2b6156395702c4957c58b605dc6a24b54b4f975fa19618c8f4cade01
data/CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.48.1] - 2026-10-20
11
+
12
+ ### Added
13
+
14
+ - **`StandardId::RetryDelayError`** (an `InvalidRequestError`, with `retry_after` seconds) is what the OTP resend cooldown (`passwordless.retry_delay`) now raises, and **`Otp.issue` reports it as `error_code: :cooldown`** instead of the generic `:invalid_request`. A host that wraps `Otp.issue` can tell "a usable code was issued moments ago" apart from a real failure and keep its own cooldown copy — which is what fundbright-web needed before it could drop its advisory lock around `Otp.issue` now that the cooldown check is atomic (0.48.0). Every existing `rescue StandardId::InvalidRequestError` keeps working.
15
+
16
+ ## [0.48.0] - 2026-10-20
17
+
18
+ ### Added
19
+
20
+ - **Refresh tokens are bound to their `ClientApplication`**, and the `refresh_token` grant refuses a token whose application has been deactivated or deleted. Migration `20261020000000_add_client_application_id_to_standard_id_refresh_tokens` adds the nullable column; each grant records the application its `client_id` resolved to (authorization_code, client_credentials, any grant whose `client_id` names one) and every rotation copies it to the successor. The refusal is the same 400 `invalid_grant` / "Refresh token is no longer valid" every other dead token gets, so a leaked token learns nothing from it. Deactivation suspends (reactivating the application restores its tokens, with one deliberate exception: a replayed, already-rotated token still trips reuse detection and revokes its family, suspended or not); deletion is final. A row minted before the migration ran is bound lazily on its next rotation by `client_id`, and refused meanwhile if an application with that `client_id` exists and is inactive. A token whose `client_id` is not a registered application (social sign-in refreshes as the provider's name) is untouched. `/oauth/introspect` reports a refresh token bound to a gone or deactivated application as inactive. Replaces the host-side prepend sidekick-web carried as `config/initializers/standard_id_refresh_client_guard.rb` (core-platform-brain#490), which can be deleted once this is installed. `ClientApplication#refresh_tokens` is the new association; there is deliberately no foreign key, so a deleted application stays detectable from the token's side.
21
+ - **`c.inactive_account_status`** (symbol, default `:inactive`): the enum key `StandardId::AccountStatus` declares for a deactivated account, and so its stored value and predicate. A host whose status column already says `"suspended"` sets `:suspended` and adopts the concern — `suspended?`, the `suspended` scope, `deactivate!`, and the OAUTH_TOKEN_ISSUING / SESSION_CREATING / SESSION_VALIDATING guard (which then raises `AccountDeactivatedError, "Account is suspended"`) — without renaming a database enum value under running processes. `inactive?` stays as an alias; `:active` itself is refused with a `ConfigurationError` at include time. `activate!` / `deactivate!` stamp `activated_at` / `deactivated_at` only when the columns exist. Replaces the hand-rolled subscriber fundbright-web carries in its initializer (fundbright/delivery-ops#438).
22
+ - **`c.oauth.introspection_liveness`** (callable, default nil) and **`c.oauth.introspection_claims`** (array, default `[]`): `/oauth/introspect` runs the host's own liveness check after the gem's (signature, expiry, refresh-token row, parent session, bound ClientApplication) and renders the bare `{"active": false}` on a falsy answer or an exception (logged); and echoes the named extra claims (`profile_type`, `gid`, …) on an active response, standard members winning. Lets sidekick-web delete its app-owned RFC 7662 controller (187 lines + spec), which existed to re-check the token's profile row and return those two claims.
23
+ - **`ClientApplication.live`**: the scope form of `#active?` (`active` AND no `deactivated_at`), now what `RefreshToken#client_application_live?` queries, so there is one definition of a live application.
24
+ - **Lib-level OTP start limits**: `c.rate_limits.otp_start_per_target` (default 5 per 15 minutes, keyed per realm + channel + address) and `c.rate_limits.otp_start_per_ip` (per hour, default 0 = off) are enforced inside `Passwordless::BaseStrategy#start!`, so they hold for a host that drives `generate_passwordless_otp`, `StandardId::Otp.issue` or the strategy from its own controllers, operations or jobs — paths the engine's controller-level limits never see. Counted on the same store as those (`StandardId::RateLimiter::STORE`, which `RateLimitHandling::RATE_LIMIT_STORE` now aliases). A tripped limit raises the new `StandardId::RateLimitExceededError` (an `InvalidRequestError`; 429 `rate_limit_exceeded` with `Retry-After` from the API error handler, a flash on the web login page, `error_code: :rate_limited` from `Otp.issue`). Raise it alongside any controller per-target limit you raise, or it becomes the binding one. The per-target default equals the API's existing per-target limit, so an API caller sees no change; the per-IP limit is opt-in because the right ceiling depends on how many real users share an address.
25
+
26
+ ### Changed
27
+
28
+ - **The OTP resend cooldown is atomic.** `BaseStrategy#start!` now runs the `retry_delay` check and the challenge insert in one transaction, serialised per (realm, channel, address) with a PostgreSQL transaction-scoped advisory lock (a no-op on other adapters). Two concurrent starts for one address used to both pass the check and both issue, and creating the second code invalidated the first, so the user held a dead one (fundbright delivery-ops#345; fundbright-web wraps `Otp.issue` in its own advisory lock for this and can stop). `PASSWORDLESS_CODE_REQUESTED` is published once that transaction has committed (still before `PASSWORDLESS_CODE_GENERATED`), so a subscriber's own writes cannot be rolled back with a failed insert; the flip side is that a subscriber which raises now leaves the user with an invalidated previous code and no new one delivered, so subscribers to this event should not raise.
29
+
30
+ ### Fixed
31
+
32
+ - **The mobile bounce (`POST /auth/callback_mobile/:provider`) no longer corrupts every relayed key after the first.** Its page wrote the redirect URL into a JS string with `j()`, which ERB then HTML-escaped, so the browser navigated to `?code=…&amp;state=…` and each later key (`state`, `user`, `id_token`, …) arrived as `amp;<key>`. The URL is now emitted with `to_json`.
33
+ - **The mobile bounce relays `error`.** Apple posts `error=user_cancelled_authorize` (and no code) when the user backs out; the app now receives it, so it can tell a cancel from a failure.
34
+
35
+ ### Upgrade
36
+
37
+ - Run `bin/rails standard_id:install:migrations && bin/rails db:migrate`. Until the column exists the gem neither writes nor reads the binding, and the boot-time migration check names it. Deactivated applications are refused by `client_id` lookup even before the migration; deleted ones need the column. Tokens minted before the migration whose application had **already been deleted** cannot be told from a provider-named token and keep rotating, unbound, until `refresh_token_lifetime` expires them; revoke their sessions by hand if that is too long.
38
+
10
39
  ## [0.47.0] - 2026-10-19
11
40
 
12
41
  ### Added
data/README.md CHANGED
@@ -207,6 +207,18 @@ StandardId.configure do |config|
207
207
  end
208
208
  ```
209
209
 
210
+ Refresh tokens are **bound to the `ClientApplication`** their grant resolved
211
+ (`standard_id_refresh_tokens.client_application_id`, since 0.48). Deactivating
212
+ the application suspends every refresh token it was issued — the grant answers
213
+ the generic `invalid_grant` — and reactivating it restores them (a replayed,
214
+ already-rotated token still trips reuse detection meanwhile and revokes its
215
+ family, as it always did); deleting the application ends them for good, which
216
+ is what makes RFC 7592 `DELETE` on a dynamically registered client an actual
217
+ revocation. A token whose `client_id`
218
+ is not a registered application (social sign-in refreshes as the provider's
219
+ name) has no application to share a fate with and is left alone. There is no
220
+ setting: this is how the grant behaves once the migration has run.
221
+
210
222
  Under `:grant`, revoking a **stateless access token is a no-op** — this engine
211
223
  has no per-access-token store, so it cannot invalidate one before its `exp`
212
224
  (the response is still `200`, per RFC 7009 §2.2). Clients that need revocation
@@ -217,6 +229,40 @@ a wrong hint must not change the outcome.
217
229
  `ServiceSession`s are never touched by `/oauth/revoke` under either setting —
218
230
  machine credentials have their own lifecycle.
219
231
 
232
+ ### Token Introspection (RFC 7662)
233
+
234
+ `POST /oauth/introspect` is off by default (`config.oauth.introspection_enabled`);
235
+ once on, confidential clients can ask whether a token is live. The gem checks
236
+ the signature and expiry, and for a refresh token also its row, its parent
237
+ session and the `ClientApplication` it is bound to. Two settings let the host
238
+ add what only it knows:
239
+
240
+ ```ruby
241
+ StandardId.configure do |config|
242
+ config.oauth.introspection_enabled = true
243
+
244
+ # Run after the gem's checks pass. Takes any subset of payload:,
245
+ # refresh_token: (nil for an access token) and request:. Falsy — or an
246
+ # exception, which is logged — renders {"active": false}.
247
+ config.oauth.introspection_liveness = ->(payload:) {
248
+ next true if payload[:gid].blank? # a token with no host row to check (client_credentials)
249
+
250
+ profile = GlobalID::Locator.locate(payload[:gid])
251
+ profile.present? && profile.active?
252
+ rescue ActiveRecord::RecordNotFound # the row was deleted: inactive, and no log line needed
253
+ false
254
+ }
255
+
256
+ # Extra claims echoed VERBATIM on an ACTIVE response, beyond the RFC 7662
257
+ # members — list scalar claims. Absent claims are omitted; the standard
258
+ # members cannot be overridden.
259
+ config.oauth.introspection_claims = %w[profile_type gid]
260
+ end
261
+ ```
262
+
263
+ Access tokens are stateless, so a revoked session's access token still
264
+ introspects as active until its `exp`; keep `access_token_lifetime` short.
265
+
220
266
  ### Authenticating Opaque Session Tokens
221
267
 
222
268
  `Session.by_token` is a **lookup**, not authentication: it matches the SHA256
@@ -717,7 +763,7 @@ Every StandardId event automatically carries tracing metadata (`event_id`, `time
717
763
  | | `oauth.token.issued` | `access_token_id`, `grant_type`, `client_id`, `account`, `expires_in` | After tokens are generated |
718
764
  | | `oauth.token.refreshed` | `old_token_id`, `new_token_id`, `client_id`, `account` | After a refresh token is redeemed |
719
765
  | | `oauth.code.consumed` | `authorization_code`, `client_id`, `account` | After an authorization code is exchanged |
720
- | Passwordless | `passwordless.code.requested` | `identifier`, `channel` (email/sms) | Before generating an OTP |
766
+ | Passwordless | `passwordless.code.requested` | `identifier`, `channel` (email/sms), `realm` | After the challenge row is committed, before `code.generated` / delivery. Subscribers should not raise: the previous code is already invalidated by then |
721
767
  | | `passwordless.code.generated` | `code_challenge`, `identifier`, `channel`, `expires_at` | After an OTP is created |
722
768
  | | `passwordless.code.sent` | `identifier`, `channel`, `realm`, `delivery_status` | After an OTP is handed to delivery (not for `Otp.issue(delivery: :manual)`). `delivery_status`: `"sent"` (built-in mailer enqueued it), `"failed"` (built-in mailer was responsible but did not enqueue), `"delegated"` (a host `PASSWORDLESS_CODE_GENERATED` subscriber delivers — the engine can't confirm it) |
723
769
  | | `passwordless.code.verified` | `code_challenge`, `account`, `channel` | After OTP verification succeeds |
@@ -932,6 +978,15 @@ end
932
978
 
933
979
  The concern works with both PostgreSQL enum and string columns - Rails enum handles both transparently.
934
980
 
981
+ If your column already carries a different value for a deactivated account,
982
+ keep it: `c.inactive_account_status = :suspended` (set in the initializer,
983
+ before the model loads) makes the concern declare `{ active: "active",
984
+ suspended: "suspended" }`, so `suspended?`, the `suspended` scope and
985
+ `deactivate!` all use your word, and the sign-in / token / live-session guard
986
+ raises `AccountDeactivatedError, "Account is suspended"`. `inactive?` remains as
987
+ an alias. `activated_at` / `deactivated_at` are stamped only when the columns
988
+ exist.
989
+
935
990
  ### Usage
936
991
 
937
992
  ```ruby
@@ -1684,6 +1739,33 @@ bundle exec rspec spec/controllers/
1684
1739
  rejected with `invalid_request`.
1685
1740
  - Rate limiting on authentication endpoints
1686
1741
 
1742
+ ## OTP Start Limits
1743
+
1744
+ The engine's controllers throttle their own OTP endpoints (`rate_limits.api_passwordless_start_*`,
1745
+ `login_per_*`, `verification_start_*`). A host that drives the passwordless strategy from its own
1746
+ controllers, an operation or a job (`generate_passwordless_otp`, `StandardId::Otp.issue`) never
1747
+ passes those, so two limits are enforced inside the strategy itself, on the same store:
1748
+
1749
+ ```ruby
1750
+ StandardId.configure do |config|
1751
+ config.rate_limits.otp_start_per_target = 5 # per (realm, channel, address), per 15 minutes; 0 = off
1752
+ config.rate_limits.otp_start_per_ip = 0 # per hour; off by default — pick a ceiling that fits CGNAT/office traffic
1753
+ end
1754
+ ```
1755
+
1756
+ A tripped limit raises `StandardId::RateLimitExceededError` (an `InvalidRequestError` that the API
1757
+ error handler renders as `429 rate_limit_exceeded` with `Retry-After`, the web login page shows as a
1758
+ flash, and `Otp.issue` returns as a failed result with `error_code: :rate_limited`). The per-target
1759
+ default matches the API's `api_passwordless_start_per_target`; if you raise that (or `login_per_email`)
1760
+ raise `otp_start_per_target` with it, or it becomes the binding limit.
1761
+
1762
+ The resend cooldown (`passwordless.retry_delay`) is reported separately from those limits:
1763
+ `StandardId::RetryDelayError` (an `InvalidRequestError` carrying `retry_after` seconds), which
1764
+ `Otp.issue` returns as `error_code: :cooldown`, so a host can tell "a code is already on its way"
1765
+ apart from a failure. The 30-second resend
1766
+ cooldown (`passwordless.retry_delay`) and the challenge insert run in one transaction, serialised per
1767
+ address with a PostgreSQL advisory lock, so two concurrent starts cannot both issue a code.
1768
+
1687
1769
  ## Missing Migrations
1688
1770
 
1689
1771
  `standard_id:install:migrations` copies the engine's migrations with new timestamps, so a gem migration that was never copied is invisible to Rails' own pending-migration check. StandardId checks for that itself, by migration name:
@@ -2,7 +2,9 @@ module StandardId
2
2
  module RateLimitHandling
3
3
  extend ActiveSupport::Concern
4
4
 
5
- RATE_LIMIT_STORE = StandardId::RateLimitStore.new
5
+ # The one store object the engine counts on, shared with the lib-level
6
+ # limits (StandardId::RateLimiter) so `c.cache_store` governs both.
7
+ RATE_LIMIT_STORE = StandardId::RateLimiter::STORE
6
8
 
7
9
  # Fallback window for the Retry-After header when the tripped limit's window
8
10
  # was not captured on the instance — e.g. a hand-rolled `raise
@@ -91,6 +91,11 @@ module StandardId
91
91
  end
92
92
 
93
93
  def handle_oauth_error(error)
94
+ # A lib-level rate limit (StandardId::RateLimiter) knows its window;
95
+ # say so, as the controller-level limits do.
96
+ if error.respond_to?(:retry_after) && error.retry_after.to_i.positive?
97
+ response.set_header("Retry-After", error.retry_after.to_i.to_s)
98
+ end
94
99
  render json: {
95
100
  error: error.oauth_error_code,
96
101
  error_description: error.message
@@ -105,6 +105,12 @@ module StandardId
105
105
  end
106
106
  return render_inactive if persisted && !persisted.active?
107
107
  return render_inactive if persisted&.session && !persisted.session.active?
108
+ # ...and whose ClientApplication is gone or deactivated, which the
109
+ # refresh grant refuses too (#validate_client_application_liveness!).
110
+ return render_inactive if persisted && !persisted.client_application_live?
111
+ # ...and whatever the host knows that the gem cannot (a claim that
112
+ # points at a host row which has since been deactivated).
113
+ return render_inactive unless host_liveness?(payload, persisted)
108
114
 
109
115
  render json: active_response(payload, persisted), status: :ok
110
116
  end
@@ -155,10 +161,30 @@ module StandardId
155
161
  nil
156
162
  end
157
163
 
164
+ # `config.oauth.introspection_liveness`, when set. Called with the
165
+ # subset of payload:/refresh_token:/request: its signature accepts.
166
+ # Anything but a truthy answer — including an exception, which is
167
+ # logged — is inactive: a check that cannot answer must not say yes.
168
+ def host_liveness?(payload, persisted)
169
+ callable = StandardId.config.oauth.introspection_liveness
170
+ return true unless callable.respond_to?(:call)
171
+
172
+ context = { payload: payload, refresh_token: persisted, request: request }
173
+ !!callable.call(**StandardId::Utils::CallableParameterFilter.filter(callable, context))
174
+ rescue StandardError => e
175
+ # StandardId.logger, not config.logger: the latter is nil unless the
176
+ # host set it, and a broken hook that silently turns every token
177
+ # inactive would otherwise look like an outage with no signal.
178
+ StandardId.logger&.error("[StandardId] oauth.introspection_liveness raised: #{e.class}: #{e.message}")
179
+ false
180
+ end
181
+
158
182
  # RFC 7662 §2.2. `.compact` so absent claims are omitted rather than
159
- # emitted as null.
183
+ # emitted as null. Host-configured extra claims
184
+ # (`config.oauth.introspection_claims`) come first so the standard
185
+ # members always win.
160
186
  def active_response(payload, persisted)
161
- {
187
+ extra_claims(payload).merge(
162
188
  active: true,
163
189
  token_type: persisted ? "refresh_token" : "Bearer",
164
190
  sub: payload[:sub],
@@ -169,7 +195,15 @@ module StandardId
169
195
  jti: payload[:jti],
170
196
  client_id: payload[:client_id],
171
197
  scope: payload[:scope]
172
- }.compact
198
+ ).compact
199
+ end
200
+
201
+ def extra_claims(payload)
202
+ names = Array(StandardId.config.oauth.introspection_claims).map(&:to_s)
203
+ return {} if names.empty?
204
+
205
+ claims = payload.to_h.with_indifferent_access
206
+ names.to_h { |name| [name.to_sym, claims[name]] }.compact
173
207
  end
174
208
 
175
209
  def render_inactive
@@ -210,8 +210,11 @@ module StandardId
210
210
  redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: redirect_uri), alert: error_message
211
211
  end
212
212
 
213
+ # `error` is relayed so the app can tell a cancel from a failure: Apple
214
+ # posts `error=user_cancelled_authorize` (with the state) when the user
215
+ # backs out, and no code.
213
216
  def mobile_relay_params
214
- params.permit(:code, :state, :user, :userIdentifier, :id_token, :identity_token, :nonce).to_h.compact
217
+ params.permit(:code, :state, :user, :userIdentifier, :id_token, :identity_token, :nonce, :error).to_h.compact
215
218
  end
216
219
 
217
220
  def build_mobile_redirect(destination, extra_params)
@@ -16,6 +16,10 @@ module StandardId
16
16
 
17
17
  has_many :client_secret_credentials, dependent: :destroy
18
18
  has_many :authorization_codes, foreign_key: :client_id, primary_key: :client_id, dependent: :destroy
19
+ # Refresh tokens issued to this application (migration 20261020000000). Not
20
+ # a dependent association: rows outlive a deleted application on purpose,
21
+ # so the refresh_token grant and introspection can see that it is gone.
22
+ has_many :refresh_tokens, class_name: "StandardId::RefreshToken", inverse_of: :client_application
19
23
 
20
24
  accepts_nested_attributes_for :client_secret_credentials, allow_destroy: false
21
25
 
@@ -46,6 +50,11 @@ module StandardId
46
50
 
47
51
  # Scopes
48
52
  scope :active, -> { where(active: true) }
53
+ # The scope form of #active?: `active` AND no `deactivated_at`. The two
54
+ # fields are kept in step by the before_update callback, but a row where
55
+ # they drift (update_columns, a data fix) must read the same from a query
56
+ # as from the instance — the refresh grant and introspection rely on it.
57
+ scope :live, -> { where(active: true, deactivated_at: nil) }
49
58
  scope :confidential, -> { where(client_type: "confidential") }
50
59
  scope :public_clients, -> { where(client_type: "public") }
51
60
  scope :for_owner, ->(owner) { where(owner: owner) }
@@ -5,6 +5,12 @@ module StandardId
5
5
  belongs_to :account, class_name: StandardId.config.account_class_name
6
6
  belongs_to :session, class_name: "StandardId::Session", optional: true
7
7
  belongs_to :previous_token, class_name: "StandardId::RefreshToken", optional: true
8
+ # The ClientApplication this token was issued to, when the grant's client_id
9
+ # named one (migration 20261020000000). NULL for tokens whose client_id is
10
+ # not an application (social sign-in refreshes as the provider's name) and
11
+ # for rows minted before the column existed. No FK on purpose: a deleted
12
+ # application has to stay detectable as "gone" (see #client_application_live?).
13
+ belongs_to :client_application, class_name: "StandardId::ClientApplication", optional: true
8
14
 
9
15
  scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) }
10
16
  scope :expired, -> { where("expires_at <= ?", Time.current) }
@@ -25,6 +31,28 @@ module StandardId
25
31
  !revoked? && !expired?
26
32
  end
27
33
 
34
+ # Whether the host has run migration 20261020000000. Until then the gem
35
+ # neither writes nor reads the binding.
36
+ def self.client_binding_column?
37
+ column_names.include?("client_application_id")
38
+ rescue ActiveRecord::ActiveRecordError
39
+ false
40
+ end
41
+
42
+ # false once the application this token is bound to has been deactivated
43
+ # or deleted. An unbound token (NULL, or the column not yet migrated) is
44
+ # live by this measure: there is no application whose fate it shares.
45
+ #
46
+ # `ClientApplication.live` is the scope form of #active? (`active` AND no
47
+ # `deactivated_at`), so this and the refresh grant can never disagree
48
+ # about a row whose two fields drifted apart.
49
+ def client_application_live?
50
+ return true unless self.class.client_binding_column?
51
+ return true if client_application_id.nil?
52
+
53
+ StandardId::ClientApplication.live.exists?(id: client_application_id)
54
+ end
55
+
28
56
  def expired?
29
57
  expires_at <= Time.current
30
58
  end
@@ -34,7 +34,10 @@
34
34
  </style>
35
35
  <script>
36
36
  document.addEventListener("DOMContentLoaded", function () {
37
- var target = "<%= j @mobile_redirect_url %>";
37
+ <%# to_json, not j(): ERB HTML-escapes the j() output, so every "&" in the
38
+ query became "&amp;" and each key after the first arrived as "amp;<key>".
39
+ to_json emits a JS string literal with & < > as \u escapes. %>
40
+ var target = <%= raw @mobile_redirect_url.to_json %>;
38
41
  if (target) {
39
42
  window.location.replace(target);
40
43
  }
@@ -0,0 +1,23 @@
1
+ # Binds each refresh token to the ClientApplication it was issued to, so the
2
+ # refresh_token grant can refuse a token whose application has since been
3
+ # deactivated or deleted (see StandardId::Oauth::RefreshTokenFlow
4
+ # #validate_client_application_liveness!). Set at mint from the grant's
5
+ # resolved client (authorization_code, client_credentials, any grant whose
6
+ # client_id names a registered application); copied to each rotated successor;
7
+ # NULL where the client_id is not an application (social sign-in refreshes as
8
+ # the provider's name) and on rows minted before this ran, which are bound on
9
+ # their next rotation.
10
+ #
11
+ # Deliberately NO foreign key: a deleted application must stay detectable as
12
+ # "gone" from the token's side, which a cascading or nullifying FK would erase.
13
+ # One nullable column, no default, no index, no backfill: metadata-only on
14
+ # PostgreSQL, so safe on a large table and under StrongMigrations. Until the
15
+ # migration has run the gem does not write or read the column; a deactivated
16
+ # application is still refused by client_id lookup, a deleted one is not.
17
+ class AddClientApplicationIdToStandardIdRefreshTokens < ActiveRecord::Migration[8.0]
18
+ include StandardId::MigrationHelpers
19
+
20
+ def change
21
+ add_column :standard_id_refresh_tokens, :client_application_id, foreign_key_type
22
+ end
23
+ end
@@ -13,6 +13,10 @@ StandardId.configure do |c|
13
13
  # include StandardId::AccountAssociations (see post-install checklist).
14
14
  # Default: "User"
15
15
  c.account_class_name = "User"
16
+ # The enum key StandardId::AccountStatus uses for a deactivated account
17
+ # (default :inactive). Set it to the value your status column already
18
+ # carries, e.g. :suspended, to adopt the concern without renaming it.
19
+ # c.inactive_account_status = :inactive
16
20
 
17
21
  # JWT "iss" claim — typically your application's canonical base URL. When
18
22
  # set, tokens embed it and verification enforces it. Strongly recommended
@@ -12,8 +12,26 @@ module StandardId
12
12
  attr_accessor :subscribed
13
13
  end
14
14
 
15
+ # The configured enum key for a deactivated account (`config.inactive_account_status`).
16
+ # `:active` is refused: the enum would collapse to one key and every
17
+ # account would read as deactivated.
18
+ def self.inactive_status
19
+ status = StandardId.config.inactive_account_status.to_s.presence&.to_sym || :inactive
20
+ raise StandardId::ConfigurationError, "inactive_account_status cannot be :active" if status == :active
21
+
22
+ status
23
+ end
24
+
15
25
  included do
16
- enum :status, { active: "active", inactive: "inactive" }, default: :active
26
+ # The deactivated key is read from config ONCE, here, when the concern is
27
+ # included: a host whose column already says "suspended" declares
28
+ # `c.inactive_account_status = :suspended` and gets `suspended?`, the
29
+ # `suspended` scope and status "suspended", with no enum value to rename.
30
+ inactive_status = StandardId::AccountStatus.inactive_status
31
+ class_attribute :standard_id_inactive_status, instance_writer: false, default: inactive_status
32
+
33
+ enum :status, { active: "active", inactive_status => inactive_status.to_s }, default: :active
34
+ alias_method :inactive?, :"#{inactive_status}?" unless inactive_status == :inactive
17
35
 
18
36
  after_commit :emit_account_status_changed_event, on: :update, if: :status_previously_changed?
19
37
 
@@ -26,29 +44,52 @@ module StandardId
26
44
  StandardId::Events::SESSION_VALIDATING
27
45
  ) do |event|
28
46
  account = event[:account]
29
- if account&.inactive?
30
- raise StandardId::AccountDeactivatedError, "Account is deactivated"
47
+ if StandardId::AccountStatus.deactivated?(account)
48
+ raise StandardId::AccountDeactivatedError, StandardId::AccountStatus.deactivated_message(account)
31
49
  end
32
50
  end
33
51
  end
34
52
  end
35
53
 
54
+ # Whether +account+ is in its configured deactivated status. False for nil
55
+ # and for a model that does not include this concern.
56
+ def self.deactivated?(account)
57
+ return false unless account.respond_to?(:standard_id_inactive_status)
58
+
59
+ account.public_send(:"#{account.standard_id_inactive_status}?")
60
+ end
61
+
62
+ # "Account is deactivated" for the default key; "Account is suspended" (the
63
+ # host's own word) for a renamed one.
64
+ def self.deactivated_message(account)
65
+ status = account.standard_id_inactive_status
66
+ status == :inactive ? "Account is deactivated" : "Account is #{status}"
67
+ end
68
+
36
69
  def activate!
37
70
  return true if active?
38
71
 
39
- update!(status: :active, activated_at: Time.current)
72
+ attributes = { status: :active }
73
+ attributes[:activated_at] = Time.current if has_attribute?(:activated_at)
74
+ update!(**attributes)
40
75
  end
41
76
 
42
77
  def deactivate!
43
- return true if inactive?
78
+ return true if standard_id_deactivated?
44
79
 
45
- update!(status: :inactive, deactivated_at: Time.current)
80
+ attributes = { status: standard_id_inactive_status }
81
+ attributes[:deactivated_at] = Time.current if has_attribute?(:deactivated_at)
82
+ update!(**attributes)
46
83
  end
47
84
 
48
85
  private
49
86
 
87
+ def standard_id_deactivated?
88
+ StandardId::AccountStatus.deactivated?(self)
89
+ end
90
+
50
91
  def emit_account_status_changed_event
51
- event = inactive? ? StandardId::Events::ACCOUNT_DEACTIVATED : StandardId::Events::ACCOUNT_ACTIVATED
92
+ event = standard_id_deactivated? ? StandardId::Events::ACCOUNT_DEACTIVATED : StandardId::Events::ACCOUNT_ACTIVATED
52
93
  StandardId::Events.publish(
53
94
  event,
54
95
  account: self,
@@ -43,6 +43,17 @@ StandardId::ConfigSchema.define do
43
43
  field :inertia_component_namespace, type: :string, default: "standard_id"
44
44
  field :alias_current_user, type: :boolean, default: false
45
45
 
46
+ # The enum key `StandardId::AccountStatus` declares for a deactivated
47
+ # account, and so its stored value and predicate: `:inactive` (default)
48
+ # gives `inactive?` / status "inactive"; `:suspended` gives `suspended?` /
49
+ # status "suspended". For a host whose status column already carries a
50
+ # different value and whose code reads that predicate everywhere, so it can
51
+ # adopt the concern (and the sign-in / token / live-session guard it
52
+ # installs) without renaming a database enum value under running
53
+ # processes. `inactive?` stays available as an alias. Read when the concern
54
+ # is included, so set it in the initializer, before the account model loads.
55
+ field :inactive_account_status, type: :symbol, default: :inactive
56
+
46
57
  # Opt the gem's own associations out of an app-wide
47
58
  # `strict_loading_by_default = true`.
48
59
  #
@@ -457,6 +468,24 @@ StandardId::ConfigSchema.define do
457
468
  # immediately.
458
469
  field :introspection_enabled, type: :boolean, default: false
459
470
 
471
+ # A host-side liveness check for `/oauth/introspect`, run after the gem's
472
+ # own checks pass (signature, expiry, refresh-token row, parent session,
473
+ # bound ClientApplication) and before the active response is rendered. A
474
+ # callable taking any subset of `payload:` (the decoded claims, string or
475
+ # symbol keys), `refresh_token:` (the persisted StandardId::RefreshToken,
476
+ # nil for an access token) and `request:`. A falsy return renders
477
+ # `{"active": false}`, nothing else. For claims the host mints that point
478
+ # at its own rows — a profile `gid`, a tenant — whose liveness only the
479
+ # host can judge. An exception is logged and counts as inactive: a check
480
+ # that cannot answer must not say yes.
481
+ field :introspection_liveness, type: :any, default: nil
482
+
483
+ # Extra claim names copied from the token into an ACTIVE introspection
484
+ # response, beyond the RFC 7662 members the gem always returns — e.g.
485
+ # `%w[profile_type gid]`. A claim the token lacks is omitted; the standard
486
+ # members (`active`, `sub`, `aud`, …) cannot be overridden.
487
+ field :introspection_claims, type: :array, default: -> { [] }
488
+
460
489
  # Grant types the token endpoint refuses even though the engine implements
461
490
  # them. Values are the `grant_type` strings: "authorization_code",
462
491
  # "refresh_token", "client_credentials", "password", "passwordless_otp".
@@ -670,6 +699,26 @@ StandardId::ConfigSchema.define do
670
699
  field :api_passwordless_start_per_ip, type: :integer, default: 10 # per hour
671
700
  field :api_passwordless_start_per_target, type: :integer, default: 5 # per 15 minutes
672
701
 
702
+ # OTP start limits enforced INSIDE the passwordless strategy
703
+ # (Passwordless::BaseStrategy#start!), not on a controller — so they hold
704
+ # for a host that drives `generate_passwordless_otp`, `StandardId::Otp.issue`
705
+ # or the strategy from its own controllers, operations or jobs, where the
706
+ # controller-level limits above are never consulted. Counted on the same
707
+ # store, keyed per (realm, channel, target) and per IP, raising
708
+ # StandardId::RateLimitExceededError (a 429 through the API error handler,
709
+ # a flash on the web login page, a failed result from Otp.issue).
710
+ #
711
+ # The per-target limit defaults to the API's per-target value, so a path
712
+ # that already runs the controller limit sees no change (the controller
713
+ # trips first, with its Retry-After). A host that RAISES a controller
714
+ # per-target limit (api_passwordless_start_per_target, login_per_email)
715
+ # must raise this one too, or it becomes the binding limit. The per-IP
716
+ # limit defaults OFF: the
717
+ # right ceiling depends on how many real users share one address (CGNAT,
718
+ # an office), and a wrong default would bounce them. 0 disables either.
719
+ field :otp_start_per_target, type: :integer, default: 5 # per 15 minutes
720
+ field :otp_start_per_ip, type: :integer, default: 0 # per hour; 0 = off
721
+
673
722
  # OAuth token endpoint — requests per IP.
674
723
  field :api_token_per_ip, type: :integer, default: 30 # per 15 minutes
675
724
 
@@ -74,6 +74,40 @@ module StandardId
74
74
  def oauth_error_code = :invalid_request
75
75
  end
76
76
 
77
+ # A lib-level rate limit tripped (StandardId::RateLimiter). A subclass of
78
+ # InvalidRequestError so every path that already turns a refused OTP start
79
+ # into a flash, a failure result or an OAuth error body keeps doing so; the
80
+ # API error handler renders it as 429 `rate_limit_exceeded`, the same shape
81
+ # the controller-level limits produce.
82
+ class RateLimitExceededError < InvalidRequestError
83
+ # Length in seconds of the window that tripped (an upper bound on the
84
+ # wait, not the time remaining), for a Retry-After header.
85
+ attr_reader :retry_after
86
+
87
+ def initialize(message = "Too many requests. Please try again later.", retry_after: nil)
88
+ super(message)
89
+ @retry_after = retry_after
90
+ end
91
+
92
+ def oauth_error_code = :rate_limit_exceeded
93
+ def http_status = :too_many_requests
94
+ end
95
+
96
+ # The OTP resend cooldown (`passwordless.retry_delay`) refused a start: a code
97
+ # for this target was issued less than retry_delay seconds ago and is still
98
+ # usable, so nothing new was sent. A subclass of InvalidRequestError so every
99
+ # existing rescue keeps working; Otp.issue reports it as `error_code:
100
+ # :cooldown` so a host can show its own "a code is already on its way" copy
101
+ # and tell it apart from a send failure. `retry_after` is the seconds left.
102
+ class RetryDelayError < InvalidRequestError
103
+ attr_reader :retry_after
104
+
105
+ def initialize(message = "Please wait before requesting another code", retry_after: nil)
106
+ super(message)
107
+ @retry_after = retry_after
108
+ end
109
+ end
110
+
77
111
  class InvalidClientError < OAuthError
78
112
  def oauth_error_code = :invalid_client
79
113
  def http_status = :unauthorized
@@ -42,6 +42,7 @@ module StandardId
42
42
 
43
43
  validate_refresh_token_record!
44
44
  validate_scope_narrowing!
45
+ validate_client_application_liveness!
45
46
  end
46
47
 
47
48
  private
@@ -77,6 +78,66 @@ module StandardId
77
78
  raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
78
79
  end
79
80
 
81
+ # Refuse a refresh whose ClientApplication has been deactivated or deleted.
82
+ #
83
+ # Deleting a client is the only revocation RFC 7592 gives a client manager,
84
+ # and a public (DCR, PKCE) client never sends a secret, so until this check
85
+ # existed the grant never looked the client up at all: DELETE
86
+ # /oauth/register/:client_id answered 204 while every refresh token it had
87
+ # issued kept rotating. Hosts closed that with a prepend on this class.
88
+ #
89
+ # The binding is the row's `client_application_id` (migration
90
+ # 20261020000000), copied down each rotation. A row without one — minted
91
+ # before the column existed, or on a host that has not migrated yet — is
92
+ # checked by the token's client_id instead: refused if an application with
93
+ # that client_id exists and is inactive, and (once the column exists) its
94
+ # successor carries the id. So deactivation bites with or without the
95
+ # migration; only deletion needs the column, because a deleted
96
+ # application leaves nothing to look up by client_id. A token whose
97
+ # client_id is not an application at all (social sign-in refreshes as the
98
+ # provider's name; a free-form client_id that happens to collide with a
99
+ # registered one is treated as that application) is left alone.
100
+ #
101
+ # Deactivation suspends: nothing is revoked, so reactivating the
102
+ # application restores its tokens. One exception, deliberate: reuse
103
+ # detection in #validate_refresh_token_record! runs first, so a replayed
104
+ # already-rotated token still revokes its family while the application is
105
+ # suspended. That is the theft signal it always was, and suspension is
106
+ # not a reason to stop honouring it. Deletion is final because the row is
107
+ # gone and client_ids are random. Either way the answer is the generic
108
+ # `invalid_grant` every other dead token gets (RFC 6749 §5.2): a distinct
109
+ # message would tell whoever holds a leaked token why it stopped working.
110
+ def validate_client_application_liveness!
111
+ return if @current_refresh_token_record.nil?
112
+ return if client_application_live?
113
+
114
+ raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
115
+ end
116
+
117
+ def client_application_live?
118
+ record = @current_refresh_token_record
119
+ return record.client_application_live? if record.try(:client_application_id).present?
120
+
121
+ client = token_client
122
+ client.nil? || client.active?
123
+ end
124
+
125
+ # Resolved by client_id (TokenGrantFlow#token_client), memoised: the
126
+ # liveness check, the successor's binding and the claim resolvers all
127
+ # read it on the hot path.
128
+ def token_client
129
+ return @token_client if defined?(@token_client)
130
+
131
+ @token_client = super
132
+ end
133
+
134
+ # The successor inherits the presented token's binding; a legacy unbound
135
+ # token's successor is bound by client_id (see
136
+ # #validate_client_application_liveness!).
137
+ def bound_client_application_id
138
+ @current_refresh_token_record.try(:client_application_id) || super
139
+ end
140
+
80
141
  def validate_refresh_token_record!
81
142
  jti = @refresh_payload[:jti]
82
143
  # Legacy tokens minted before jti tracking was added cannot be looked
@@ -230,10 +230,27 @@ module StandardId
230
230
  token_digest: StandardId::RefreshToken.digest_for(jti),
231
231
  expires_at: expires_at,
232
232
  previous_token: previous_refresh_token_record,
233
- **StandardId::AuthLineage.refresh_token_attributes(refresh_token_auth_lineage)
233
+ **StandardId::AuthLineage.refresh_token_attributes(refresh_token_auth_lineage),
234
+ **client_binding_attributes
234
235
  )
235
236
  end
236
237
 
238
+ # The ClientApplication the refresh token is bound to, so the
239
+ # refresh_token grant can refuse it once that application is deactivated
240
+ # or deleted. Nothing is written until migration 20261020000000 has run.
241
+ def client_binding_attributes
242
+ return {} unless StandardId::RefreshToken.client_binding_column?
243
+
244
+ { client_application_id: bound_client_application_id }
245
+ end
246
+
247
+ # nil when the grant's client_id is not a registered application (the
248
+ # social flow, a password/passwordless grant with a free-form client_id):
249
+ # such a token has no application whose fate to share.
250
+ def bound_client_application_id
251
+ token_client&.id
252
+ end
253
+
237
254
  # How the authentication behind this grant was established, recorded on
238
255
  # the refresh token so the refresh_token grant can re-check the
239
256
  # login-method policy against it. Grants that authenticate an account
@@ -74,7 +74,10 @@ module StandardId
74
74
  # - success?: true/false
75
75
  # - challenge: the created CodeChallenge (nil on failure)
76
76
  # - code: raw OTP code — only populated when delivery: :manual
77
- # - error_code: machine-readable symbol on failure
77
+ # - error_code: machine-readable symbol on failure — :invalid_request
78
+ # (bad target, username_validator rejection), :cooldown (a usable code
79
+ # was issued less than passwordless.retry_delay seconds ago; nothing new
80
+ # was sent), :rate_limited (rate_limits.otp_start_per_* tripped)
78
81
  # - error_message: human-readable message on failure
79
82
  IssueResult = Data.define(:success?, :challenge, :code, :error_code, :error_message) do
80
83
  # Parallel naming with VerificationService::Result, which exposes the
@@ -132,6 +135,15 @@ module StandardId
132
135
  skip_sender: delivery_sym == :manual,
133
136
  delivery: delivery_sym
134
137
  )
138
+ rescue StandardId::RetryDelayError => e
139
+ # The resend cooldown (passwordless.retry_delay): a usable code was
140
+ # issued moments ago and nothing new was sent. Distinct code so a
141
+ # caller can say "it's on its way" rather than report a failure.
142
+ return failure_issue_result(:cooldown, e.message)
143
+ rescue StandardId::RateLimitExceededError => e
144
+ # A lib-level start limit (rate_limits.otp_start_per_*) tripped.
145
+ # Distinct code so a caller can answer 429 rather than 400.
146
+ return failure_issue_result(:rate_limited, e.message)
135
147
  rescue StandardId::InvalidRequestError => e
136
148
  # Validation failures from the strategy (invalid email/phone format,
137
149
  # custom username_validator rejections, etc.) are surfaced as a
@@ -32,14 +32,26 @@ module StandardId
32
32
 
33
33
  validate_username!(username)
34
34
  run_username_validator!(username)
35
- enforce_retry_delay!(username)
35
+ enforce_start_rate_limits!(username)
36
+ # Cooldown check and challenge creation in ONE transaction, serialised
37
+ # per target (see #lock_target!), so two concurrent starts for the same
38
+ # address cannot both pass the check and both issue a code — which
39
+ # invalidated the first code the moment the second was created and
40
+ # left the user holding a dead one (fundbright delivery-ops#345).
41
+ challenge = ActiveRecord::Base.transaction do
42
+ lock_target!(username)
43
+ enforce_retry_delay!(username)
44
+ create_challenge!(
45
+ username,
46
+ code_length: code_length,
47
+ expires_in: expires_in,
48
+ metadata: metadata
49
+ )
50
+ end
51
+ # Published once the row is committed: a subscriber that writes must
52
+ # not have its work rolled back with a failed insert, nor hold the
53
+ # per-target lock for its whole runtime. Still before CODE_GENERATED.
36
54
  emit_code_requested(username)
37
- challenge = create_challenge!(
38
- username,
39
- code_length: code_length,
40
- expires_in: expires_in,
41
- metadata: metadata
42
- )
43
55
  # skip_sender is forwarded into the event payload so subscribers that
44
56
  # deliver on PASSWORDLESS_CODE_GENERATED (e.g. PasswordlessDeliverySubscriber)
45
57
  # can honor a per-call manual-delivery request. Without this,
@@ -52,6 +64,44 @@ module StandardId
52
64
 
53
65
  protected
54
66
 
67
+ # Per-target and per-IP ceilings on starts (rate_limits.otp_start_per_*),
68
+ # counted here rather than on a controller so they hold whichever way a
69
+ # host reaches the strategy. The target key carries the realm and
70
+ # channel, so an authentication code and a verification code for the
71
+ # same address are separate budgets, as they are separate challenges.
72
+ def enforce_start_rate_limits!(username)
73
+ limits = StandardId.config.rate_limits
74
+ target = username.to_s.strip.downcase
75
+ StandardId::RateLimiter.hit!(
76
+ "standard_id:otp-start:target:#{@realm}:#{connection_type}:#{target}",
77
+ limit: limits.otp_start_per_target, window: 15.minutes
78
+ )
79
+
80
+ ip = request.respond_to?(:remote_ip) ? request.remote_ip : nil
81
+ return if ip.blank?
82
+
83
+ StandardId::RateLimiter.hit!(
84
+ "standard_id:otp-start:ip:#{StandardId::Utils::IpNormalizer.normalize(ip)}",
85
+ limit: limits.otp_start_per_ip, window: 1.hour
86
+ )
87
+ end
88
+
89
+ # Serialise concurrent starts for one (realm, channel, target) for the
90
+ # rest of the enclosing transaction. On PostgreSQL this is a transaction-
91
+ # scoped advisory lock, released at commit or rollback, so a second start
92
+ # waits for the first to finish and then sees the challenge it created.
93
+ # Other adapters get no cross-process lock: the retry-delay check there
94
+ # stays what it was, a read followed by a write. Keyed on the normalised
95
+ # target (as the rate limit is), so "User@x" and "user@x" contend for one
96
+ # lock even when a caller skipped the web login's own downcasing.
97
+ def lock_target!(username)
98
+ connection = StandardId::CodeChallenge.connection
99
+ return unless connection.adapter_name.match?(/postg/i)
100
+
101
+ key = "standard_id:otp:#{@realm}:#{connection_type}:#{username.to_s.strip.downcase}"
102
+ connection.execute("SELECT pg_advisory_xact_lock(hashtext(#{connection.quote(key)}))")
103
+ end
104
+
55
105
  # Reject a resend that arrives within passwordless.retry_delay seconds of
56
106
  # the most recent still-active code for this target. A soft cooldown that
57
107
  # complements the coarser per-target rate limits: it does not consume the
@@ -71,8 +121,10 @@ module StandardId
71
121
  return if elapsed >= delay
72
122
 
73
123
  wait = (delay - elapsed).ceil
74
- raise StandardId::InvalidRequestError,
75
- "Please wait #{wait} #{'second'.pluralize(wait)} before requesting another code"
124
+ raise StandardId::RetryDelayError.new(
125
+ "Please wait #{wait} #{'second'.pluralize(wait)} before requesting another code",
126
+ retry_after: wait
127
+ )
76
128
  end
77
129
 
78
130
  def create_challenge!(username, code_length: nil, expires_in: nil, metadata: {})
@@ -0,0 +1,35 @@
1
+ module StandardId
2
+ # Fixed-window counters on the configured rate-limit store, for limits the
3
+ # engine enforces OUTSIDE a controller — where Rails' `rate_limit` macro
4
+ # cannot reach. The first one is the OTP start path (Passwordless::BaseStrategy
5
+ # #start!), which a host can drive from its own controllers, an operation or
6
+ # a job without ever passing the engine's throttled actions.
7
+ #
8
+ # `STORE` is the same lazily-resolved RateLimitStore the engine's controllers
9
+ # count on (RateLimitHandling::RATE_LIMIT_STORE is this object), so one
10
+ # `c.cache_store` governs both, and a spec that stubs one stubs the other.
11
+ module RateLimiter
12
+ STORE = StandardId::RateLimitStore.new
13
+
14
+ module_function
15
+
16
+ # Count one hit against +key+ and raise once the window holds more than
17
+ # +limit+. A non-positive limit disables the check. A store that cannot
18
+ # count (`ActiveSupport::Cache::NullStore` answers nil) disables it too:
19
+ # there is nothing to count against, and refusing everyone is worse than
20
+ # counting nothing.
21
+ #
22
+ # @param key [String]
23
+ # @param limit [Integer]
24
+ # @param window [ActiveSupport::Duration, Integer] seconds
25
+ # @raise [StandardId::RateLimitExceededError]
26
+ def hit!(key, limit:, window:)
27
+ return if limit.to_i <= 0
28
+
29
+ count = STORE.increment(key, 1, expires_in: window)
30
+ return if count.nil? || count.to_i <= limit.to_i
31
+
32
+ raise StandardId::RateLimitExceededError.new(retry_after: window.to_i)
33
+ end
34
+ end
35
+ end
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.47.0"
2
+ VERSION = "0.48.1"
3
3
  end
data/lib/standard_id.rb CHANGED
@@ -69,6 +69,7 @@ require "standard_id/otp"
69
69
  require "standard_id/authorization_bypass"
70
70
  require "standard_id/utils/ip_normalizer"
71
71
  require "standard_id/rate_limit_store"
72
+ require "standard_id/rate_limiter"
72
73
 
73
74
  require "concurrent/delay"
74
75
 
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.47.0
4
+ version: 0.48.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -238,6 +238,7 @@ files:
238
238
  - db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb
239
239
  - db/migrate/20261002000000_create_standard_id_social_identities.rb
240
240
  - db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb
241
+ - db/migrate/20261020000000_add_client_application_id_to_standard_id_refresh_tokens.rb
241
242
  - lib/generators/standard_id/install/install_generator.rb
242
243
  - lib/generators/standard_id/install/templates/standard_id.rb
243
244
  - lib/standard_id.rb
@@ -309,6 +310,7 @@ files:
309
310
  - lib/standard_id/providers/base.rb
310
311
  - lib/standard_id/providers/plugin_railtie.rb
311
312
  - lib/standard_id/rate_limit_store.rb
313
+ - lib/standard_id/rate_limiter.rb
312
314
  - lib/standard_id/routing.rb
313
315
  - lib/standard_id/scope_config.rb
314
316
  - lib/standard_id/session_type_resolver.rb