standard_id 0.41.1 → 0.43.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -0
  3. data/README.md +310 -17
  4. data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
  5. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +1 -0
  6. data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
  7. data/app/controllers/concerns/standard_id/rate_limit_handling.rb +5 -19
  8. data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
  9. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
  10. data/app/controllers/standard_id/web/login_controller.rb +1 -2
  11. data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
  12. data/app/controllers/standard_id/web/verify_email/start_controller.rb +12 -8
  13. data/app/controllers/standard_id/web/verify_phone/start_controller.rb +12 -8
  14. data/app/jobs/standard_id/cleanup_all_job.rb +46 -0
  15. data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
  16. data/app/models/concerns/standard_id/credentiable.rb +8 -1
  17. data/app/models/standard_id/application_record.rb +26 -0
  18. data/app/models/standard_id/authorization_code.rb +3 -1
  19. data/app/models/standard_id/identifier.rb +1 -0
  20. data/app/models/standard_id/session.rb +2 -1
  21. data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
  22. data/app/views/standard_id/web/login/show.html.erb +5 -5
  23. data/app/views/standard_id/web/signup/show.html.erb +3 -3
  24. data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
  25. data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
  26. data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
  27. data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
  28. data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
  29. data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
  30. data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
  31. data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
  32. data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
  33. data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
  34. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
  35. data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
  36. data/lib/generators/standard_id/install/install_generator.rb +64 -3
  37. data/lib/generators/standard_id/install/templates/standard_id.rb +41 -13
  38. data/lib/standard_id/checks/migrations.rb +61 -0
  39. data/lib/standard_id/config/schema.rb +34 -20
  40. data/lib/standard_id/config_schema.rb +94 -6
  41. data/lib/standard_id/deprecator.rb +17 -0
  42. data/lib/standard_id/engine.rb +22 -0
  43. data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +3 -0
  44. data/lib/standard_id/instrumentation.rb +49 -0
  45. data/lib/standard_id/migration_check.rb +183 -0
  46. data/lib/standard_id/migration_helpers.rb +65 -0
  47. data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
  48. data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
  49. data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
  50. data/lib/standard_id/otp.rb +20 -28
  51. data/lib/standard_id/passwordless/base_strategy.rb +18 -14
  52. data/lib/standard_id/passwordless/email_strategy.rb +0 -5
  53. data/lib/standard_id/passwordless/sms_strategy.rb +0 -4
  54. data/lib/standard_id/provider_registry.rb +95 -5
  55. data/lib/standard_id/providers/base.rb +222 -8
  56. data/lib/standard_id/providers/plugin_railtie.rb +59 -0
  57. data/lib/standard_id/scope_config.rb +43 -26
  58. data/lib/standard_id/testing/config_helpers.rb +55 -0
  59. data/lib/standard_id/testing/provider_examples.rb +117 -0
  60. data/lib/standard_id/testing.rb +2 -0
  61. data/lib/standard_id/version.rb +1 -1
  62. data/lib/standard_id.rb +26 -0
  63. metadata +25 -17
  64. data/config/initializers/migration_helpers.rb +0 -32
@@ -61,7 +61,10 @@ module StandardId
61
61
  # @param username [String] the identifier value (email or phone number)
62
62
  # @param code [String] the OTP code to verify
63
63
  # @param connection [String] the delivery channel ("email" or "sms"), defaults to "email"
64
- # @param allow_registration [Boolean] whether to create a new account if none exists (default: true)
64
+ # @param allow_registration [Boolean] whether to create a new account if none exists (default: true).
65
+ # When the including controller resolves a scope (StandardId::LifecycleHooks#current_scope_config)
66
+ # and that scope sets `allow_registration: false`, registration is refused regardless —
67
+ # a scope can only restrict.
65
68
  # @return [StandardId::Passwordless::VerificationService::Result] a result with:
66
69
  # - success? -- true when verification succeeded
67
70
  # - account -- the authenticated/created account (nil on failure)
@@ -77,8 +80,14 @@ module StandardId
77
80
  code: code,
78
81
  connection: connection,
79
82
  request: request,
80
- allow_registration: allow_registration
83
+ allow_registration: StandardId::ScopeConfig.registration_allowed?(allow_registration, passwordless_scope_config)
81
84
  )
