standard_id 0.3.2 → 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.
- checksums.yaml +4 -4
- data/app/controllers/concerns/standard_id/controller_policy.rb +99 -0
- data/app/controllers/standard_id/api/authorization_controller.rb +2 -0
- data/app/controllers/standard_id/api/base_controller.rb +1 -0
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +2 -0
- data/app/controllers/standard_id/api/oauth/tokens_controller.rb +2 -0
- data/app/controllers/standard_id/api/oidc/logout_controller.rb +2 -0
- data/app/controllers/standard_id/api/passwordless_controller.rb +2 -0
- data/app/controllers/standard_id/api/userinfo_controller.rb +2 -0
- data/app/controllers/standard_id/api/well_known/jwks_controller.rb +6 -0
- data/app/controllers/standard_id/web/account_controller.rb +2 -0
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +2 -0
- data/app/controllers/standard_id/web/base_controller.rb +1 -0
- data/app/controllers/standard_id/web/login_controller.rb +2 -0
- data/app/controllers/standard_id/web/login_verify_controller.rb +12 -84
- data/app/controllers/standard_id/web/logout_controller.rb +7 -0
- data/app/controllers/standard_id/web/reset_password/confirm_controller.rb +2 -0
- data/app/controllers/standard_id/web/reset_password/start_controller.rb +2 -0
- data/app/controllers/standard_id/web/sessions_controller.rb +2 -0
- data/app/controllers/standard_id/web/signup_controller.rb +2 -0
- data/app/controllers/standard_id/web/verify_email/base_controller.rb +2 -0
- data/app/controllers/standard_id/web/verify_phone/base_controller.rb +2 -0
- data/lib/standard_id/authorization_bypass.rb +121 -0
- data/lib/standard_id/jwt_service.rb +41 -15
- data/lib/standard_id/oauth/password_flow.rb +5 -1
- data/lib/standard_id/oauth/passwordless_otp_flow.rb +10 -61
- data/lib/standard_id/passwordless/verification_service.rb +227 -0
- data/lib/standard_id/testing/authentication_helpers.rb +75 -0
- data/lib/standard_id/testing/factories/credentials.rb +24 -0
- data/lib/standard_id/testing/factories/identifiers.rb +37 -0
- data/lib/standard_id/testing/factories/oauth.rb +89 -0
- data/lib/standard_id/testing/factories/sessions.rb +112 -0
- data/lib/standard_id/testing/factory_bot.rb +7 -0
- data/lib/standard_id/testing/request_helpers.rb +60 -0
- data/lib/standard_id/testing.rb +26 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +6 -0
- metadata +40 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
raise StandardId::InvalidGrantError,
|
|
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
|
|
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!
|
|
@@ -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
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
FactoryBot.define do
|
|
2
|
+
# ClientApplication requires a polymorphic `owner` association.
|
|
3
|
+
# The host app must define an `:account` factory for this association to resolve.
|
|
4
|
+
factory :standard_id_client_application, class: "StandardId::ClientApplication" do
|
|
5
|
+
association :owner, factory: :account
|
|
6
|
+
sequence(:name) { |n| "Test App #{n}" }
|
|
7
|
+
redirect_uris { "https://example.com/callback" }
|
|
8
|
+
scopes { "openid profile email" }
|
|
9
|
+
grant_types { "authorization_code refresh_token" }
|
|
10
|
+
response_types { "code" }
|
|
11
|
+
client_type { "confidential" }
|
|
12
|
+
require_pkce { true }
|
|
13
|
+
code_challenge_methods { "S256" }
|
|
14
|
+
access_token_lifetime { 3600 }
|
|
15
|
+
refresh_token_lifetime { 2_592_000 }
|
|
16
|
+
authorization_code_lifetime { 600 }
|
|
17
|
+
active { true }
|
|
18
|
+
|
|
19
|
+
trait :public_client do
|
|
20
|
+
client_type { "public" }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
trait :inactive do
|
|
24
|
+
active { false }
|
|
25
|
+
deactivated_at { Time.current }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Replaces the default grant_types value. To combine with other grant types,
|
|
29
|
+
# set grant_types explicitly: grant_types { "authorization_code client_credentials" }
|
|
30
|
+
trait :with_client_credentials do
|
|
31
|
+
grant_types { "client_credentials" }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
factory :standard_id_code_challenge, class: "StandardId::CodeChallenge" do
|
|
36
|
+
realm { "authentication" }
|
|
37
|
+
channel { "email" }
|
|
38
|
+
sequence(:target) { |n| "user#{n}@example.com" }
|
|
39
|
+
code { SecureRandom.random_number(10**6).to_s.rjust(6, "0") }
|
|
40
|
+
expires_at { 10.minutes.from_now }
|
|
41
|
+
|
|
42
|
+
trait :expired do
|
|
43
|
+
expires_at { 5.minutes.ago }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
trait :used do
|
|
47
|
+
used_at { Time.current }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
trait :for_verification do
|
|
51
|
+
realm { "verification" }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
trait :for_sms do
|
|
55
|
+
channel { "sms" }
|
|
56
|
+
sequence(:target) { |n| "+1555#{(n % 10_000_000).to_s.rjust(7, '0')}" }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# AuthorizationCode has `belongs_to :account, optional: true`.
|
|
61
|
+
# client_id is a plain string (no FK to ClientApplication) — intentionally
|
|
62
|
+
# unlinked so tests can create authorization codes without a full OAuth setup.
|
|
63
|
+
factory :standard_id_authorization_code, class: "StandardId::AuthorizationCode" do
|
|
64
|
+
transient do
|
|
65
|
+
plaintext_code { SecureRandom.hex(20) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
association :account, factory: :account
|
|
69
|
+
code_hash { StandardId::AuthorizationCode.hash_for(plaintext_code) }
|
|
70
|
+
client_id { SecureRandom.hex(16) }
|
|
71
|
+
redirect_uri { "https://example.com/callback" }
|
|
72
|
+
scope { "openid profile" }
|
|
73
|
+
issued_at { Time.current }
|
|
74
|
+
expires_at { 10.minutes.from_now }
|
|
75
|
+
|
|
76
|
+
trait :expired do
|
|
77
|
+
expires_at { 5.minutes.ago }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
trait :consumed do
|
|
81
|
+
consumed_at { Time.current }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
trait :with_pkce do
|
|
85
|
+
code_challenge { SecureRandom.hex(32) }
|
|
86
|
+
code_challenge_method { "S256" }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
FactoryBot.define do
|
|
2
|
+
# BrowserSession requires an `account` association (belongs_to :account).
|
|
3
|
+
# The host app must define an `:account` factory for this association to resolve.
|
|
4
|
+
factory :standard_id_browser_session, class: "StandardId::BrowserSession" do
|
|
5
|
+
association :account, factory: :account
|
|
6
|
+
user_agent { "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0" }
|
|
7
|
+
sequence(:ip_address) { |n| "192.168.1.#{(n % 254) + 1}" }
|
|
8
|
+
expires_at { StandardId::BrowserSession.expiry }
|
|
9
|
+
|
|
10
|
+
trait :active do
|
|
11
|
+
revoked_at { nil }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
trait :expired do
|
|
15
|
+
expires_at { 2.days.ago }
|
|
16
|
+
revoked_at { nil }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
trait :revoked do
|
|
20
|
+
revoked_at { 1.day.ago }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
trait :chrome do
|
|
24
|
+
user_agent { "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0" }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
trait :firefox do
|
|
28
|
+
user_agent { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Firefox/121.0" }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
trait :safari do
|
|
32
|
+
user_agent { "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/17.2" }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
trait :edge do
|
|
36
|
+
user_agent { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Edg/120.0" }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# DeviceSession requires an `account` association (belongs_to :account).
|
|
41
|
+
# The host app must define an `:account` factory for this association to resolve.
|
|
42
|
+
factory :standard_id_device_session, class: "StandardId::DeviceSession" do
|
|
43
|
+
association :account, factory: :account
|
|
44
|
+
device_agent { "App/1.0 (iPhone; iOS 17.2)" }
|
|
45
|
+
sequence(:device_id) { |n| "device_#{n}" }
|
|
46
|
+
sequence(:ip_address) { |n| "10.0.0.#{(n % 254) + 1}" }
|
|
47
|
+
expires_at { StandardId::DeviceSession.expiry }
|
|
48
|
+
last_refreshed_at { 30.minutes.ago }
|
|
49
|
+
|
|
50
|
+
trait :active do
|
|
51
|
+
revoked_at { nil }
|
|
52
|
+
last_refreshed_at { 30.minutes.ago }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
trait :expired do
|
|
56
|
+
expires_at { 15.days.ago }
|
|
57
|
+
revoked_at { nil }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
trait :revoked do
|
|
61
|
+
expires_at { 30.days.from_now }
|
|
62
|
+
revoked_at { 5.days.ago }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
trait :stale do
|
|
66
|
+
expires_at { 30.days.from_now }
|
|
67
|
+
revoked_at { nil }
|
|
68
|
+
last_refreshed_at { 2.hours.ago }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
trait :iphone do
|
|
72
|
+
device_agent { "App/1.0 (iPhone; iOS 17.2)" }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
trait :android do
|
|
76
|
+
device_agent { "App/1.0 (Android; Samsung Galaxy S24)" }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
trait :ipad do
|
|
80
|
+
device_agent { "App/1.0 (iPad; iPadOS 17.2)" }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# NOTE: ServiceSession inherits `belongs_to :account` from Session and adds
|
|
85
|
+
# `belongs_to :owner` (polymorphic). Both are required.
|
|
86
|
+
# By default, account and owner are distinct :account instances. Override one
|
|
87
|
+
# or both if your test needs them to be the same object.
|
|
88
|
+
# The host app must define an `:account` factory for these associations to resolve.
|
|
89
|
+
factory :standard_id_service_session, class: "StandardId::ServiceSession" do
|
|
90
|
+
association :account, factory: :account
|
|
91
|
+
association :owner, factory: :account
|
|
92
|
+
service_name { "test-service" }
|
|
93
|
+
service_version { "1.0.0" }
|
|
94
|
+
ip_address { "10.0.0.1" }
|
|
95
|
+
user_agent { "ServiceClient/1.0" }
|
|
96
|
+
expires_at { StandardId::ServiceSession.default_expiry }
|
|
97
|
+
|
|
98
|
+
trait :active do
|
|
99
|
+
revoked_at { nil }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
trait :expired do
|
|
103
|
+
expires_at { 30.days.ago }
|
|
104
|
+
revoked_at { nil }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
trait :revoked do
|
|
108
|
+
expires_at { 90.days.from_now }
|
|
109
|
+
revoked_at { 1.day.ago }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
require "factory_bot"
|
|
2
|
+
|
|
3
|
+
# Factories are loaded alphabetically via glob. FactoryBot resolves associations
|
|
4
|
+
# lazily, so load order does not affect correctness. If adding a factory file
|
|
5
|
+
# with an explicit dependency on another, use require_relative instead.
|
|
6
|
+
factory_paths = Dir[File.join(__dir__, "factories", "*.rb")]
|
|
7
|
+
factory_paths.each { |f| require f }
|