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.
- 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 +2 -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 +2 -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/app/models/standard_id/authorization_code.rb +2 -1
- data/lib/standard_id/authorization_bypass.rb +121 -0
- data/lib/standard_id/current_attributes.rb +1 -1
- data/lib/standard_id/events.rb +1 -0
- data/lib/standard_id/jwt_service.rb +41 -15
- data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +3 -2
- data/lib/standard_id/oauth/authorization_code_flow.rb +4 -0
- 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/oauth/subflows/social_login_grant.rb +2 -1
- data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +1 -0
- 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,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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
@
|
|
98
|
-
@
|
|
99
|
-
@
|
|
100
|
-
@
|
|
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
|
-
@
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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!
|
|
@@ -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
|
|
@@ -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
|