standard_id 0.3.1 → 0.4.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/standard_id/api_authentication.rb +6 -0
  3. data/app/controllers/concerns/standard_id/controller_policy.rb +99 -0
  4. data/app/controllers/concerns/standard_id/sentry_context.rb +36 -0
  5. data/app/controllers/concerns/standard_id/set_current_request_details.rb +1 -1
  6. data/app/controllers/concerns/standard_id/web_authentication.rb +5 -0
  7. data/app/controllers/standard_id/api/authorization_controller.rb +2 -0
  8. data/app/controllers/standard_id/api/base_controller.rb +1 -0
  9. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +2 -0
  10. data/app/controllers/standard_id/api/oauth/tokens_controller.rb +2 -0
  11. data/app/controllers/standard_id/api/oidc/logout_controller.rb +2 -0
  12. data/app/controllers/standard_id/api/passwordless_controller.rb +2 -0
  13. data/app/controllers/standard_id/api/userinfo_controller.rb +2 -0
  14. data/app/controllers/standard_id/api/well_known/jwks_controller.rb +6 -0
  15. data/app/controllers/standard_id/web/account_controller.rb +2 -0
  16. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +2 -0
  17. data/app/controllers/standard_id/web/base_controller.rb +1 -0
  18. data/app/controllers/standard_id/web/login_controller.rb +2 -0
  19. data/app/controllers/standard_id/web/login_verify_controller.rb +12 -84
  20. data/app/controllers/standard_id/web/logout_controller.rb +7 -0
  21. data/app/controllers/standard_id/web/reset_password/confirm_controller.rb +2 -0
  22. data/app/controllers/standard_id/web/reset_password/start_controller.rb +2 -0
  23. data/app/controllers/standard_id/web/sessions_controller.rb +2 -0
  24. data/app/controllers/standard_id/web/signup_controller.rb +2 -0
  25. data/app/controllers/standard_id/web/verify_email/base_controller.rb +2 -0
  26. data/app/controllers/standard_id/web/verify_email/start_controller.rb +1 -1
  27. data/app/controllers/standard_id/web/verify_phone/base_controller.rb +2 -0
  28. data/app/controllers/standard_id/web/verify_phone/start_controller.rb +1 -1
  29. data/lib/standard_id/api/session_manager.rb +7 -3
  30. data/lib/standard_id/api/token_manager.rb +2 -2
  31. data/lib/standard_id/authorization_bypass.rb +121 -0
  32. data/lib/standard_id/config/schema.rb +2 -0
  33. data/lib/standard_id/jwt_service.rb +41 -15
  34. data/lib/standard_id/oauth/password_flow.rb +5 -1
  35. data/lib/standard_id/oauth/passwordless_otp_flow.rb +10 -61
  36. data/lib/standard_id/passwordless/base_strategy.rb +1 -1
  37. data/lib/standard_id/passwordless/verification_service.rb +227 -0
  38. data/lib/standard_id/testing/authentication_helpers.rb +75 -0
  39. data/lib/standard_id/testing/factories/credentials.rb +24 -0
  40. data/lib/standard_id/testing/factories/identifiers.rb +37 -0
  41. data/lib/standard_id/testing/factories/oauth.rb +89 -0
  42. data/lib/standard_id/testing/factories/sessions.rb +112 -0
  43. data/lib/standard_id/testing/factory_bot.rb +7 -0
  44. data/lib/standard_id/testing/request_helpers.rb +60 -0
  45. data/lib/standard_id/testing.rb +26 -0
  46. data/lib/standard_id/utils/ip_normalizer.rb +16 -0
  47. data/lib/standard_id/version.rb +1 -1
  48. data/lib/standard_id/web/session_manager.rb +14 -1
  49. data/lib/standard_id/web/token_manager.rb +1 -1
  50. data/lib/standard_id.rb +7 -0
  51. metadata +42 -1
