standard_id 0.14.4 → 0.16.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +114 -1
  3. data/app/controllers/concerns/standard_id/audience_verification.rb +121 -12
  4. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +117 -36
  5. data/app/controllers/concerns/standard_id/social_authentication.rb +19 -0
  6. data/app/controllers/standard_id/api/oauth/revocations_controller.rb +55 -6
  7. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +6 -0
  8. data/app/controllers/standard_id/web/reset_password/start_controller.rb +27 -1
  9. data/app/controllers/standard_id/web/verify_email/start_controller.rb +1 -7
  10. data/app/controllers/standard_id/web/verify_phone/start_controller.rb +1 -7
  11. data/app/forms/standard_id/web/reset_password_start_form.rb +20 -19
  12. data/app/jobs/standard_id/cleanup_expired_authorization_codes_job.rb +51 -0
  13. data/app/jobs/standard_id/cleanup_expired_code_challenges_job.rb +45 -0
  14. data/app/jobs/standard_id/password_reset_delivery_job.rb +42 -0
  15. data/app/mailers/standard_id/password_reset_mailer.rb +16 -0
  16. data/app/models/concerns/standard_id/account_associations.rb +43 -0
  17. data/app/models/standard_id/authorization_code.rb +15 -2
  18. data/app/models/standard_id/client_application.rb +78 -3
  19. data/app/models/standard_id/code_challenge.rb +6 -1
  20. data/app/views/standard_id/password_reset_mailer/reset_email.html.erb +27 -0
  21. data/app/views/standard_id/password_reset_mailer/reset_email.text.erb +12 -0
  22. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +117 -0
  23. data/lib/generators/standard_id/install/install_generator.rb +174 -1
  24. data/lib/generators/standard_id/install/templates/standard_id.rb +322 -76
  25. data/lib/standard_id/account_locking.rb +23 -10
  26. data/lib/standard_id/account_status.rb +22 -8
  27. data/lib/standard_id/api/token_manager.rb +78 -11
  28. data/lib/standard_id/api_engine.rb +2 -0
  29. data/lib/standard_id/config/callable_validator.rb +163 -0
  30. data/lib/standard_id/config/schema.rb +124 -5
  31. data/lib/standard_id/config/scope_claims_validator.rb +47 -0
  32. data/lib/standard_id/engine.rb +60 -1
  33. data/lib/standard_id/errors.rb +41 -0
  34. data/lib/standard_id/events/definitions.rb +9 -3
  35. data/lib/standard_id/events/subscribers/password_reset_delivery_subscriber.rb +36 -0
  36. data/lib/standard_id/jwt_service.rb +141 -3
  37. data/lib/standard_id/oauth/audience_profile_resolver.rb +93 -0
  38. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +10 -0
  39. data/lib/standard_id/oauth/authorization_flow.rb +8 -0
  40. data/lib/standard_id/oauth/oauth_session_persistence.rb +81 -0
  41. data/lib/standard_id/oauth/token_grant_flow.rb +57 -1
  42. data/lib/standard_id/otp.rb +250 -0
  43. data/lib/standard_id/passwordless/base_strategy.rb +42 -15
  44. data/lib/standard_id/passwordless/verification_service.rb +145 -72
  45. data/lib/standard_id/passwordless.rb +34 -2
  46. data/lib/standard_id/scope_config.rb +79 -4
  47. data/lib/standard_id/session_type_resolver.rb +102 -0
  48. data/lib/standard_id/version.rb +1 -1
  49. data/lib/standard_id/web/token_manager.rb +42 -4
  50. data/lib/standard_id/web_engine.rb +2 -0
  51. data/lib/standard_id.rb +6 -1
  52. data/lib/tasks/standard_id_tasks.rake +56 -4
  53. metadata +15 -1
@@ -14,7 +14,10 @@ module StandardId
14
14
  end
15
15
 
16
16
  def create
17
- form = StandardId::Web::ResetPasswordStartForm.new(email: params[:email])
17
+ form = StandardId::Web::ResetPasswordStartForm.new(
18
+ email: params[:email],
19
+ reset_url_template: build_reset_url_template
20
+ )
18
21
 