82
85
  end
86
+
87
+ # The active ScopeConfig when the controller also includes
88
+ # StandardId::LifecycleHooks; nil otherwise (no scope → no restriction).
89
+ def passwordless_scope_config
90
+ respond_to?(:current_scope_config, true) ? current_scope_config : nil
91
+ end
83
92
  end
84
93
  end
@@ -41,29 +41,15 @@ module StandardId
41
41
  end
42
42
  end
43
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).
44
+ # Effective per-IP login rate limit (`rate_limits.login_per_ip`). The
45
+ # deprecated `password_login_per_ip` fallback was removed in 0.43.
50
46
  def self.login_per_ip
51
- resolve_login_alias(:login_per_ip, :password_login_per_ip)
47
+ StandardId.config.rate_limits.login_per_ip
52
48
  end
53
49
 
54
- # Effective per-email login rate limit; see .login_per_ip.
50
+ # Effective per-email login rate limit (`rate_limits.login_per_email`).
55
51
  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
52
+ StandardId.config.rate_limits.login_per_email
67
53
  end
68
54
 
69
55
  private
@@ -38,7 +38,7 @@ module StandardId
38
38
 
39
39
  emit_social_user_info_fetched(provider, social_info, email)
40
40
 
41
- identifier = StandardId::EmailIdentifier.find_by(value: email)
41
+ identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: email)
42
42
 
43
43
  if identifier.present?
44
44
  validate_social_link!(identifier, provider)
@@ -55,19 +55,12 @@ module StandardId
55
55
  # must not emit. The error re-raises into the standard
56
56
  # handle_oauth_error JSON response.
57
57
  def fetch_provider_user_info
58
- get_user_info_from_provider(flow: resolve_flow_for(provider.provider_name))
58
+ get_user_info_from_provider(flow: provider.flow_for(params))
59
59
  rescue StandardId::OAuthError => e
60
60
  emit_social_auth_failed(e)
61
61
  raise
62
62
  end
63
63
 
64
- def resolve_flow_for(connection)
65
- return :mobile unless connection == "apple"
66
-
67
- flow_param = params[:flow].to_s.downcase
68
- flow_param == "web" ? :web : :mobile
69
- end
70
-
71
64
  # The `except` list is the trust boundary — non-reserved values are
72
65
  # host-supplied opaque attribution data, never interpreted by the gem.
73
66
  def forwarded_request_params
@@ -11,8 +11,7 @@ module StandardId
11
11
  layout "public"
12
12
 
13
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,
14
+ # mechanism-agnostic `login_per_ip` — this action branches password OR passwordless,
16
15
  # so on a passwordless app this governs the OTP-send limit, not a password
17
16
  # login. See StandardId::RateLimitHandling.login_per_ip.
18
17
  rate_limit to: StandardId::RateLimitHandling.login_per_ip,
@@ -106,6 +106,8 @@ module StandardId
106
106
  end
107
107
  end
108
108
 
109
+ # Global web.passwordless_registration AND the active scope's
110
+ # allow_registration (verify_passwordless_otp applies the scope half).
109
111
  def passwordless_registration_enabled?
110
112
  StandardId.config.web.passwordless_registration
111
113
  end
@@ -35,17 +35,21 @@ module StandardId
35
35
  render plain: "missing email", status: :unprocessable_content and return
36
36
  end
37
37
 
