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