19
22
  if form.submit
20
23
  flash[:notice] = "If an account with that email exists, we've sent password reset instructions."
@@ -24,6 +27,29 @@ module StandardId
24
27
  render :show, status: :unprocessable_content
25
28
  end
26
29
  end
30
+
31
+ private
32
+
33
+ # Build a password-reset URL template containing a literal "{token}"
34
+ # placeholder. The async delivery job substitutes the placeholder with
35
+ # the generated token once the account lookup completes. We build this
36
+ # here (rather than in the job) so the URL reflects the request's
37
+ # scheme/host/port — the job has no access to the HTTP request.
38
+ #
39
+ # The route helper may be absent if the host app mounts the engine
40
+ # without the `:password_reset` mechanism, or raise UrlGenerationError
41
+ # if required params are missing; fall back to a request-derived URL
42
+ # in those cases. Any other exception should surface normally.
43
+ def build_reset_url_template
44
+ base = begin
45
+ reset_password_confirm_url
46
+ rescue NameError, NoMethodError, ActionController::UrlGenerationError
47
+ nil
48
+ end
49
+ base ||= "#{request.base_url}/reset_password/confirm"
50
+ separator = base.include?("?") ? "&" : "?"
51
+ "#{base}#{separator}token={token}"
52
+ end
27
53
  end
28
54
  end
29
55
  end
@@ -32,7 +32,7 @@ module StandardId
32
32
  realm: "verification",
33
33
  channel: "email",
34
34
  target: email,
35
- code: generate_otp_code,
35
+ code: StandardId::Passwordless.generate_otp_code,
36
36
  expires_at: 10.minutes.from_now,
37
37
  ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
38
38
  user_agent: request.user_agent
@@ -42,12 +42,6 @@ module StandardId
42
42
 
43
43
  redirect_to standard_id_web.login_path, notice: "Verification code sent to your email", status: :see_other
44
44
  end
45
-
46
- private
47
-
48
- def generate_otp_code
49
- (SecureRandom.random_number(900_000) + 100_000).to_s
50
- end
51
45
  end
52
46
  end
53
47
  end
@@ -32,7 +32,7 @@ module StandardId
32
32
  realm: "verification",
33
33
  channel: "sms",
34
34
  target: phone,
35
- code: generate_otp_code,
35
+ code: StandardId::Passwordless.generate_otp_code,
36
36
  expires_at: 10.minutes.from_now,
37
37
  ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
38
38
  user_agent: request.user_agent
@@ -42,12 +42,6 @@ module StandardId
42
42
 
43
43
  redirect_to standard_id_web.login_path, notice: "Verification code sent via SMS", status: :see_other
44
44
  end
45
-
46
- private
47
-
48
- def generate_otp_code
49
- (SecureRandom.random_number(900_000) + 100_000).to_s
50
- end
51
45
  end
52
46
  end
53
47
  end
@@ -6,32 +6,33 @@ module StandardId
6
6
 
7
7
  attribute :email, :string
8
8
 
9
- attr_reader :password_credential, :token
10
-
11
9
  validates :email, presence: { message: "Please enter your email address" }, format: { with: URI::MailTo::EMAIL_REGEXP }
12
10
 
13
- def submit
14
- return false unless valid?
15
-
16
- if token.present?
17
- # TODO: send reset link via email
18
- end
19
-
20
- true
11
+ # Constructor accepts the reset URL template so the form is decoupled
12
+ # from routing. The controller builds a URL from `reset_password_confirm_url`
13
+ # (or a request-derived fallback) and appends a literal `?token={token}` (or
14
+ # `&token={token}`) marker via string concatenation. The delivery job
15
+ # substitutes that placeholder with the actual token after account lookup.
16
+ def initialize(attributes = {})
17
+ @reset_url_template = attributes.delete(:reset_url_template) if attributes.is_a?(Hash)
18
+ super
21
19
  end
22
20
 
23
- def password_credential
24
- @password_credential ||= identifier&.account&.credentials&.where(credentialable_type: "StandardId::PasswordCredential")&.sole&.credentialable
25
- end
21
+ attr_reader :reset_url_template
26
22
 
