standard_id 0.28.0 → 0.29.0

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: c3aa81db933d1f506926c8c400e2bc57f60a7b29529f7bfa514e71b68fe028c1
4
- data.tar.gz: 47a3be048ef63b02ee3819354dae0e4af75cb21a88d740e5b72e754c55471a2a
3
+ metadata.gz: e92eb10b6ca75511deb6b2b825b95fc79b25db73135d1cc6c637095e7466f8e8
4
+ data.tar.gz: 4968ead0789aaeddb2ab68de34c224d951d988a18932e49e29125489cfa6eacb
5
5
  SHA512:
6
- metadata.gz: e4b6618506a9428d7a426ad4d095bd95fd2aa9c3f7b58e8727f20e8300d8dc3c6f622da1e07829961474c392d35a292775f4596b36e39c0961f66a28cca2db7a
7
- data.tar.gz: 585a4b2d8c65e4d9de10eb8466d99c9323ea6279459edf21fe72705f829688cded440133faaa50317a01bcf4d2544ecf48b01749527287be73cc042eb833fe6a
6
+ metadata.gz: 4c14b2551f993b7b7f9a20694ef337af4a9ebb05270c9a1cc343f43d3f3483d6196d1360786410b4140cc6c0214e82feadf5688019c347f66e0476958fb3c7c3
7
+ data.tar.gz: b71083067ee12c30bfedc1a1ee5ac8b9181c47fcaf790e8ee60e00eee4d73ac5a1eadcbd496e51f7244fd08bd83ec0a5a412e2cc225901c259809645b482af9a
@@ -4,20 +4,89 @@ module StandardId
4
4
 
5
5
  RATE_LIMIT_STORE = StandardId::RateLimitStore.new
6
6
 
7
+ # Fallback window for the Retry-After header when the tripped limit's window
8
+ # was not captured on the instance — e.g. a hand-rolled `raise
9
+ # TooManyRequests` that bypasses the rate_limit macro (see
10
+ # Api::Oauth::TokensController's per-audience limit, which uses a 15-minute
11
+ # window). Matches that hand-rolled limit's window, so the fallback stays
12
+ # accurate for it.
13
+ DEFAULT_RETRY_AFTER = 15.minutes
14
+
7
15
  included do
8
16
  rescue_from ActionController::TooManyRequests, with: :handle_rate_limited
9
17
  end
10
18
 
19
+ class_methods do
20
+ # Wrap Rails' `rate_limit` so the tripped limit's window (`within:`) is
21
+ # remembered on the controller instance, letting the shared
22
+ # `handle_rate_limited` rescue emit a Retry-After that reflects the ACTUAL
23
+ # window instead of a hardcoded 15 minutes (which was 4x wrong for every
24
+ # 1-hour limit — verification/password-reset/signup/api-passwordless start,
25
+ # dynamic registration). This transparently upgrades every existing
26
+ # `rate_limit ... within: X` call site with no change to the call itself.
27
+ #
28
+ # ActionController::TooManyRequests carries no window, and a controller may
29
+ # declare several limits with different windows, so a single class-level
30
+ # value can't identify which limit fired. Capturing it in the per-limit
31
+ # `with:` closure (evaluated in controller context the instant that
32
+ # specific limit trips) is the least-invasive way to thread the correct
33
+ # window through. A host that passes its own `with:` opts out and keeps the
34
+ # default fallback.
35
+ def rate_limit(within:, with: nil, **options)
36
+ with ||= -> {
37
+ @standard_id_rate_limit_within = within
38
+ raise ActionController::TooManyRequests
39
+ }
40
+ super(within: within, with: with, **options)
41
+ end
42
+ end
43
+
44
+ # Resolve the effective per-IP login rate limit, preferring the
45
+ # mechanism-agnostic `login_per_ip` alias and falling back to the deprecated
46
+ # `password_login_per_ip` when the host left the alias at its default (i.e.
47
+ # only configured the old name). The new alias wins whenever explicitly set.
48
+ # Mirrors the max_attempts -> max_attempts_per_challenge deprecation-alias
49
+ # precedent (prefer-new, fall-back-to-old).
50
+ def self.login_per_ip
51
+ resolve_login_alias(:login_per_ip, :password_login_per_ip)
52
+ end
53
+
54
+ # Effective per-email login rate limit; see .login_per_ip.
55
+ def self.login_per_email
56
+ resolve_login_alias(:login_per_email, :password_login_per_email)
57
+ end
58
+
59
+ # Return the alias value unless it still equals its schema default, in which
60
+ # case fall back to the deprecated field. Both fields share the same default,
61
+ # so the effective default is unchanged when neither is set.
62
+ def self.resolve_login_alias(new_field, old_field)
63
+ rate_limits = StandardId.config.rate_limits
64
+ default = StandardId.config.__schema__.field_for(:rate_limits, new_field).default_value
65
+ new_value = rate_limits[new_field]
66
+ new_value == default ? rate_limits[old_field] : new_value
67
+ end
68
+
11
69
  private