38
- challenge = StandardId::CodeChallenge.create!(
38
+ # Issued through the passwordless strategy (validation, retry delay,
39
+ # previous-code invalidation) so delivery goes through the
40
+ # PASSWORDLESS_CODE_GENERATED event like every other OTP. Before 0.43
41
+ # this called the since-removed passwordless_email_sender directly.
42
+ result = StandardId::Otp.issue(
39
43
  realm: "verification",
40
- channel: "email",
41
44
  target: email,
42
- code: StandardId::Passwordless.generate_otp_code,
43
- expires_at: 10.minutes.from_now,
44
- ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
45
- user_agent: request.user_agent
45
+ channel: :email,
46
+ request: request,
47
+ expires_in: 10.minutes
46
48
  )
47
-
48
- StandardId.config.passwordless_email_sender&.call(email, challenge.code)
49
+ unless result.success?
50
+ flash[:alert] = result.error_message
51
+ render plain: "invalid email", status: :unprocessable_content and return
52
+ end
49
53
 
50
54
  redirect_to standard_id_web.login_path, notice: "Verification code sent to your email", status: :see_other
51
55
  end
@@ -35,17 +35,21 @@ module StandardId
35
35
  render plain: "invalid phone", status: :unprocessable_content and return
36
36
  end
37
37
 
38
- challenge = StandardId::CodeChallenge.create!(
38
+ # Issued through the passwordless strategy (validation, retry delay,
39
+ # previous-code invalidation) so delivery goes through the
40
+ # PASSWORDLESS_CODE_GENERATED event like every other OTP. Before 0.43
41
+ # this called the since-removed passwordless_sms_sender directly.
42
+ result = StandardId::Otp.issue(
39
43
  realm: "verification",
40
- channel: "sms",
41
44
  target: phone,
42
- code: StandardId::Passwordless.generate_otp_code,
43
- expires_at: 10.minutes.from_now,
44
- ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
45
- user_agent: request.user_agent
45
+ channel: :sms,
46
+ request: request,
47
+ expires_in: 10.minutes
46
48
  )
47
-
48
- StandardId.config.passwordless_sms_sender&.call(phone, challenge.code)
49
+ unless result.success?
50
+ flash[:alert] = result.error_message
51
+ render plain: "invalid phone", status: :unprocessable_content and return
52
+ end
49
53
 
50
54
  redirect_to standard_id_web.login_path, notice: "Verification code sent via SMS", status: :see_other
51
55
  end
@@ -0,0 +1,46 @@
1
+ module StandardId
2
+ # Runs all four expired-row cleanup jobs inline, in one job.
3
+ #
4
+ # Schedule this ONE job instead of the four individual ones when you want a
5
+ # single recurring entry — typically so the schedule carries one cron
6
+ # monitor (Sentry, Honeybadger, …) rather than four. Subclass it in the host
7
+ # to attach the monitor:
8
+ #
9
+ # # app/jobs/standard_id_cleanup_job.rb
10
+ # class StandardIdCleanupJob < StandardId::CleanupAllJob
11
+ # include Sentry::Cron::MonitorCheckIns
12
+ # sentry_monitor_check_ins slug: "standard-id-cleanup",
13
+ # monitor_config: Sentry::Cron::MonitorConfig.from_crontab("7 * * * *")
14
+ # end
15
+ #
16
+ # One table's DELETE failing (a lock timeout, say) does not skip the others;
17
+ # the first error is re-raised once all four have run, so the run still
18
+ # fails its cron check-in and lands in the queue's failed executions.
19
+ #
20
+ # Each job keeps its default grace windows (see README "Scheduled
21
+ # Maintenance"); retention is bounded by those, not by the cadence.
22
+ class CleanupAllJob < ApplicationJob
23
+ queue_as :default
24
+
25
+ # @return [Array<Class>] the cleanup jobs run, in order
26
+ def self.jobs
27
+ [
28
+ StandardId::CleanupExpiredSessionsJob,
29
+ StandardId::CleanupExpiredRefreshTokensJob,
30
+ StandardId::CleanupExpiredAuthorizationCodesJob,
31
+ StandardId::CleanupExpiredCodeChallengesJob
32
+ ]
33
+ end
34
+
35
+ def perform
36
+ errors = self.class.jobs.filter_map do |job|
37
+ job.perform_now
38
+ nil
39
+ rescue StandardError => e
40
+ Rails.logger.warn("[StandardId] #{job.name} failed: #{e.class}: #{e.message}")
41
+ e
42
+ end
43
+ raise errors.first if errors.any?
44
+ end
45
+ end
46
+ end
@@ -15,7 +15,7 @@ module StandardId
15
15
  normalized = email.to_s.strip.downcase
16
16
  return if normalized.blank?
17
17
 
18
- identifier = StandardId::EmailIdentifier.find_by(value: normalized)
18
+ identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: normalized)
19
19
  return if identifier.nil?
20
20
 
21
21
  password_credential = identifier.account
@@ -3,7 +3,14 @@ module StandardId
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
- has_one :credential, as: :credentialable, touch: true
6
+ # strict_loading: false — `touch: true` makes Rails read this association
7
+ # from inside its own after_create/after_update/after_destroy callbacks
8
+ # (ActiveRecord::Associations::Builder::HasOne.touch_record), which no
9
+ # `includes` at a call site can reach. Without the exemption every save of
10
+ # a PasswordCredential / ClientSecretCredential raises
11
+ # StrictLoadingViolationError in hosts running strict_loading_by_default.
12
+ # It is a one-row has_one read, never an N+1.
13
+ has_one :credential, as: :credentialable, touch: true, strict_loading: false
7
14
  accepts_nested_attributes_for :credential
8
15
 
9
16
  delegate :account, to: :credential
@@ -1,5 +1,31 @@
1
1
  module StandardId
2
2
  class ApplicationRecord < ActiveRecord::Base
3
3
  self.abstract_class = true
4
+
5
+ # Load +associations+ onto already-instantiated +records+ without tripping
6
+ # strict loading.
7
+ #
8
+ # Gem models run under hosts that set `strict_loading_by_default = true`.
9
+ # A lazy `record.account` then raises ActiveRecord::StrictLoadingViolationError
10
+ # even for a single-row belongs_to read. Where the gem did not load the
11
+ # record itself (model callbacks, records handed in by host code) it cannot
12
+ # add `includes` at the query, so it preloads here instead — the supported
13
+ # Rails API, which also skips associations that are already loaded.
14
+ #
15
+ # @return [Array<ActiveRecord::Base>] the records
16
+ def self.preload_associations(records, *associations)
17
+ records = Array(records).compact
18
+ return records if records.empty?
19
+
20
+ ActiveRecord::Associations::Preloader.new(records: records, associations: associations).call
21
+ records
22
+ end
23
+
24
+ private
25
+
26
+ def preload_associations(*associations)
27
+ StandardId::ApplicationRecord.preload_associations(self, *associations)
28
+ self
29
+ end
4
30
  end
5
31
  end
@@ -60,8 +60,10 @@ module StandardId
60
60
  )