27
- def token
28
- @token ||= password_credential&.generate_token_for(:password_reset)
29
- end
23
+ def submit
24
+ return false unless valid?
30
25
 
31
- private
26
+ # Enqueue the full lookup + token generation + mailer delivery pipeline
27
+ # so the controller response time does not depend on whether an account
28
+ # exists for the submitted email. This closes the user-enumeration
29
+ # timing side channel.
30
+ StandardId::PasswordResetDeliveryJob.perform_later(
31
+ email: email.to_s,
32
+ reset_url_template: reset_url_template.to_s
33
+ )
32
34
 
33
- def identifier
34
- @identifier ||= StandardId::EmailIdentifier.find_by(value: email.to_s.strip.downcase)
35
+ true
35
36
  end
36
37
  end
37
38
  end
@@ -0,0 +1,51 @@
1
+ module StandardId
2
+ class CleanupExpiredAuthorizationCodesJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ # Delete OAuth authorization codes that are either expired or consumed
6
+ # beyond the respective grace periods.
7
+ #
8
+ # Authorization codes are single-use and short-lived (OAuth 2.1 recommends
9
+ # a lifetime under 10 minutes). Two grace windows apply:
10
+ #
11
+ # - `grace_period_seconds` (default 7 days): how long expired-but-unused
12
+ # codes are retained after `expires_at`. Matches the sessions/refresh-
13
+ # token cleanup windows so operators only have to reason about one
14
+ # default.
15
+ # - `consumed_grace_period_seconds` (default 1 day): how long consumed
16
+ # codes are retained after `consumed_at`. Used codes are useless after
17
+ # redemption, so they're pruned faster — keeping them briefly only
18
+ # helps with replay-attack forensics in the immediate aftermath of a
19
+ # redemption.
20
+ #
21
+ # Accepts integer seconds for reliable ActiveJob serialization across all
22
+ # queue adapters.
23
+ def perform(grace_period_seconds: 7.days.to_i, consumed_grace_period_seconds: 1.day.to_i)
24
+ expired_cutoff = grace_period_seconds.seconds.ago
25
+ consumed_cutoff = consumed_grace_period_seconds.seconds.ago
26
+
27
+ # The two windows govern disjoint sets of rows:
28
+ # - Unconsumed codes: ruled by `expires_at < expired_cutoff`.
29
+ # - Consumed codes: ruled by `consumed_at < consumed_cutoff`.
30
+ #
31
+ # A naive `expires_at < :expired_cutoff OR consumed_at < :consumed_cutoff`
32
+ # would let the expired arm delete a consumed-12-hours-ago row whose
33
+ # `expires_at` sits 8 days in the past — defeating the consumed grace
34
+ # window's whole purpose. Scoping the expired clause to `consumed_at IS
35
+ # NULL` keeps consumed rows under the consumed window exclusively.
36
+ deleted = StandardId::AuthorizationCode
37
+ .where(
38
+ "(expires_at < :expired_cutoff AND consumed_at IS NULL) " \
39
+ "OR consumed_at < :consumed_cutoff",
40
+ expired_cutoff: expired_cutoff,
41
+ consumed_cutoff: consumed_cutoff
42
+ )
43
+ .delete_all
44
+
45
+ Rails.logger.info(
46
+ "[StandardId] Cleaned up #{deleted} authorization codes " \
47
+ "(expired before #{expired_cutoff}, consumed before #{consumed_cutoff})"
48
+ )
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,45 @@
1
+ module StandardId
2
+ class CleanupExpiredCodeChallengesJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ # Delete code challenges (OTP records) that are either expired or used
6
+ # beyond the respective grace periods.
7
+ #
8
+ # Code challenges back passwordless/OTP flows: rows are short-lived and
9
+ # single-use. Two grace windows apply:
10
+ #
11
+ # - `grace_period_seconds` (default 7 days): how long expired-but-unused
12
+ # challenges are retained after `expires_at`. Matches the sessions/
13
+ # refresh-token cleanup windows for operational consistency.
14
+ # - `used_grace_period_seconds` (default 1 day): how long used challenges
15
+ # are retained after `used_at`. A used OTP is useless after redemption,
16
+ # so prune faster — the short tail only helps with replay-attack
17
+ # forensics immediately after use.
18
+ #
19
+ # Accepts integer seconds for reliable ActiveJob serialization across all
20
+ # queue adapters.
21
+ def perform(grace_period_seconds: 7.days.to_i, used_grace_period_seconds: 1.day.to_i)
22
+ expired_cutoff = grace_period_seconds.seconds.ago
23
+ used_cutoff = used_grace_period_seconds.seconds.ago
24
+
25
+ # See CleanupExpiredAuthorizationCodesJob for the rationale — the two
26
+ # windows govern disjoint sets of rows and a naive OR lets the expired
27
+ # arm prune recently-used challenges out from under the used grace
28
+ # window. Unused challenges follow `expires_at`; used challenges follow
29
+ # `used_at`.
30
+ deleted = StandardId::CodeChallenge
31
+ .where(
32
+ "(expires_at < :expired_cutoff AND used_at IS NULL) " \
33
+ "OR used_at < :used_cutoff",
34
+ expired_cutoff: expired_cutoff,
35
+ used_cutoff: used_cutoff
36
+ )
37
+ .delete_all
38
+
39
+ Rails.logger.info(
40
+ "[StandardId] Cleaned up #{deleted} code challenges " \
41
+ "(expired before #{expired_cutoff}, used before #{used_cutoff})"
42
+ )
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,42 @@
1
+ module StandardId
2
+ # Handles the full password-reset email delivery pipeline asynchronously.
3
+ #
4
+ # Running this work in a job (rather than inline in the request) eliminates
5
+ # the timing side-channel that would otherwise leak whether an account
6
+ # exists for a given email: every request enqueues the same job, so the
7
+ # synchronous request path is constant-time regardless of account state.
8
+ class PasswordResetDeliveryJob < ApplicationJob
9
+ queue_as :default
10
+
11
+ # @param email [String] the raw email submitted by the user
12
+ # @param reset_url_template [String] URL with a literal "{token}" placeholder
13
+ # that the job will substitute with the generated reset token
14
+ def perform(email:, reset_url_template:)
15
+ normalized = email.to_s.strip.downcase
16
+ return if normalized.blank?
17
+
18
+ identifier = StandardId::EmailIdentifier.find_by(value: normalized)
19
+ return if identifier.nil?
20
+
21
+ password_credential = identifier.account
22
+ &.credentials
23
+ &.where(credentialable_type: "StandardId::PasswordCredential")
24
+ &.first
25
+ &.credentialable
26
+ return if password_credential.nil?
27
+
28
+ token = password_credential.generate_token_for(:password_reset)
29
+ return if token.blank?
30
+
31
+ reset_url = reset_url_template.to_s.sub("{token}", token)
32
+
33
+ StandardId::Events.publish(
34
+ StandardId::Events::CREDENTIAL_PASSWORD_RESET_INITIATED,
35
+ account: identifier.account,
36
+ identifier: normalized,
37
+ token: token,
38
+ reset_url: reset_url
39
+ )
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,16 @@
1
+ module StandardId
2
+ class PasswordResetMailer < ApplicationMailer
3
+ layout false
4
+
5
+ def reset_email
6
+ @reset_url = params[:reset_url]
7
+ @email = params[:email]
8
+
9
+ mail(
10
+ to: @email,
11
+ from: StandardId.config.reset_password.mailer_from,
12
+ subject: StandardId.config.reset_password.mailer_subject
13
+ )
14
+ end
15
+ end
16
+ end
@@ -12,6 +12,31 @@ module StandardId
12
12
  accepts_nested_attributes_for :identifiers