@@ -0,0 +1,121 @@
1
+ module StandardId
2
+ module AuthorizationBypass
3
+ FRAMEWORK_CALLBACKS = {
4
+ action_policy: :verify_authorized,
5
+ pundit: :verify_authorized,
6
+ cancancan: :check_authorization
7
+ }.freeze
8
+
9
+ MUTEX = Mutex.new
10
+ private_constant :MUTEX
11
+
12
+ class << self
13
+ # Skips the host app's authorization callback on all engine controllers,
14
+ # and also skips authenticate_account! on public controllers (login,
15
+ # signup, callbacks, etc.) since those must be accessible without a session.
16
+ #
17
+ # In production (eager_load=true), controllers are already loaded when
18
+ # this runs so the registry is populated. In development (eager_load=false),
19
+ # controllers are loaded lazily on first request; newly registered
20
+ # controllers receive skips immediately via apply_to_controller (called
21
+ # from ControllerPolicy.register). The to_prepare block handles class
22
+ # reloading — after Zeitwerk unloads/reloads classes, the freshly loaded
23
+ # controllers re-register and receive skips again.
24
+ def apply(framework: nil, callback: nil)
25
+ if framework && callback
26
+ raise ArgumentError, "Provide framework: or callback:, not both"
27
+ end
28
+
29
+ register_prepare = false
30
+
31
+ MUTEX.synchronize do
32
+ # Guard against duplicate to_prepare registrations if called more than
33
+ # once (e.g. in tests or misconfigured initializers). skip_before_action
34
+ # is idempotent so duplicates are harmless, but this keeps things tidy.
35
+ return if @callback_name
36
+
37
+ @callback_name = resolve_callback(framework, callback)
38
+ # @prepared is intentionally NOT cleared by reset!. This ensures
39
+ # at most one to_prepare block is registered per process lifetime.
40
+ # Trade-off: after reset! + apply (e.g. in tests switching
41
+ # frameworks), the to_prepare code path is not re-registered, so
42
+ # it can only be verified by the first test that calls apply.
43
+ register_prepare = !@prepared
44
+ @prepared = true
45
+ end
46
+
47
+ apply_skips!
48
+
49
+ # Re-apply after class reloading in development. In dev (eager_load=false),
50
+ # reset_registry! + apply_skips! is effectively a no-op because the
51
+ # registry is empty at this point — lazy-loaded controllers haven't
52
+ # registered yet. The real work for lazy-loaded controllers is done by
53
+ # apply_to_controller (called from ControllerPolicy.register). This
54
+ # block is still needed because after a Zeitwerk reload, controllers
55
+ # re-register and apply_to_controller fires again for each one, but the
56
+ # reset_registry! here clears stale references to the old class objects
57
+ # to prevent memory leaks in long dev sessions.
58
+ return unless register_prepare
59
+
60
+ Rails.application.config.to_prepare do
61
+ StandardId::ControllerPolicy.reset_registry!
62
+ StandardId::AuthorizationBypass.apply_skips!
63
+ end
64
+ end
65
+
66
+ # Whether apply has been called. Used by ControllerPolicy.register to
67
+ # decide if newly loaded controllers need immediate skip_before_action.
68
+ def applied?
69
+ MUTEX.synchronize { !@callback_name.nil? }
70
+ end
71
+
72
+ # Apply skips to a single controller. Called by ControllerPolicy.register
73
+ # when a controller is lazily loaded after apply has already been called.
74
+ def apply_to_controller(controller, policy)
75
+ callback = MUTEX.synchronize { @callback_name }
76
+ return unless callback
77
+
78
+ controller.skip_before_action callback, raise: false
79
+ if policy == :public
80
+ # authenticate_account! is defined in WebAuthentication, not on API
81
+ # controllers. raise: false ensures this is a safe no-op for API
82
+ # controllers that don't have the callback.
83
+ controller.skip_before_action :authenticate_account!, raise: false
84
+ end
85
+ end
86
+
87
+ # @api private — called internally by apply and the to_prepare block.
88
+ # Must remain public because it is invoked from a to_prepare lambda
89
+ # registered in apply, which executes outside this module's scope.
90
+ def apply_skips!
91
+ StandardId::ControllerPolicy.registry_snapshot.each do |policy, controllers|
92
+ controllers.each { |controller| apply_to_controller(controller, policy) }
93
+ end
94
+ end
95
+
96
+ # @api private — intended for test isolation only.
97
+ # NOTE: This clears @callback_name (so applied? returns false and apply
98
+ # can be called again with a different framework) but intentionally does
99
+ # NOT clear @prepared, so no additional to_prepare block is registered.
100
+ def reset!
101
+ MUTEX.synchronize { @callback_name = nil }
102
+ end
103
+
104
+ private
105
+
106
+ def resolve_callback(framework, callback)
107
+ if callback
108
+ callback.to_sym
109
+ elsif framework
110
+ FRAMEWORK_CALLBACKS.fetch(framework.to_sym) do
111
+ raise ArgumentError, "Unknown framework: #{framework}. " \
112
+ "Supported: #{FRAMEWORK_CALLBACKS.keys.join(', ')}. " \
113
+ "Or pass callback: :your_callback_name instead."
114
+ end
115
+ else
116
+ raise ArgumentError, "Provide either framework: or callback:"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -14,8 +14,10 @@ StandardConfig.schema.draw do
14
14
  field :issuer, type: :string, default: nil
15
15
  field :login_url, type: :string, default: nil
16
16
  field :allowed_post_logout_redirect_uris, type: :array, default: []
17
+ field :account_scope, type: :any, default: nil
17
18
  field :use_inertia, type: :boolean, default: false
18
19
  field :inertia_component_namespace, type: :string, default: "standard_id"
20
+ field :alias_current_user, type: :boolean, default: false
19
21
  end
20
22
 
21
23
  scope :events do
@@ -1,5 +1,6 @@
1
1
  require "jwt"
2
2
  require "concurrent/delay"
3
+ require "concurrent/atomic/atomic_reference"
3
4
  require "openssl"
4
5
  require "digest"
5
6
 
@@ -34,6 +35,11 @@ module StandardId
34
35
  end
35
36
  end
36
37
 
38
+ @signing_key_ref = Concurrent::AtomicReference.new
39
+ @key_id_ref = Concurrent::AtomicReference.new
40
+ @previous_keys_ref = Concurrent::AtomicReference.new
41
+ @jwks_ref = Concurrent::AtomicReference.new
42
+
37
43
  def self.session_class
38
44
  SESSION_CLASS.value
39
45
  end
@@ -52,7 +58,11 @@ module StandardId
52
58
 
53
59
  def self.signing_key
54
60
  if asymmetric?
55
- @signing_key_cache ||= parse_private_key(StandardId.config.oauth.signing_key)
61
+ @signing_key_ref.get || begin
62
+ computed = parse_private_key(StandardId.config.oauth.signing_key)
63
+ @signing_key_ref.compare_and_set(nil, computed)
64
+ @signing_key_ref.get
65
+ end
56
66
  else
57
67
  Rails.application.secret_key_base
58
68
  end
@@ -74,16 +84,24 @@ module StandardId
74
84
 
75
85
  # Generate stable key ID from public key fingerprint
76
86
  # Use public_to_pem which works for both RSA and EC keys
77
- @key_id ||= Digest::SHA256.hexdigest(signing_key.public_to_pem)[0..7]
87
+ @key_id_ref.get || begin
88
+ computed = Digest::SHA256.hexdigest(signing_key.public_to_pem)[0..7]
89
+ @key_id_ref.compare_and_set(nil, computed)
90
+ @key_id_ref.get
91
+ end
78
92
  end
79
93
 
80
94
  def self.previous_keys
81
95
  return [] unless asymmetric?
82
96
 