61
61
  end
62
62
 
63
+ # Preloads :account — every caller (the token exchange) reads it, and hosts
64
+ # running strict_loading_by_default would otherwise raise on the lazy read.
63
65
  def self.lookup(plaintext_code)
64
- find_by(code_hash: hash_for(plaintext_code))
66
+ includes(:account).find_by(code_hash: hash_for(plaintext_code))
65
67
  end
66
68
 
67
69
  def self.hash_for(plaintext_code)
@@ -36,6 +36,7 @@ module StandardId
36
36
  end
37
37
 
38
38
  def mark_account_verified!
39
+ preload_associations(:account)
39
40
  return if account.nil?
40
41
 
41
42
  return unless account.has_attribute?(:verified)
@@ -181,7 +181,7 @@ module StandardId
181
181
  # loop and leave later sessions without their SESSION_REVOKED event, which
182
182
  # would permanently desync audit-trail consumers from the DB.
183
183
  def self.publish_session_revocations(sessions, account:, reason:, now:)
184
- shared_account = account || sessions.first.account
184
+ shared_account = account || preload_associations(sessions.first, :account).first&.account
185
185
 
186
186
  sessions.each do |session|
187
187
  session.revoked_at = now
@@ -277,6 +277,7 @@ module StandardId
277
277
  end
278
278
 
279
279
  def emit_session_revoked_event
280
+ preload_associations(:account)
280
281
  StandardId::Events.publish(
281
282
  StandardId::Events::SESSION_REVOKED,
282
283
  session: self,
@@ -6,14 +6,14 @@
6
6
  %>
7
7
  <section aria-label="Other sign-in options">
8
8
  <p>Or continue with</p>
9
- <% if StandardId.config.google_client_id.present? %>
9
+ <% if StandardId.social_provider_enabled?(:google) %>
10
10
  <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
11
11
  <%= form.hidden_field :connection, value: "google" %>
12
12
  <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
13
13
  <button type="submit">Google</button>
14
14
  <% end %>
15
15
  <% end %>
16
- <% if StandardId.config.apple_client_id.present? %>
16
+ <% if StandardId.social_provider_enabled?(:apple) %>
17
17
  <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
18
18
  <%= form.hidden_field :connection, value: "apple" %>
19
19
  <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
@@ -53,7 +53,7 @@
53
53
  })();
54
54
  </script>
55
55
 
56
- <% if StandardId.config.google_client_id.present? || StandardId.config.apple_client_id.present? %>
56
+ <% if StandardId.social_provider_enabled?(:google) || StandardId.social_provider_enabled?(:apple) %>
57
57
  <%= render "standard_id/web/login/social_buttons" %>
58
58
  <% end %>
59
59
 
@@ -128,7 +128,7 @@
128
128
  </div>
129
129
  <% end %>
130
130
 
131
- <% if StandardId.config.google_client_id.present? || StandardId.config.apple_client_id.present? %>
131
+ <% if StandardId.social_provider_enabled?(:google) || StandardId.social_provider_enabled?(:apple) %>
132
132
  <div>
133
133
  <div class="mt-10 flex items-center gap-x-6">
134
134
  <div class="w-full flex-1 border-t border-gray-200 dark:border-white/10"></div>
@@ -137,7 +137,7 @@
137
137
  </div>
138
138
 
139
139
  <div class="mt-6 grid grid-cols-2 gap-4">
140
- <% if StandardId.config.google_client_id.present? %>
140
+ <% if StandardId.social_provider_enabled?(:google) %>
141
141
  <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
142
142
  <%= form.hidden_field :connection, value: "google" %>
143
143
  <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
@@ -153,7 +153,7 @@
153
153
  <% end %>
154
154
  <% end %>
155
155
 
156
- <% if StandardId.config.apple_client_id.present? %>
156
+ <% if StandardId.social_provider_enabled?(:apple) %>
157
157
  <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
158
158
  <%= form.hidden_field :connection, value: "apple" %>
159
159
  <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
@@ -185,7 +185,7 @@
185
185
  <main>
186
186
  <h1>Sign in</h1>
187
187
  <p>No login method is enabled.</p>
188
- <% if StandardId.config.google_client_id.present? || StandardId.config.apple_client_id.present? %>
188
+ <% if StandardId.social_provider_enabled?(:google) || StandardId.social_provider_enabled?(:apple) %>
189
189
  <%= render "standard_id/web/login/social_buttons" %>
190
190
  <% end %>
191
191
  </main>
@@ -46,7 +46,7 @@
46
46
  </div>
47
47
  <% end %>
48
48
 
49
- <% if StandardId.config.google_client_id.present? || StandardId.config.apple_client_id.present? %>
49
+ <% if StandardId.social_provider_enabled?(:google) || StandardId.social_provider_enabled?(:apple) %>
50
50
  <div>
51
51
  <div class="mt-10 flex items-center gap-x-6">
52
52
  <div class="w-full flex-1 border-t border-gray-200 dark:border-white/10"></div>
@@ -55,7 +55,7 @@
55
55
  </div>
56
56
 
57
57
  <div class="mt-6 grid grid-cols-2 gap-4">
58
- <% if StandardId.config.google_client_id.present? %>
58
+ <% if StandardId.social_provider_enabled?(:google) %>
59
59
  <%= form_with url: signup_path, method: :post, local: true do |form| %>
60
60
  <%= form.hidden_field :connection, value: "google" %>
61
61
  <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
@@ -71,7 +71,7 @@
71
71
  <% end %>
72
72
  <% end %>
73
73
 
74
- <% if StandardId.config.apple_client_id.present? %>
74
+ <% if StandardId.social_provider_enabled?(:apple) %>
75
75
  <%= form_with url: signup_path, method: :post, local: true do |form| %>
76
76
  <%= form.hidden_field :connection, value: "apple" %>
77
77
  <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdClientApplications < ActiveRecord::Migration[7.1]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_client_applications, id: primary_key_type do |t|
4
6
  # Polymorphic owner association (Account, Organization, etc.)
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdPasswordCredentials < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_password_credentials, id: primary_key_type do |t|
4
6
  t.string :login, null: false, index: { unique: true }
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdIdentifiers < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_identifiers, id: primary_key_type do |t|
4
6
  t.references :account, type: primary_key_type, null: false, foreign_key: { to_table: StandardId.account_class.table_name }, index: true
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdCredentials < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_credentials, id: primary_key_type do |t|
4
6
  t.references :identifier, type: primary_key_type, null: false, foreign_key: { to_table: :standard_id_identifiers }, index: true
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdSessions < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_sessions, id: primary_key_type do |t|
4
6
  t.references :account, type: primary_key_type, null: false, foreign_key: true, index: true
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdClientSecretCredentials < ActiveRecord::Migration[7.1]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_client_secret_credentials, id: primary_key_type do |t|
4
6
  t.string :name, null: false
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdAuthorizationCodes < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_authorization_codes, id: primary_key_type do |t|
4
6
  # Link to account when available (can be nil for pre-auth flows)
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdCodeChallenges < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_code_challenges, id: primary_key_type do |t|
4
6
  t.string :realm, null: false # e.g., authentication, verification
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdRefreshTokens < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_refresh_tokens, id: primary_key_type do |t|
4
6
  t.references :account, type: primary_key_type, null: false, foreign_key: true, index: true
@@ -1,3 +1,4 @@
1
+ # Superseded by 20260416180511's partial index_code_challenges_on_active_target_created_at; hosts that installed that may skip this one (StandardId::MigrationCheck::SUPERSEDED_BY).
1
2
  class AddTargetCreatedAtIndexToCodeChallenges < ActiveRecord::Migration[8.0]
2
3
  def change
3
4
  add_index :standard_id_code_challenges,
@@ -7,8 +7,10 @@ class AddPartialIndexesForActiveSessionAndChallengeLookups < ActiveRecord::Migra
7
7
  # except for the GIN drop in step 3. We guard that drop with an `if_exists`
8
8
  # check so StrongMigrations/host apps that never ran the creating migration
9
9
  # (e.g. SQLite dummies) don't error. StrongMigrations considers partial-
10
- # index-add and remove_index safe when concurrently + if_exists are used, so
11
- # no ignore comment is needed.
10
+ # index-add and remove_index safe when concurrently + if_exists are used —
11
+ # except its "non-unique index with more than three columns" heuristic, which
12
+ # rejects the 4-column code_challenges index below; that one add_index is
13
+ # asserted safe (see #assert_safe and the comment at the call site).
12
14
  #
13
15
  # Split into def up / def down because `remove_index :table, name: "..."`
14
16
  # (name-only, no column list) is not auto-reversible via def change — Rails
@@ -55,12 +57,20 @@ class AddPartialIndexesForActiveSessionAndChallengeLookups < ActiveRecord::Migra
55
57
  # The existing [:realm, :channel, :target, :created_at] index works but
56
58
  # covers every row, including long-since-consumed ones. A partial variant
57
59
  # stays tiny (only live challenges) and matches the exact query shape.
58
- add_index :standard_id_code_challenges,
59
- [:realm, :channel, :target, :created_at],
60
- where: "used_at IS NULL",
61
- name: "index_code_challenges_on_active_target_created_at",
62
- if_not_exists: true,
63
- **concurrent
60
+ #
61
+ # StrongMigrations flags any non-unique index over more than three columns.
62
+ # The shape here is deliberate: three equality predicates plus the ORDER BY
63
+ # column, partial on live challenges, built CONCURRENTLY — so assert it safe
64
+ # rather than make every host wrap it by hand (fundbright, luminality and
65
+ # nutripod all had to).
66
+ assert_safe do
67
+ add_index :standard_id_code_challenges,
68
+ [:realm, :channel, :target, :created_at],
69
+ where: "used_at IS NULL",
70
+ name: "index_code_challenges_on_active_target_created_at",
71
+ if_not_exists: true,
72
+ **concurrent
73
+ end
64
74
 
65
75
  # Drop the GIN metadata index on Postgres: metadata is only written to
66
76
  # (record_failed_attempt bumps `attempts`), never queried with containment
@@ -114,4 +124,11 @@ class AddPartialIndexesForActiveSessionAndChallengeLookups < ActiveRecord::Migra
114
124
  if_exists: true,
115
125
  **concurrent
116
126
  end
127
+
128
+ private
129
+
130
+ # StrongMigrations is optional for hosts (the gem does not depend on it).
131
+ def assert_safe(&block)
132
+ respond_to?(:safety_assured) ? safety_assured(&block) : yield
133
+ end
117
134
  end
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdClientGrants < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_client_grants, id: primary_key_type do |t|
4
6
  # The account that granted consent. Required — consent is per-user.