12
70
 
13
71
  def handle_rate_limited(_exception)
14
- response.set_header("Retry-After", 15.minutes.to_i.to_s)
72
+ retry_after = (@standard_id_rate_limit_within || DEFAULT_RETRY_AFTER).to_i
73
+ response.set_header("Retry-After", retry_after.to_s)
15
74
 
16
75
  if self.class.ancestors.include?(ActionController::API)
17
76
  render json: {
18
77
  error: "rate_limit_exceeded",
19
78
  error_description: "Too many requests. Please try again later."
20
79
  }, status: :too_many_requests
80
+ elsif request.get? || request.head?
81
+ # A rate-limited GET/HEAD has no sibling form to bounce to. Redirecting
82
+ # to `request.path` (the non-GET branch below) would target the SAME
83
+ # throttled action, so the browser follows the redirect, re-increments
84
+ # the counter, and gets redirected again — an unbounded loop that also
85
+ # keeps resetting the window. v0.28.0 shipped the first rate-limited GETs
86
+ # (email/phone confirm #show), which exposed this. Render a terminal 429
87
+ # instead: the response is the end of the exchange, so it cannot loop.
88
+ render plain: "Too many requests. Please try again later.",
89
+ status: :too_many_requests
21
90
  else
22
91
  flash[:alert] = "Too many requests. Please try again later."
23
92
  # Bounce back to the rate-limited form's own GET action. Previously this
@@ -26,7 +95,8 @@ module StandardId
26
95
  # API/control-plane that only mounts the engine — `main_app.root_path`
27
96
  # then doesn't exist), and a cross-origin `Referer` (Rails refuses the
28
97
  # redirect as unsafe). `request.path` is always a valid, same-origin GET
29
- # for every rate-limited action here, so it degrades gracefully.
98
+ # for every rate-limited *non-GET* action here, so it degrades
99
+ # gracefully. (GET/HEAD are handled above to avoid a redirect loop.)
30
100
  redirect_to request.path, status: :see_other
31
101
  end
32
102
  end
@@ -12,10 +12,17 @@ module StandardId
12
12
  only: :start,
13
13
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
14
14
 
15
- # RAR-60: Rate limit OTP initiation by target (5 per 15 minutes)
15
+ # RAR-60: Rate limit OTP initiation by target (5 per 15 minutes). A blank
16
+ # target would collapse into one shared "api-passwordless:" bucket
17
+ # (`.compact` does not drop a non-nil empty string), throttling every
18
+ # caller; fall the key back to the remote IP when the target is blank so
19
+ # blank spam stays bounded per-IP without poisoning real targets.
16
20
  rate_limit to: StandardId.config.rate_limits.api_passwordless_start_per_target,
17
21
  within: 15.minutes,
18
- by: -> { "api-passwordless:#{(params[:username] || params[:email] || params[:phone_number]).to_s.strip.downcase}" },
22
+ by: -> {
23
+ target = (params[:username] || params[:email] || params[:phone_number]).to_s.strip.downcase
24
+ "api-passwordless:#{target.presence || request.remote_ip}"
25
+ },
19
26
  name: "passwordless-target",
20
27
  only: :start,
21
28
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
@@ -10,17 +10,29 @@ module StandardId
10
10
 
11
11
  layout "public"
12
12
 
13
- # RAR-51: Rate limit login attempts by IP (20 per 15 minutes)
14
- rate_limit to: StandardId.config.rate_limits.password_login_per_ip,
13
+ # RAR-51: Rate limit login attempts by IP (20 per 15 minutes). Reads the
14
+ # mechanism-agnostic `login_per_ip` alias, falling back to the deprecated
15
+ # `password_login_per_ip` — this action branches password OR passwordless,
16
+ # so on a passwordless app this governs the OTP-send limit, not a password
17
+ # login. See StandardId::RateLimitHandling.login_per_ip.
18
+ rate_limit to: StandardId::RateLimitHandling.login_per_ip,
15
19
  within: 15.minutes,
16
20
  name: "login-ip",
17
21
  only: :create,
18
22
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
19
23
 