13
13
  end
14
14
 
15
+ # Returns the account's StandardId::EmailIdentifier, if any.
16
+ #
17
+ # Uses the in-memory collection when identifiers is already loaded to
18
+ # avoid issuing an extra query (N+1 safety). Falls back to a scoped
19
+ # query otherwise.
20
+ #
21
+ # @return [StandardId::EmailIdentifier, nil]
22
+ def email_identifier
23
+ typed_identifier(StandardId::EmailIdentifier)
24
+ end
25
+
26
+ # Returns the account's StandardId::PhoneNumberIdentifier, if any.
27
+ #
28
+ # @return [StandardId::PhoneNumberIdentifier, nil]
29
+ def phone_number_identifier
30
+ typed_identifier(StandardId::PhoneNumberIdentifier)
31
+ end
32
+
33
+ # Returns the account's StandardId::UsernameIdentifier, if any.
34
+ #
35
+ # @return [StandardId::UsernameIdentifier, nil]
36
+ def username_identifier
37
+ typed_identifier(StandardId::UsernameIdentifier)
38
+ end
39
+
15
40
  class_methods do
16
41
  def find_or_create_by_verified_email!(email, **account_attributes)
17
42
  raise ArgumentError, "email is required" if email.blank?
@@ -54,5 +79,23 @@ module StandardId
54
79
  identifier.account
