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.
Files changed (38) 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 +1 -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 +1 -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/lib/standard_id/authorization_bypass.rb +121 -0
  24. data/lib/standard_id/jwt_service.rb +41 -15
  25. data/lib/standard_id/oauth/password_flow.rb +5 -1
  26. data/lib/standard_id/oauth/passwordless_otp_flow.rb +10 -61
  27. data/lib/standard_id/passwordless/verification_service.rb +227 -0
  28. data/lib/standard_id/testing/authentication_helpers.rb +75 -0
  29. data/lib/standard_id/testing/factories/credentials.rb +24 -0
  30. data/lib/standard_id/testing/factories/identifiers.rb +37 -0
  31. data/lib/standard_id/testing/factories/oauth.rb +89 -0
  32. data/lib/standard_id/testing/factories/sessions.rb +112 -0
  33. data/lib/standard_id/testing/factory_bot.rb +7 -0
  34. data/lib/standard_id/testing/request_helpers.rb +60 -0
  35. data/lib/standard_id/testing.rb +26 -0
  36. data/lib/standard_id/version.rb +1 -1
  37. data/lib/standard_id.rb +6 -0
  38. 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
- @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!
@@ -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 }