20
- # RAR-51: Rate limit login attempts by email target (5 per 15 minutes)
21
- rate_limit to: StandardId.config.rate_limits.password_login_per_email,
24
+ # RAR-51: Rate limit login attempts by email target (5 per 15 minutes).
25
+ # A blank email would otherwise collapse every blank-target request into
26
+ # one shared "login-email:" bucket (`.compact` does not drop a non-nil
27
+ # empty string), so blank spam throttles all users. Fall the key back to
28
+ # the remote IP when the target is blank, keeping it bounded per-IP while
29
+ # a well-formed email keeps its own per-target bucket.
30
+ rate_limit to: StandardId::RateLimitHandling.login_per_email,
22
31
  within: 15.minutes,
23
- by: -> { "login-email:#{params.dig(:login, :email).to_s.strip.downcase}" },
32
+ by: -> {
33
+ email = params.dig(:login, :email).to_s.strip.downcase
34
+ "login-email:#{email.presence || request.remote_ip}"
35
+ },
24
36
  name: "login-email",
25
37
  only: :create,
26
38
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
@@ -16,10 +16,17 @@ module StandardId
16
16
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
17
17
 
18
18
  # Rate limit reset requests by email target (3 per 15 minutes) to blunt
19
- # account-enumeration probing of individual addresses.
19
+ # account-enumeration probing of individual addresses. A blank email
20
+ # would collapse into one shared "reset-password:" bucket (`.compact`
21
+ # does not drop a non-nil empty string), throttling everyone; fall the
22
+ # key back to the remote IP when blank so it stays bounded per-IP without
23
+ # poisoning real targets.
20
24
  rate_limit to: StandardId.config.rate_limits.password_reset_start_per_target,
21
25
  within: 15.minutes,
22
- by: -> { "reset-password:#{params[:email].to_s.strip.downcase}" },
26
+ by: -> {
27
+ email = params[:email].to_s.strip.downcase
28
+ "reset-password:#{email.presence || request.remote_ip}"
29
+ },
23
30
  name: "reset-password-target",
24
31
  only: :create,
25
32
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
@@ -9,10 +9,17 @@ module StandardId
9
9
  only: :create,
10
10
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
11
11
 
12
- # RAR-56: Rate limit verification code generation by email target (3 per 15 minutes)
12
+ # RAR-56: Rate limit verification code generation by email target (3 per
13
+ # 15 minutes). A blank email would collapse into one shared
14
+ # "verify-email:" bucket (`.compact` does not drop a non-nil empty
15
+ # string), throttling everyone; fall the key back to the remote IP when
16
+ # blank so it stays bounded per-IP without poisoning real targets.
13
17
  rate_limit to: StandardId.config.rate_limits.verification_start_per_target,
14
18
  within: 15.minutes,
15
- by: -> { "verify-email:#{params[:email].to_s.strip.downcase}" },
19
+ by: -> {
20
+ email = params[:email].to_s.strip.downcase
21
+ "verify-email:#{email.presence || request.remote_ip}"
22
+ },
16
23
  name: "verify-email-target",
17
24
  only: :create,
18
25
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
@@ -9,10 +9,17 @@ module StandardId
9
9
  only: :create,
10
10
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
11
11
 
12
- # RAR-56: Rate limit verification code generation by phone target (3 per 15 minutes)
12
+ # RAR-56: Rate limit verification code generation by phone target (3 per
13
+ # 15 minutes). A blank phone would collapse into one shared
14
+ # "verify-phone:" bucket (`.compact` does not drop a non-nil empty
15
+ # string), throttling everyone; fall the key back to the remote IP when
16
+ # blank so it stays bounded per-IP without poisoning real targets.
13
17
  rate_limit to: StandardId.config.rate_limits.verification_start_per_target,
14
18
  within: 15.minutes,
15
- by: -> { "verify-phone:#{params[:phone_number].to_s.strip}" },
19
+ by: -> {
20
+ phone = params[:phone_number].to_s.strip
21
+ "verify-phone:#{phone.presence || request.remote_ip}"
22
+ },
16
23
  name: "verify-phone-target",
17
24
  only: :create,
18
25
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
@@ -6,5 +6,13 @@ module StandardId
6
6
  def otp_code_length
7
7
  StandardId::Passwordless.otp_code_length
8
8
  end
9
+
10
+ # The configured OTP resend cooldown, in seconds. The native login_verify
11
+ # view uses this to drive a client-side "resend in Ns" countdown that mirrors
12
+ # the server-side cooldown (passwordless.retry_delay, enforced in
13
+ # BaseStrategy#enforce_retry_delay!). 0 or negative means no cooldown.
14
+ def otp_retry_delay
15
+ StandardId::Passwordless.retry_delay
16
+ end
9
17
  end