55
80
  end
56
81
  end
82
+
83
+ private
84
+
85
+ # Fetch the first identifier of the given STI subclass.
86
+ #
87
+ # When the identifiers association is already loaded, filters in memory
88
+ # to avoid triggering an additional query. Otherwise issues a scoped
89
+ # query that returns at most one row.
90
+ #
91
+ # @param klass [Class] subclass of StandardId::Identifier
92
+ # @return [StandardId::Identifier, nil]
93
+ def typed_identifier(klass)
94
+ if association(:identifiers).loaded?
95
+ identifiers.detect { |i| i.is_a?(klass) }
96
+ else
97
+ identifiers.where(type: klass.sti_name).first
98
+ end
99
+ end
57
100
  end
58
101
  end
@@ -18,9 +18,22 @@ module StandardId
18
18
 
19
19
  def self.issue!(plaintext_code:, client_id:, redirect_uri:, scope: nil, audience: nil, account: nil, code_challenge: nil, code_challenge_method: nil, nonce: nil, metadata: {})
20
20
  # Fail fast: reject unsupported PKCE methods at issuance rather than
21
- # storing a code that will always fail at redemption time.
21
+ # storing a code that will always fail at redemption time. When the
22
+ # client record has PKCE required, defer to the client's configured
23
+ # `code_challenge_methods`. In all other cases (client lookup missing,
24
+ # or client opted out of PKCE but still sent a challenge) fall back
25
+ # to an S256-only belt-and-suspenders check so we never silently
26
+ # accept a weaker method.
22
27
  if code_challenge.present?
23
- unless code_challenge_method.to_s.downcase == "s256"
28
+ client = StandardId::ClientApplication.find_by(client_id: client_id)
29
+ method_supported =
30
+ if client&.require_pkce?
31
+ client.supports_pkce_method?(code_challenge_method)
32
+ else
33
+ code_challenge_method.to_s.downcase == "s256"
34
+ end
35
+
36
+ unless method_supported
24
37
  raise StandardId::InvalidRequestError, "Unsupported code_challenge_method: only S256 is allowed"
25
38
  end
26
39
  end
@@ -12,6 +12,7 @@ module StandardId
12
12
  validates :name, presence: true, length: { maximum: 255 }
13
13
  validates :description, length: { maximum: 1000 }
14
14
  validates :redirect_uris, presence: true
15
+ validate :redirect_uris_must_be_absolute_without_query_or_fragment
15
16
  validates :client_type, inclusion: { in: %w[confidential public] }
16
17
  validates :grant_types, presence: true
17
18
  validates :response_types, presence: true
@@ -22,6 +23,11 @@ module StandardId
22
23
  validates :access_token_lifetime, :refresh_token_lifetime, :authorization_code_lifetime,
23
24
  presence: true, numericality: { greater_than: 0 }
24
25
 
26
+ # Security: public clients cannot opt out of PKCE. Public clients run in
27
+ # environments where a client secret cannot be kept confidential, so PKCE
28
+ # is the only protection against authorization code interception.
29
+ validate :public_clients_must_require_pkce
30
+
25
31
  # Scopes