83
- @previous_keys_cache ||= Array(StandardId.config.oauth.previous_signing_keys).filter_map do |entry|
84
- parse_previous_key_entry(entry)
85
- rescue StandardError
86
- nil
97
+ @previous_keys_ref.get || begin
98
+ computed = Array(StandardId.config.oauth.previous_signing_keys).filter_map do |entry|
99
+ parse_previous_key_entry(entry)
100
+ rescue StandardError
101
+ nil
102
+ end
103
+ @previous_keys_ref.compare_and_set(nil, computed)
104
+ @previous_keys_ref.get
87
105
  end
88
106
  end
89
107
 
@@ -93,11 +111,15 @@ module StandardId
93
111
  [{ kid: key_id, key: verification_key, algorithm: algorithm }] + previous_keys
94
112
  end
95
113
 
114
+ # NOTE: Individual resets are atomic but the group is not — a concurrent
115
+ # reader between two .set(nil) calls may see a mix of old and new values.
116
+ # This is acceptable: key rotation is an infrequent operator action and
117
+ # the worst case is one request using a stale (but still valid) key.
96
118
  def self.reset_cached_key!
97
- @key_id = nil
98
- @signing_key_cache = nil
99
- @previous_keys_cache = nil
100
- @jwks = nil
119
+ @key_id_ref.set(nil)
120
+ @signing_key_ref.set(nil)
121
+ @previous_keys_ref.set(nil)
122
+ @jwks_ref.set(nil)
101
123
  end
102
124
 
103
125
  def self.encode(payload, expires_in: 1.hour)
@@ -168,12 +190,16 @@ module StandardId
168
190
  def self.jwks
169
191
  return nil unless asymmetric?
170
192
 
171
- @jwks ||= begin
172
- exported_keys = all_verification_keys.map do |entry|
173
- jwk = JWT::JWK.new(entry[:key], kid: entry[:kid]).export
174
- jwk.merge(alg: entry[:algorithm], use: "sig")
193
+ @jwks_ref.get || begin
194
+ computed = begin
195
+ exported_keys = all_verification_keys.map do |entry|
196
+ jwk = JWT::JWK.new(entry[:key], kid: entry[:kid]).export
197
+ jwk.merge(alg: entry[:algorithm], use: "sig")
198
+ end
199
+ { keys: exported_keys }
175
200
  end
176
- { keys: exported_keys }
201
+ @jwks_ref.compare_and_set(nil, computed)
202
+ @jwks_ref.get
177
203
  end
178
204
  end
179
205
 
@@ -1,9 +1,13 @@
1
+ require "concurrent/delay"
2
+
1
3
  module StandardId
2
4
  module Oauth
3
5
  class PasswordFlow < TokenGrantFlow
4
6
  expect_params :username, :password, :client_id
5
7
  permit_params :client_secret, :audience, :scope, :realm
6
8
 
9
+ DUMMY_PASSWORD_DIGEST = Concurrent::Delay.new { BCrypt::Password.create("").freeze }
10
+
7
11
  def authenticate!
8
12
  validate_client_secret!(params[:client_id], params[:client_secret]) if params[:client_secret].present?
9
13
  emit_authentication_started
@@ -95,7 +99,7 @@ module StandardId
95
99
  end
96
100
 
97
101
  def dummy_password_digest
98
- @dummy_password_digest ||= BCrypt::Password.create("").freeze
102
+ DUMMY_PASSWORD_DIGEST.value
99
103
  end
100
104
 
101
105
  def default_scope
@@ -6,53 +6,22 @@ module StandardId
6
6
 
7
7
  def authenticate!
8
8
  validate_client_secret!(params[:client_id], params[:client_secret]) if params[:client_secret].present?
9
+ validate_requested_scope!
9
10
 
10
- if code_challenge.blank?
11
- emit_otp_validation_failed
12
- raise StandardId::InvalidGrantError, "Invalid or expired verification code"
13
- end
11
+ @verification_result = StandardId::Passwordless::VerificationService.verify(
12
+ connection: params[:connection],
13
+ username: params[:username],
14
+ code: params[:otp],
15
+ request: request
16
+ )
14
17
 
15
- if account.blank?
16
- raise StandardId::InvalidGrantError, "Unable to authenticate user"
18
+ unless @verification_result.success?
19
+ raise StandardId::InvalidGrantError, @verification_result.error
17
20
  end