10
18
  end
@@ -17,7 +17,7 @@
17
17
  <p role="status"><%= flash[:notice] %></p>
18
18
  <% end %>
19
19
 
20
- <%= form_with url: login_path, method: :post, local: true do |form| %>
20
+ <%= form_with url: login_path, method: :post, local: true, html: { id: "passwordless-login-form" } do |form| %>
21
21
  <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
22
22
 
23
23
  <p>
@@ -26,10 +26,33 @@
26
26
  </p>
27
27
 
28
28
  <p>
29
- <%= form.submit "Continue" %>
29
+ <%= form.submit "Continue", data: { sending_label: "Sending…" } %>
30
30
  </p>
31
31
  <% end %>
32
32
 
33
+ <%#
34
+ Progressive-enhancement double-submit guard. With JS off the form submits
35
+ normally; with JS on we disable the submit button on the first submit so an
36
+ impatient double-click can't fire two OTP requests. Disabling happens IN the
37
+ submit handler — the browser has already serialized the form for the native
38
+ (non-AJAX) submit by then, so the request still goes through. Vanilla JS
39
+ only; the gem ships no JS framework.
40
+ %>
41
+ <script>
42
+ (function () {
43
+ var form = document.getElementById("passwordless-login-form");
44
+ if (!form) return;
45
+ form.addEventListener("submit", function () {
46
+ var btn = form.querySelector('input[type="submit"], button[type="submit"]');
47
+ if (!btn || btn.dataset.stdidSubmitting === "true") return;
48
+ btn.dataset.stdidSubmitting = "true";
49
+ btn.disabled = true;
50
+ var label = btn.getAttribute("data-sending-label") || "Sending…";
51
+ if (btn.tagName === "INPUT") { btn.value = label; } else { btn.textContent = label; }
52
+ });
53
+ })();
54
+ </script>
55
+
33
56
  <% if StandardId.config.google_client_id.present? || StandardId.config.apple_client_id.present? %>
34
57
  <%= render "standard_id/web/login/social_buttons" %>
35
58
  <% end %>
@@ -30,6 +30,54 @@
30
30
  <%= form.submit "Verify", class: "flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400 dark:focus-visible:outline-indigo-500" %>
31
31
  </div>
32
32
  <% end %>
33
+
34
+ <%#
35
+ Resend code. Re-runs the passwordless start path (POST /login with the
36
+ same email), which re-issues an OTP and redirects back here. The server
37
+ enforces a resend cooldown (passwordless.retry_delay) and rejects an early
38
+ resend with a flash alert, so this is safe with JS off. With JS on we mirror
39
+ that cooldown as a visible "Resend in Ns" countdown and keep the button
40
+ disabled until it hits 0. @otp_data comes from require_otp_payload!.
41
+ %>
42
+ <div class="mt-6 text-center text-sm/6 text-gray-500 dark:text-gray-400">
43
+ <%= form_with url: login_path, method: :post, local: true, html: { id: "resend-otp-form", class: "inline" } do |resend_form| %>
44
+ <%= resend_form.hidden_field "login[email]", value: @otp_data[:username] %>
45
+ Didn't get a code?
46
+ <button type="submit" id="resend-otp-button" data-retry-delay="<%= otp_retry_delay %>" class="font-semibold text-indigo-600 hover:text-indigo-500 disabled:cursor-not-allowed disabled:text-gray-400 dark:text-indigo-400 dark:hover:text-indigo-300 dark:disabled:text-gray-500">
47
+ Resend code
48
+ </button>
49
+ <% end %>
50
+ </div>
33
51
  </div>
34
52
  </div>
35
53
  </div>
54
+
55
+ <%#
56
+ Client-side resend cooldown. Progressive enhancement: with JS off the button is
57
+ always active and the server's retry_delay guard is the backstop. Vanilla JS only.
58
+ %>
59
+ <script>
60
+ (function () {
61
+ var btn = document.getElementById("resend-otp-button");
62
+ if (!btn) return;
63
+ var delay = parseInt(btn.getAttribute("data-retry-delay"), 10);
64
+ if (!delay || delay <= 0) return;
65
+
66
+ var label = "Resend code";
67
+ var remaining = delay;
68
+
69
+ function tick() {
70
+ if (remaining <= 0) {
71
+ btn.disabled = false;
72
+ btn.textContent = label;
73
+ return;
74
+ }
75
+ btn.disabled = true;
76
+ btn.textContent = "Resend in " + remaining + "s";
77
+ remaining -= 1;
78
+ window.setTimeout(tick, 1000);
79
+ }
80
+
81
+ tick();
82
+ })();
83
+ </script>
@@ -362,8 +362,14 @@ StandardId.configure do |c|
362
362
  # ---------------------------------------------------------------------------