26
32
  scope :active, -> { where(active: true) }
27
33
  scope :confidential, -> { where(client_type: "confidential") }
@@ -75,11 +81,50 @@ module StandardId
75
81
 
76
82
  def supports_pkce_method?(method)
77
83
  return false unless require_pkce?
78
- code_challenge_methods_array.include?(method.to_s)
84
+ normalized = method.to_s.downcase
85
+ code_challenge_methods_array.any? { |m| m.downcase == normalized }
86
+ end
87
+
88
+ # Validates a redirect_uri presented in an OAuth request against this
89
+ # client's registered URIs.
90
+ #
91
+ # OAuth 2.0 (RFC 6749 §3.1.2) requires the authorization server to compare
92
+ # the registered redirect URI and the request redirect URI using simple
93
+ # string comparison, with the exception that the authorization server may
94
+ # redirect with additional query parameters. We implement a stricter
95
+ # scheme+host+port+path match: the *request* URI may add query or fragment
96
+ # segments, but the scheme, host, port, and path must exactly match a
97
+ # registered URI. This prevents a class of "query-string piggyback" attacks
98
+ # where a registered callback at /cb is abused with a crafted query string
99
+ # (or, worse, a different path segment like /cb/evil).
100
+ #
101
+ # Subdomain wildcards are NOT supported — host must match exactly.
102
+ def valid_redirect_uri?(uri)
103
+ requested = self.class.parse_redirect_uri(uri)
104
+ return false unless requested
105
+
106
+ redirect_uris_array.any? do |registered_uri|
107
+ registered = self.class.parse_redirect_uri(registered_uri)
108
+ next false unless registered
109
+
110
+ registered.scheme == requested.scheme &&
111
+ registered.host == requested.host &&
112
+ registered.port == requested.port &&
113
+ registered.path == requested.path
114
+ end
79
115
  end
80
116
 
81
- def valid_redirect_uri?(uri)
82
- redirect_uris_array.include?(uri.to_s)
117
+ # Parse a redirect URI string into a URI object suitable for comparison.
118
+ # Returns nil for unparseable, relative, or scheme-less URIs.
119
+ def self.parse_redirect_uri(value)
120
+ return nil if value.to_s.strip.empty?
121
+
122
+ parsed = URI.parse(value.to_s.strip)
123
+ return nil if parsed.scheme.blank? || parsed.host.blank?
124
+
125
+ parsed
126
+ rescue URI::InvalidURIError
127
+ nil
83
128
  end
84
129
 
85
130
  def confidential?
@@ -127,6 +172,36 @@ module StandardId
127
172
 
128
173
  private
129
174
 
175
+ # Registered redirect URIs must be absolute (include scheme + host) and
176
+ # must NOT carry a query string or fragment. Allowing either would turn
177
+ # the whitelist into a prefix match and enable "query-param piggyback"
178
+ # attacks where a registered callback is reused with attacker-controlled
179
+ # parameters.
180
+ def redirect_uris_must_be_absolute_without_query_or_fragment
181
+ redirect_uris_array.each do |value|
182
+ parsed = self.class.parse_redirect_uri(value)
183
+ if parsed.nil?
184
+ errors.add(:redirect_uris, "contains an invalid URI (#{value.inspect}). Redirect URIs must be absolute (scheme + host)")
185
+ next
186
+ end
187
+
188
+ if parsed.query.present?
189
+ errors.add(:redirect_uris, "must not contain a query string (#{value.inspect}). Register the base URI only; OAuth adds query params at runtime")
190
+ end
191
+
192
+ if parsed.fragment.present?
193
+ errors.add(:redirect_uris, "must not contain a fragment (#{value.inspect})")
194
+ end
195
+ end
196
+ end
197
+
198
+ def public_clients_must_require_pkce
199
+ return unless client_type == "public"
200
+ return if require_pkce?
201
+
202
+ errors.add(:require_pkce, "public clients must have require_pkce enabled")
203
+ end
204
+
130
205
  def generate_client_id