18
-
19
- validate_requested_scope!
20
-
21
- code_challenge.use!
22
- emit_otp_validated
23
21
  end
24
22
 
25
23
  private
26
24
 
27
- def emit_otp_validated
28
- StandardId::Events.publish(
29
- StandardId::Events::OTP_VALIDATED,
30
- account: account,
31
- channel: params[:connection]
32
- )
33
- StandardId::Events.publish(
34
- StandardId::Events::PASSWORDLESS_CODE_VERIFIED,
35
- code_challenge: code_challenge,
36
- account: account,
37
- channel: params[:connection]
38
- )
39
- end
40
-
41
- def emit_otp_validation_failed
42
- StandardId::Events.publish(
43
- StandardId::Events::OTP_VALIDATION_FAILED,
44
- identifier: params[:username],
45
- channel: params[:connection],
46
- attempts: nil
47
- )
48
- StandardId::Events.publish(
49
- StandardId::Events::PASSWORDLESS_CODE_FAILED,
50
- identifier: params[:username],
51
- channel: params[:connection],
52
- attempts: nil
53
- )
54
- end
55
-
56
25
  def subject_id
57
26
  account.id
58
27
  end
@@ -77,28 +46,8 @@ module StandardId
77
46
  true
78
47
  end
79
48
 
80
- def code_challenge
81
- @code_challenge ||= StandardId::CodeChallenge.active.find_by(
82
- realm: "authentication",
83
- channel: params[:connection],
84
- target: params[:username],
85
- code: params[:otp]
86
- )
87
- end
88
-
89
49
  def account
90
- @account ||= strategy_for(params[:connection]).find_or_create_account(params[:username])
91
- end
92
-
93
- def strategy_for(connection)
94
- case connection
95
- when "email"
96
- StandardId::Passwordless::EmailStrategy.new(request)
97
- when "sms"
98
- StandardId::Passwordless::SmsStrategy.new(request)
99
- else
100
- raise StandardId::InvalidRequestError, "Unsupported connection type: #{connection}"
101
- end
50
+ @verification_result&.account
102
51
  end
103
52
 
104
53
  def validate_requested_scope!
@@ -35,7 +35,7 @@ module StandardId
35
35
  target: username,
36
36
  code: code,
37
37
  expires_at: StandardId.config.passwordless.code_ttl.seconds.from_now,
38
- ip_address: request.remote_ip,
38
+ ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
39
39
  user_agent: request.user_agent
40
40
  )
41
41
  cc