363
363
  # Defaults are conservative; tune per environment.
364
364
 
365
- # c.rate_limits.password_login_per_ip = 20 # per 15 minutes
366
- # c.rate_limits.password_login_per_email = 5 # per 15 minutes
365
+ # Login limits. The login action branches password OR passwordless, so on a
366
+ # passwordless app these govern the OTP-SEND limit. Prefer the
367
+ # mechanism-agnostic names; the deprecated password_login_* names still work
368
+ # (the new name wins when both are set).
369
+ # c.rate_limits.login_per_ip = 20 # per 15 minutes
370
+ # c.rate_limits.login_per_email = 5 # per 15 minutes
371
+ # c.rate_limits.password_login_per_ip = 20 # deprecated alias of login_per_ip
372
+ # c.rate_limits.password_login_per_email = 5 # deprecated alias of login_per_email
367
373
  # c.rate_limits.otp_verify_per_ip = 20 # per 15 minutes
368
374
  # c.rate_limits.verification_start_per_target = 3 # per 15 minutes
369
375
  # c.rate_limits.verification_start_per_ip = 10 # per hour
@@ -341,9 +341,24 @@ StandardId::ConfigSchema.define do
341
341
 
342
342
  # Rate limiting defaults (used by Rails 8 built-in rate_limit DSL)
343
343
  scope :rate_limits do
344
- # RAR-51: Password login
345
- field :password_login_per_ip, type: :integer, default: 20 # per 15 minutes
346
- field :password_login_per_email, type: :integer, default: 5 # per 15 minutes
344
+ # RAR-51: Login rate limits.
345
+ #
346
+ # Mechanism-agnostic aliases. The `rate_limit` macro that consumes these
347
+ # sits on Web::LoginController#create, which branches password OR
348
+ # passwordless — so on a passwordless app these govern the OTP-SEND limit,
349
+ # not a password login. Prefer these names for new config.
350
+ field :login_per_ip, type: :integer, default: 20 # per 15 minutes
351
+ field :login_per_email, type: :integer, default: 5 # per 15 minutes
352
+
353
+ # Deprecated mechanism-specific names, retained for backwards compatibility.
354
+ # The schema raises ConfigurationError on unknown fields at boot, so these
355
+ # must NOT be removed while hosts still set them. When a host leaves the
356
+ # `login_per_*` alias at its default, the login controller falls back to
357
+ # these values (see StandardId::RateLimitHandling.login_per_ip). New name
358
+ # wins when explicitly set. Mirrors the max_attempts ->
359
+ # max_attempts_per_challenge deprecation-alias precedent.
360
+ field :password_login_per_ip, type: :integer, default: 20 # per 15 minutes; deprecated alias of login_per_ip
361
+ field :password_login_per_email, type: :integer, default: 5 # per 15 minutes; deprecated alias of login_per_email
347
362
 
348
363
  # RAR-60: OTP verification
349
364
  field :otp_verify_per_ip, type: :integer, default: 20 # per 15 minutes
@@ -361,9 +376,16 @@ StandardId::ConfigSchema.define do
361
376
  # Password signup — throttle account-creation spam by IP.
362
377
  field :signup_per_ip, type: :integer, default: 10 # per hour
363
378
 
364
- # API equivalents
379
+ # API OTP-initiation limits — the API counterpart of the web *login* limits
380
+ # (login_per_* / the deprecated password_login_*): the API passwordless
381
+ # `start` action and the web login action both drive the passwordless
382
+ # `start!` strategy. NOT the counterpart of verification_start_*
383
+ # (email/phone verification), which bypasses the strategy via a direct
384
+ # CodeChallenge.create!.
365
385
  field :api_passwordless_start_per_ip, type: :integer, default: 10 # per hour
366
386
  field :api_passwordless_start_per_target, type: :integer, default: 5 # per 15 minutes
387
+
388
+ # OAuth token endpoint — requests per IP.
367
389
  field :api_token_per_ip, type: :integer, default: 30 # per 15 minutes
368
390
 
369
391
  # Optional per-audience tightening on top of the api_token_per_ip
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.28.0"
2
+ VERSION = "0.29.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.28.0
4
+ version: 0.29.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim