standard_id 0.3.2 → 0.5.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/standard_id/controller_policy.rb +99 -0
  3. data/app/controllers/standard_id/api/authorization_controller.rb +2 -0
  4. data/app/controllers/standard_id/api/base_controller.rb +2 -0
  5. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +2 -0
  6. data/app/controllers/standard_id/api/oauth/tokens_controller.rb +2 -0
  7. data/app/controllers/standard_id/api/oidc/logout_controller.rb +2 -0
  8. data/app/controllers/standard_id/api/passwordless_controller.rb +2 -0
  9. data/app/controllers/standard_id/api/userinfo_controller.rb +2 -0
  10. data/app/controllers/standard_id/api/well_known/jwks_controller.rb +6 -0
  11. data/app/controllers/standard_id/web/account_controller.rb +2 -0
  12. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +2 -0
  13. data/app/controllers/standard_id/web/base_controller.rb +2 -0
  14. data/app/controllers/standard_id/web/login_controller.rb +2 -0
  15. data/app/controllers/standard_id/web/login_verify_controller.rb +12 -84
  16. data/app/controllers/standard_id/web/logout_controller.rb +7 -0
  17. data/app/controllers/standard_id/web/reset_password/confirm_controller.rb +2 -0
  18. data/app/controllers/standard_id/web/reset_password/start_controller.rb +2 -0
  19. data/app/controllers/standard_id/web/sessions_controller.rb +2 -0
  20. data/app/controllers/standard_id/web/signup_controller.rb +2 -0
  21. data/app/controllers/standard_id/web/verify_email/base_controller.rb +2 -0
  22. data/app/controllers/standard_id/web/verify_phone/base_controller.rb +2 -0
  23. data/app/models/standard_id/authorization_code.rb +2 -1
  24. data/lib/standard_id/authorization_bypass.rb +121 -0
  25. data/lib/standard_id/current_attributes.rb +1 -1
  26. data/lib/standard_id/events.rb +1 -0
  27. data/lib/standard_id/jwt_service.rb +41 -15
  28. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +3 -2
  29. data/lib/standard_id/oauth/authorization_code_flow.rb +4 -0
  30. data/lib/standard_id/oauth/password_flow.rb +5 -1
  31. data/lib/standard_id/oauth/passwordless_otp_flow.rb +10 -61
  32. data/lib/standard_id/oauth/subflows/social_login_grant.rb +2 -1
  33. data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +1 -0
  34. data/lib/standard_id/passwordless/verification_service.rb +227 -0
  35. data/lib/standard_id/testing/authentication_helpers.rb +75 -0
  36. data/lib/standard_id/testing/factories/credentials.rb +24 -0
  37. data/lib/standard_id/testing/factories/identifiers.rb +37 -0
  38. data/lib/standard_id/testing/factories/oauth.rb +89 -0
  39. data/lib/standard_id/testing/factories/sessions.rb +112 -0
  40. data/lib/standard_id/testing/factory_bot.rb +7 -0
  41. data/lib/standard_id/testing/request_helpers.rb +60 -0
  42. data/lib/standard_id/testing.rb +26 -0
  43. data/lib/standard_id/version.rb +1 -1
  44. data/lib/standard_id.rb +6 -0
  45. metadata +40 -1
@@ -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
 
@@ -2,7 +2,7 @@ module StandardId
2
2
  module Oauth
3
3
  class AuthorizationCodeAuthorizationFlow < AuthorizationFlow
4
4
  expect_params :client_id, :audience
5
- permit_params :scope, :redirect_uri, :state, :connection, :prompt, :organization, :invitation, :code_challenge, :code_challenge_method
5
+ permit_params :scope, :redirect_uri, :state, :connection, :prompt, :organization, :invitation, :code_challenge, :code_challenge_method, :nonce
6
6
 
7
7
  private
8
8
 
@@ -39,7 +39,8 @@ module StandardId
39
39
  audience: audience,
40
40
  state: state,
41
41
  code_challenge: flow_params[:code_challenge],
42
- code_challenge_method: flow_params[:code_challenge_method]
42
+ code_challenge_method: flow_params[:code_challenge_method],
43
+ nonce: flow_params[:nonce]
43
44
  }
44
45
  end
45
46
  end
@@ -70,6 +70,10 @@ module StandardId
70
70
  def audience
71
71
  @authorization_code&.audience
72
72
  end
73
+
74
+ def nonce
75
+ @authorization_code&.nonce
76
+ end
73
77
  end
74
78
  end
75
79
  end
@@ -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!
@@ -31,7 +31,8 @@ module StandardId
31
31
  audience: params[:audience],
32
32
  state: params[:state],
33
33
  code_challenge: params[:code_challenge],
34
- code_challenge_method: params[:code_challenge_method]
34
+ code_challenge_method: params[:code_challenge_method],
35
+ nonce: params[:nonce]
35
36
  }.compact
36
37
 
37
38
  # Remove code_challenge_method if code_challenge is not present
@@ -31,6 +31,7 @@ module StandardId
31
31
  account: params[:current_account],
32
32
  code_challenge: params[:code_challenge],
33
33
  code_challenge_method: params[:code_challenge_method],
34
+ nonce: params[:nonce],
34
35
  metadata: { state: params[:state] }.compact
35
36
  )
36
37
  end
@@ -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
@@ -0,0 +1,37 @@
1
+ FactoryBot.define do
2
+ factory :standard_id_email_identifier, class: "StandardId::EmailIdentifier" do
3
+ sequence(:value) { |n| "user#{n}@example.com" }
4
+
5
+ trait :verified do
6
+ verified_at { Time.current }
7
+ end
8
+
9
+ trait :unverified do
10
+ verified_at { nil }
11
+ end
12
+ end
13
+
14
+ factory :standard_id_phone_number_identifier, class: "StandardId::PhoneNumberIdentifier" do
15
+ sequence(:value) { |n| "+1555#{(n % 10_000_000).to_s.rjust(7, '0')}" }
16
+
17
+ trait :verified do
18
+ verified_at { Time.current }
19
+ end
20
+
21
+ trait :unverified do
22
+ verified_at { nil }
23
+ end
24
+ end
25
+
26
+ factory :standard_id_username_identifier, class: "StandardId::UsernameIdentifier" do
27
+ sequence(:value) { |n| "user_#{n}" }
28
+
29
+ trait :verified do
30
+ verified_at { Time.current }
31
+ end
32
+
33
+ trait :unverified do
34
+ verified_at { nil }
35
+ end
36
+ end
37
+ end