131
206
  self.client_id ||= SecureRandom.hex(16)
132
207
  end
@@ -2,10 +2,15 @@ module StandardId
2
2
  class CodeChallenge < ApplicationRecord
3
3
  self.table_name = "standard_id_code_challenges"
4
4
 
5
+ # Well-known realms used by the engine itself. Host apps may create
6
+ # challenges in any realm (see StandardId::Otp) — realm is a free-form
7
+ # string that partitions challenges by purpose (e.g. "authentication",
8
+ # "verification", "custom_widget"). Only presence is validated so
9
+ # consumers can define their own realms without the engine knowing.
5
10
  REALMS = %w[authentication verification].freeze
6
11
  CHANNELS = %w[email sms].freeze
7
12
 
8
- validates :realm, presence: true, inclusion: { in: REALMS }
13
+ validates :realm, presence: true
9
14
  validates :channel, presence: true, inclusion: { in: CHANNELS }
10
15
  validates :target, presence: true
11
16
  validates :code, presence: true
@@ -0,0 +1,27 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <style>
6
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5; margin: 0; padding: 0; }
7
+ .container { max-width: 480px; margin: 40px auto; background-color: #ffffff; border-radius: 8px; padding: 40px; }
8
+ .button { display: inline-block; padding: 12px 24px; background-color: #111111; color: #ffffff !important; text-decoration: none; border-radius: 6px; font-weight: 600; }
9
+ .footer { margin-top: 32px; font-size: 13px; color: #888888; text-align: center; }
10
+ .fallback { margin-top: 16px; font-size: 13px; color: #555555; word-break: break-all; }
11
+ </style>
12
+ </head>
13
+ <body>
14
+ <div class="container">
15
+ <p>Hi,</p>
16
+ <p>We received a request to reset the password for the account associated with this email address. Click the button below to choose a new password:</p>
17
+ <p style="text-align: center; margin: 24px 0;">
18
+ <a class="button" href="<%= @reset_url %>">Reset your password</a>
19
+ </p>
20
+ <p class="fallback">If the button doesn't work, paste this link into your browser: <%= @reset_url %></p>
21
+ <p>If you did not request a password reset, you can safely ignore this email.</p>
22
+ <div class="footer">
23
+ <p>This is an automated message. Please do not reply.</p>
24
+ </div>
25
+ </div>
26
+ </body>
27
+ </html>
@@ -0,0 +1,12 @@
1
+ Hi,
2
+
3
+ We received a request to reset the password for the account associated with this email address.
4
+
5
+ Use the link below to choose a new password:
6
+
7
+ <%= @reset_url %>
8
+
9
+ If you did not request a password reset, you can safely ignore this email.
10
+
11
+ --
12
+ This is an automated message. Please do not reply.
@@ -0,0 +1,117 @@
1
+ class AddPartialIndexesForActiveSessionAndChallengeLookups < ActiveRecord::Migration[8.0]
2
+ # Run indexes CONCURRENTLY on Postgres so we don't take a long write lock on
3
+ # busy tables. SQLite (dummy app, some host setups) ignores algorithm:; we
4
+ # only pass :concurrently when the adapter is Postgres.
5
+ #
6
+ # StrongMigrations note: every change here is additive and non-destructive
7
+ # except for the GIN drop in step 3. We guard that drop with an `if_exists`
8
+ # check so StrongMigrations/host apps that never ran the creating migration
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.
12
+ #
13
+ # Split into def up / def down because `remove_index :table, name: "..."`
14
+ # (name-only, no column list) is not auto-reversible via def change — Rails
15
+ # raises ActiveRecord::IrreversibleMigration on rollback. The explicit down
16
+ # path re-adds the GIN index using the same column + opclass the creating
17
+ # migration used (t.index :metadata, using: :gin).
18
+ disable_ddl_transaction!
19
+
20
+ def up
21
+ pg = connection.adapter_name.downcase.include?("postgres")
22
+ concurrent = pg ? { algorithm: :concurrently } : {}
23
+
24
+ # ── D3: Partial indexes on hot "active session" lookups ────────────────
25
+ # The existing [:expires_at, :revoked_at] indexes include revoked rows.
26
+ # Most of our hot paths (SessionManager#find_active, cleanup jobs, the
27
+ # revocation controller) only care about revoked_at IS NULL rows, and a
28
+ # partial index on expires_at WHERE revoked_at IS NULL is both smaller
29
+ # and lets Postgres short-circuit the revoked_at check in the plan.
30
+ add_index :standard_id_sessions,
31
+ :expires_at,
32
+ where: "revoked_at IS NULL",
33
+ name: "index_standard_id_sessions_on_expires_at_where_active",
34
+ if_not_exists: true,
35
+ **concurrent
36
+
37
+ add_index :standard_id_refresh_tokens,
38
+ :expires_at,
39
+ where: "revoked_at IS NULL",
40
+ name: "index_standard_id_refresh_tokens_on_expires_at_where_active",
41
+ if_not_exists: true,
42
+ **concurrent
43
+
44
+ # ── D4: code_challenges index rework ───────────────────────────────────
45
+ # The hot-path consumer is Passwordless::VerificationService#find_active_challenge:
46
+ #
47
+ # CodeChallenge.active
48
+ # .where(realm:, channel:, target:)
49
+ # .order(created_at: :desc).first
50
+ #
51
+ # where `active` = used_at IS NULL AND expires_at > NOW(). We can't put
52
+ # `expires_at > NOW()` into a partial index predicate (NOW() isn't
53
+ # immutable), but `used_at IS NULL` is safe and eliminates consumed OTPs.
54
+ #
55
+ # The existing [:realm, :channel, :target, :created_at] index works but
56
+ # covers every row, including long-since-consumed ones. A partial variant
57
+ # 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
64
+
65
+ # Drop the GIN metadata index on Postgres: metadata is only written to
66
+ # (record_failed_attempt bumps `attempts`), never queried with containment
67
+ # (@>, ?, ?|, ?&) operators, so the GIN index is pure write overhead.
68
+ # SQLite never had a GIN index to drop.
69
+ if pg
70
+ remove_index :standard_id_code_challenges,
71
+ name: "index_standard_id_code_challenges_on_metadata",
72
+ if_exists: true,
73
+ **concurrent
74
+ end
75
+
76
+ # ── D5: skipped intentionally ──────────────────────────────────────────
77
+ # standard_id_credentials already has a composite
78
+ # (credentialable_type, credentialable_id) index via `t.references
79
+ # :credentialable, polymorphic: true, index: true`. A search of the
80
+ # codebase shows no queries filtering by credentialable_id alone (callers
81
+ # always know the credentialable_type because Credential uses
82
+ # delegated_type). Adding a single-column credentialable_id index would
83
+ # only add write overhead with no matching read pattern, so we skip it.
84
+ end
85
+
86
+ def down
87
+ pg = connection.adapter_name.downcase.include?("postgres")
88
+ concurrent = pg ? { algorithm: :concurrently } : {}
89
+
90
+ # Re-create the GIN metadata index first (mirrors the creating migration:
91
+ # `t.index :metadata, using: :gin` on standard_id_code_challenges).
92
+ # SQLite never had a GIN index, so this is Postgres-only.
93
+ if pg
94
+ add_index :standard_id_code_challenges,
95
+ :metadata,
96
+ using: :gin,
97
+ name: "index_standard_id_code_challenges_on_metadata",
98
+ if_not_exists: true,
99
+ **concurrent
100
+ end
101
+
102
+ remove_index :standard_id_code_challenges,
103
+ name: "index_code_challenges_on_active_target_created_at",
104
+ if_exists: true,
105
+ **concurrent
106
+
107
+ remove_index :standard_id_refresh_tokens,
108
+ name: "index_standard_id_refresh_tokens_on_expires_at_where_active",
109
+ if_exists: true,
110
+ **concurrent
111
+
112
+ remove_index :standard_id_sessions,
113
+ name: "index_standard_id_sessions_on_expires_at_where_active",
114
+ if_exists: true,
115
+ **concurrent
116
+ end
117
+ end