@@ -0,0 +1,227 @@
1
+ module StandardId
2
+ module Passwordless
3
+ class VerificationService
4
+ # Result object returned by .verify.
5
+ # - success?: true/false
6
+ # - account: the resolved account (nil on failure)
7
+ # - challenge: the consumed CodeChallenge (nil on failure)
8
+ # - error: error message string (nil on success)
9
+ # - attempts: nil on success, 0 when no challenge was found (fabricated
10
+ # target), or 1+ for wrong-code failures against an active challenge
11
+ Result = Data.define(:success?, :account, :challenge, :error, :attempts)
12
+
13
+ STRATEGY_MAP = {
14
+ "email" => StandardId::Passwordless::EmailStrategy,
15
+ "sms" => StandardId::Passwordless::SmsStrategy
16
+ }.freeze
17
+
18
+ class << self
19
+ # Verify a passwordless OTP code and resolve the account.
20
+ #
21
+ # @param email [String, nil] The email address (mutually exclusive with phone)
22
+ # @param phone [String, nil] The phone number (mutually exclusive with email)
23
+ # @param connection [String, nil] Channel type ("email" or "sms") — convenience
24
+ # alternative to email:/phone: (use with username:)
25
+ # @param username [String, nil] The identifier value — used with connection:
26
+ # @param code [String] The OTP code to verify
27
+ # @param request [ActionDispatch::Request] The current request (needed for strategy)
28
+ # @return [Result] A result object with success?, account, challenge, error, and attempts
29
+ #
30
+ # OTP_VALIDATION_FAILED / PASSWORDLESS_CODE_FAILED events are only emitted
31
+ # when an active challenge exists but the code is wrong. Requests with no
32
+ # matching challenge (e.g. fabricated usernames) do not emit failure events
33
+ # — this avoids noise from speculative probes that never triggered a code.
34
+ # NOTE: This is a behavioral change from the pre-extraction API flow
35
+ # (PasswordlessOtpFlow), which emitted failure events unconditionally.
36
+ #
37
+ # @example Using connection/username (preferred for callers with channel info)
38
+ # result = StandardId::Passwordless::VerificationService.verify(
39
+ # connection: "email",
40
+ # username: "user@example.com",
41
+ # code: "123456",
42
+ # request: request
43
+ # )
44
+ #
45
+ # @example Using email/phone directly
46
+ # result = StandardId::Passwordless::VerificationService.verify(
47
+ # email: "user@example.com",
48
+ # code: "123456",
49
+ # request: request
50
+ # )
51
+ # if result.success?
52
+ # sign_in(result.account)
53
+ # else
54
+ # render_error(result.error)
55
+ # end
56
+ #
57
+ def verify(email: nil, phone: nil, code:, request:, connection: nil, username: nil)
58
+ # Allow callers to use connection:/username: instead of email:/phone:
59
+ if connection.present?
60
+ if username.blank?
61
+ raise StandardId::InvalidRequestError, "username: is required when connection: is provided"
62
+ end
63
+
64
+ case connection.to_s
65
+ when "email" then email = username
66
+ when "sms" then phone = username
67
+ else raise StandardId::InvalidRequestError, "Unsupported connection type: #{connection}"
68
+ end
69
+ end
70
+
71
+ new(email: email, phone: phone, code: code, request: request).verify
72
+ end
73
+ end
74
+
75
+ def initialize(email: nil, phone: nil, code:, request:)
76
+ @code = code.to_s.strip
77
+ @request = request
78
+ resolve_target_and_channel!(email, phone)
79
+ end
80
+
81
+ def verify
82
+ if @code.blank?
83
+ return failure("Code is required")
84
+ end
85
+
86
+ challenge = find_active_challenge
87
+ code_matches = challenge.present? && secure_compare(challenge.code, @code)
88
+ attempts = record_failed_attempt(challenge, code_matches)
89
+
90
+ unless code_matches
91
+ emit_otp_validation_failed(attempts) if challenge.present?
92
+ return failure("Invalid or expired verification code", attempts: attempts)
93
+ end
94
+
95
+ # Re-fetch with lock inside a transaction to prevent concurrent use.
96
+ result = nil
97
+ ActiveRecord::Base.transaction do
98
+ locked_challenge = StandardId::CodeChallenge.lock.find(challenge.id)
99
+ unless locked_challenge.active?
100
+ # No OTP_VALIDATION_FAILED event here: the code was correct but the
101
+ # challenge was consumed by a concurrent request — not an attacker
102
+ # guessing codes. Emitting a failure event would be misleading.
103
+ result = failure("Invalid or expired verification code", attempts: attempts)
104
+ raise ActiveRecord::Rollback
105
+ end
106
+
107
+ strategy = strategy_for(@channel)
108
+ account = strategy.find_or_create_account(@target)
109
+
110
+ locked_challenge.use!
111
+
112
+ result = success(account: account, challenge: locked_challenge)
113
+ end
114
+
115
+ raise "BUG: transaction block failed to set result" if result.nil?
116
+
117
+ # Emit events after the transaction commits so subscribers never see
118
+ # events for rolled-back state.
119
+ emit_otp_validated(result.account, result.challenge) if result.success?
120
+
121
+ result
122
+ rescue ActiveRecord::RecordNotFound
123
+ failure("Invalid or expired verification code")
124
+ rescue ActiveRecord::RecordInvalid => e
125
+ failure("Unable to complete verification: #{e.record.errors.full_messages.to_sentence}")
126
+ end
127
+
128
+ private
129
+
130
+ def resolve_target_and_channel!(email, phone)
131
+ if email.present?
132
+ @target = email.to_s.strip
133
+ @channel = "email"
134
+ elsif phone.present?
135
+ @target = phone.to_s.strip
136
+ @channel = "sms"
137
+ else
138
+ raise StandardId::InvalidRequestError, "Either email: or phone: must be provided"
139
+ end
140
+ end
141
+
142
+ def find_active_challenge
143
+ StandardId::CodeChallenge.active.find_by(
144
+ realm: "authentication",
145
+ channel: @channel,
146
+ target: @target
147
+ )
148
+ end
149
+
150
+ # NOTE: The update! here can raise ActiveRecord::RecordInvalid, which is
151
+ # rescued alongside account-creation errors. This is intentional — both
152
+ # represent unexpected persistence failures and warrant the same response.
153
+ def record_failed_attempt(challenge, code_matches)
154
+ return 0 if challenge.blank?
155
+ return 0 if code_matches
156
+
157
+ attempts = (challenge.metadata["attempts"] || 0) + 1
158
+ challenge.update!(metadata: challenge.metadata.merge("attempts" => attempts))
159
+
160
+ max_attempts = StandardId.config.passwordless.max_attempts
161
+ challenge.use! if attempts >= max_attempts
162
+
163
+ attempts
164
+ end
165
+
166
+ def secure_compare(a, b)
167
+ ActiveSupport::SecurityUtils.secure_compare(a.to_s, b.to_s)
168
+ end
169
+
170
+ def strategy_for(channel)
171
+ klass = STRATEGY_MAP[channel]
172
+ raise StandardId::InvalidRequestError, "Unsupported connection type: #{channel}" unless klass
173
+ klass.new(@request)
174
+ end
175
+
176
+ def emit_otp_validated(account, challenge)
177
+ StandardId::Events.publish(
178
+ StandardId::Events::OTP_VALIDATED,
179
+ account: account,
180
+ channel: @channel
181
+ )
182
+ StandardId::Events.publish(
183
+ StandardId::Events::PASSWORDLESS_CODE_VERIFIED,
184
+ code_challenge: challenge,
185
+ account: account,
186
+ channel: @channel
187
+ )
188
+ end
189
+
190
+ def emit_otp_validation_failed(attempts)
191
+ StandardId::Events.publish(
192
+ StandardId::Events::OTP_VALIDATION_FAILED,
193
+ identifier: @target,
194
+ channel: @channel,
195
+ attempts: attempts
196
+ )
197
+ StandardId::Events.publish(
198
+ StandardId::Events::PASSWORDLESS_CODE_FAILED,
199
+ identifier: @target,
200
+ channel: @channel,
201
+ attempts: attempts
202
+ )
203
+ end
204
+
205
+ def success(account:, challenge:)
206
+ Result.new(
207
+ "success?": true,
208
+ account: account,
209
+ challenge: challenge,
210
+ error: nil,
211
+ attempts: nil
212
+ )
213
+ end
214
+
215
+ # attempts is nil on success (not meaningful) and 0 when no challenge was found.
216
+ def failure(error, attempts: nil)
217
+ Result.new(
218
+ "success?": false,
219
+ account: nil,
220
+ challenge: nil,
221
+ error: error,
222
+ attempts: attempts
223
+ )
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,75 @@
1
+ module StandardId
2
+ module Testing
3
+ # Helpers for stubbing StandardId authentication in controller and request specs.
4
+ #
5
+ # Usage in rails_helper.rb:
6
+ #
7
+ # require "standard_id/testing"
8
+ #
9
+ # RSpec.configure do |config|
10
+ # config.include StandardId::Testing::AuthenticationHelpers, type: :request
11
+ # config.include StandardId::Testing::AuthenticationHelpers, type: :controller
12
+ # end
13
+ #
14
+ module AuthenticationHelpers
15
+ # Stub web (cookie-based) authentication for controllers that include
16
+ # StandardId::WebAuthentication.
17
+ #
18
+ # Stubs the following methods on the given controller class:
19
+ # - current_account → returns the given account
20
+ # - authenticated? → true when account is present
21
+ # - authenticate_account! → no-op (returns true)
22
+ # - require_browser_session! → no-op (returns true)
23
+ #
24
+ # @param account [Object] the account to return from current_account
25
+ # @param controller_class [Class] the controller class to stub (default: ApplicationController)
26
+ #
27
+ # Example:
28
+ # stub_web_authentication(account: my_account)
29
+ # stub_web_authentication(account: my_account, controller_class: BackendController)
30
+ #
31
+ def stub_web_authentication(account:, controller_class: ApplicationController)
32
+ allow_any_instance_of(controller_class).to receive(:current_account).and_return(account)
33
+ allow_any_instance_of(controller_class).to receive(:authenticated?).and_return(account.present?)
34
+ allow_any_instance_of(controller_class).to receive(:authenticate_account!).and_return(true)
35
+ allow_any_instance_of(controller_class).to receive(:require_browser_session!).and_return(true)
36
+ end
37
+
38
+ # Stub API (JWT-based) authentication for controllers that include
39
+ # StandardId::ApiAuthentication.
40
+ #
41
+ # @param account [Object] the account to return from current_account
42
+ # @param session [Object, nil] optional session object for current_session
43
+ # @param controller_class [Class] the controller class to stub (default: inferred)
44
+ #
45
+ # Example:
46
+ # stub_api_authentication(account: my_account)
47
+ # stub_api_authentication(account: my_account, session: mock_session)
48
+ #
49
+ # When controller_class is not provided, defaults to Api::BaseController — the
50
+ # conventional base class generated by StandardId's install generator.
51
+ #
52
+ # Note: Uses allow_any_instance_of which RSpec discourages. For more targeted
53
+ # stubbing, pass the exact controller class your spec exercises:
54
+ #
55
+ # stub_api_authentication(account: my_account, controller_class: Api::V1::WidgetsController)
56
+ #
57
+ def stub_api_authentication(account:, session: nil, controller_class: nil)
58
+ if controller_class.nil?
59
+ unless defined?(Api::BaseController)
60
+ raise ArgumentError,
61
+ "Could not infer API controller class. Api::BaseController is not defined. " \
62
+ "Pass controller_class: explicitly (e.g. controller_class: YourApi::BaseController)."
63
+ end
64
+
65
+ controller_class = Api::BaseController
66
+ end
67
+
68
+ allow_any_instance_of(controller_class).to receive(:current_account).and_return(account)
69
+ allow_any_instance_of(controller_class).to receive(:authenticated?).and_return(account.present?)
70
+ allow_any_instance_of(controller_class).to receive(:verify_access_token!).and_return(true)
71
+ allow_any_instance_of(controller_class).to receive(:current_session).and_return(session)
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,24 @@
1
+ FactoryBot.define do
2
+ factory :standard_id_password_credential, class: "StandardId::PasswordCredential" do
3
+ sequence(:login) { |n| "user#{n}@example.com" }
4
+ password { "password123" }
5
+ password_confirmation { "password123" }
6
+ end
7
+
8
+ factory :standard_id_credential, class: "StandardId::Credential" do
9
+ association :identifier, factory: [:standard_id_email_identifier, :verified]
10
+ association :credentialable, factory: :standard_id_password_credential
11
+
12
+ # Sync login to identifier value so authentication works out of the box.
13
+ # If you override credentialable after build, set login manually.
14
+ after(:build) do |credential|
15
+ credential.credentialable.login = credential.identifier.value
16
+ end
17
+ end
18
+
19
+ factory :standard_id_client_secret_credential, class: "StandardId::ClientSecretCredential" do
20
+ association :client_application, factory: :standard_id_client_application
21
+ name { "Default Secret" }
22
+ active { true }
23
+ end
24
+ end