standard_id 0.42.0 → 0.43.1
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/CHANGELOG.md +70 -0
- data/README.md +69 -7
- data/app/controllers/concerns/standard_id/rate_limit_handling.rb +5 -19
- data/app/controllers/standard_id/web/login_controller.rb +1 -2
- data/app/controllers/standard_id/web/verify_email/start_controller.rb +16 -9
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +16 -9
- data/app/jobs/standard_id/cleanup_all_job.rb +46 -0
- data/app/mailers/standard_id/passwordless_mailer.rb +59 -3
- data/app/models/standard_id/code_challenge.rb +4 -0
- data/app/views/standard_id/passwordless_mailer/otp_email.html.erb +5 -5
- data/app/views/standard_id/passwordless_mailer/otp_email.text.erb +5 -5
- data/app/views/standard_id/passwordless_mailer/verification_email.html.erb +24 -0
- data/app/views/standard_id/passwordless_mailer/verification_email.text.erb +12 -0
- data/config/locales/en.yml +22 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +5 -6
- data/lib/standard_id/config/schema.rb +18 -31
- data/lib/standard_id/config_schema.rb +66 -1
- data/lib/standard_id/engine.rb +1 -0
- data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +51 -14
- data/lib/standard_id/otp.rb +20 -28
- data/lib/standard_id/passwordless/base_strategy.rb +39 -20
- data/lib/standard_id/passwordless/email_strategy.rb +0 -5
- data/lib/standard_id/passwordless/sms_strategy.rb +0 -4
- data/lib/standard_id/provider_registry.rb +2 -23
- data/lib/standard_id/scope_config.rb +22 -22
- data/lib/standard_id/testing/config_helpers.rb +55 -0
- data/lib/standard_id/testing/provider_examples.rb +1 -1
- data/lib/standard_id/testing.rb +1 -0
- data/lib/standard_id/version.rb +1 -1
- metadata +6 -1
|
@@ -9,11 +9,12 @@ StandardId::ConfigSchema.define do
|
|
|
9
9
|
field :cache_store, type: :any, default: nil
|
|
10
10
|
field :logger, type: :any, default: nil
|
|
11
11
|
field :web_layout, type: :string, default: nil
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
# Removed in 0.43 (deprecated since 0.1.7): assigning either raises
|
|
13
|
+
# StandardId::ConfigurationError with this hint.
|
|
14
|
+
removed :passwordless_email_sender,
|
|
15
|
+
"deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber (it runs synchronously in the request, so I18n.locale is still available; skip it when event[:skip_sender] is true) and set c.passwordless.delivery = :custom. See docs/MIGRATION_GUIDE.md."
|
|
16
|
+
removed :passwordless_sms_sender,
|
|
17
|
+
"deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber (skip it when event[:skip_sender] is true). See docs/MIGRATION_GUIDE.md."
|
|
17
18
|
field :issuer, type: :string, default: nil
|
|
18
19
|
|
|
19
20
|
# Whether `JwtService.decode` REQUIRES a matching `iss` claim.
|
|
@@ -72,9 +73,8 @@ StandardId::ConfigSchema.define do
|
|
|
72
73
|
# Scope-aware authentication: maps scope names to profile-based access config.
|
|
73
74
|
# Each scope is a hash with keys: :profile_types (Array<String>), :after_sign_in_path,
|
|
74
75
|
# :no_profile_message, :label, :allow_registration, :authorizer.
|
|
75
|
-
# The legacy :profile_type (singular
|
|
76
|
-
#
|
|
77
|
-
# warning fires on use).
|
|
76
|
+
# The legacy :profile_type (singular) key was removed in 0.43; a scope that still
|
|
77
|
+
# sets it raises StandardId::ConfigurationError at boot.
|
|
78
78
|
field :scopes, type: :any, default: {}
|
|
79
79
|
|
|
80
80
|
# Callable that resolves the active scope name for a given request/session.
|
|
@@ -153,11 +153,8 @@ StandardId::ConfigSchema.define do
|
|
|
153
153
|
end
|
|
154
154
|
|
|
155
155
|
scope :passwordless do
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
# still boot.
|
|
159
|
-
field :enabled, type: :boolean, default: false,
|
|
160
|
-
deprecated: "it has had no effect since 0.8. Use web.passwordless_login (WebEngine) instead, and remove this line. Removal in v2.0."
|
|
156
|
+
# Removed in 0.43 (no effect since 0.8).
|
|
157
|
+
removed :enabled, "it had no effect since 0.8. Use web.passwordless_login (WebEngine) instead, and delete this line."
|
|
161
158
|
field :connection, type: :string, default: "email"
|
|
162
159
|
field :code_ttl, type: :integer, default: 600 # 10 minutes in seconds
|
|
163
160
|
|
|
@@ -312,12 +309,10 @@ StandardId::ConfigSchema.define do
|
|
|
312
309
|
# RefreshTokenFlow::MAX_REUSE_LEEWAY_SECONDS.
|
|
313
310
|
field :refresh_token_reuse_leeway, type: :integer, default: 0
|
|
314
311
|
field :token_lifetimes, type: :hash, default: -> { {} }
|
|
315
|
-
#
|
|
316
|
-
# rows with ClientSecretCredential secrets.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
field :client_secret, type: :string, default: nil,
|
|
320
|
-
deprecated: "it is not read anywhere — client secrets are StandardId::ClientSecretCredential records. Remove this line. Removal in v2.0."
|
|
312
|
+
# Removed in 0.43 — never read by the gem. OAuth clients are
|
|
313
|
+
# StandardId::ClientApplication rows with ClientSecretCredential secrets.
|
|
314
|
+
removed :client_id, "it was never read — OAuth clients are StandardId::ClientApplication records. Delete this line."
|
|
315
|
+
removed :client_secret, "it was never read — client secrets are StandardId::ClientSecretCredential records. Delete this line."
|
|
321
316
|
field :scope_claims, type: :hash, default: -> { {} }
|
|
322
317
|
field :claim_resolvers, type: :hash, default: -> { {} }
|
|
323
318
|
# List of audience values that tokens issued and accepted by this app may
|
|
@@ -601,17 +596,9 @@ StandardId::ConfigSchema.define do
|
|
|
601
596
|
field :login_per_ip, type: :integer, default: 20 # per 15 minutes
|
|
602
597
|
field :login_per_email, type: :integer, default: 5 # per 15 minutes
|
|
603
598
|
|
|
604
|
-
#
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
# `login_per_*` alias at its default, the login controller falls back to
|
|
608
|
-
# these values (see StandardId::RateLimitHandling.login_per_ip). New name
|
|
609
|
-
# wins when explicitly set. Mirrors the max_attempts ->
|
|
610
|
-
# max_attempts_per_challenge deprecation-alias precedent.
|
|
611
|
-
field :password_login_per_ip, type: :integer, default: 20, # per 15 minutes; deprecated alias of login_per_ip
|
|
612
|
-
deprecated: "use rate_limits.login_per_ip (same meaning; it also governs passwordless OTP sends). Removal in v2.0."
|
|
613
|
-
field :password_login_per_email, type: :integer, default: 5, # per 15 minutes; deprecated alias of login_per_email
|
|
614
|
-
deprecated: "use rate_limits.login_per_email (same meaning; it also governs passwordless OTP sends). Removal in v2.0."
|
|
599
|
+
# Removed in 0.43: the mechanism-specific names of login_per_ip / login_per_email.
|
|
600
|
+
removed :password_login_per_ip, "use rate_limits.login_per_ip (same meaning; it also governs passwordless OTP sends)."
|
|
601
|
+
removed :password_login_per_email, "use rate_limits.login_per_email (same meaning; it also governs passwordless OTP sends)."
|
|
615
602
|
|
|
616
603
|
# RAR-60: OTP verification
|
|
617
604
|
field :otp_verify_per_ip, type: :integer, default: 20 # per 15 minutes
|
|
@@ -630,7 +617,7 @@ StandardId::ConfigSchema.define do
|
|
|
630
617
|
field :signup_per_ip, type: :integer, default: 10 # per hour
|
|
631
618
|
|
|
632
619
|
# API OTP-initiation limits — the API counterpart of the web *login* limits
|
|
633
|
-
# (login_per_*
|
|
620
|
+
# (login_per_*): the API passwordless
|
|
634
621
|
# `start` action and the web login action both drive the passwordless
|
|
635
622
|
# `start!` strategy. NOT the counterpart of verification_start_*
|
|
636
623
|
# (email/phone verification), which bypasses the strategy via a direct
|
|
@@ -28,7 +28,11 @@ module StandardId
|
|
|
28
28
|
def build = instance.apply(Config.new)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
def initialize
|
|
31
|
+
def initialize
|
|
32
|
+
@scopes = Concurrent::Map.new
|
|
33
|
+
@removed = Concurrent::Map.new
|
|
34
|
+
end
|
|
35
|
+
|
|
32
36
|
def scopes = @scopes
|
|
33
37
|
def scope?(name) = @scopes.key?(name.to_sym)
|
|
34
38
|
def field?(scope_name, field_name) = !!@scopes[scope_name.to_sym]&.key?(field_name.to_sym)
|
|
@@ -44,6 +48,18 @@ module StandardId
|
|
|
44
48
|
fields.compute_if_absent(name.to_sym) { Field.new(name.to_sym, type, default, deprecated) }
|
|
45
49
|
end
|
|
46
50
|
|
|
51
|
+
# Record a field that has been REMOVED from the schema. Assigning it raises
|
|
52
|
+
# StandardId::ConfigurationError carrying +message+ (what to do instead),
|
|
53
|
+
# rather than the generic "Unknown field" — or, for a base-scope field
|
|
54
|
+
# assigned through the top-level config, rather than being silently
|
|
55
|
+
# stored on the top-level OrderedOptions and ignored.
|
|
56
|
+
def add_removed_field(scope:, name:, message:)
|
|
57
|
+
@removed[[scope.to_sym, name.to_sym]] = message
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [String, nil] the removal hint for a removed field, else nil
|
|
61
|
+
def removed_field_message(scope_name, field_name) = @removed[[scope_name.to_sym, field_name.to_sym]]
|
|
62
|
+
|
|
47
63
|
# Register a scope without adding a field. Allows `define { scope :foo }` so
|
|
48
64
|
# provider gems can later `add_field(scope: :foo, ...)` against an existing scope.
|
|
49
65
|
def ensure_scope(name)
|
|
@@ -107,6 +123,10 @@ module StandardId
|
|
|
107
123
|
def field(name, type: :string, default: nil, deprecated: nil, **)
|
|
108
124
|
@schema.add_field(scope: @scope_name, name: name, type: type, default: default, deprecated: deprecated)
|
|
109
125
|
end
|
|
126
|
+
|
|
127
|
+
def removed(name, message)
|
|
128
|
+
@schema.add_removed_field(scope: @scope_name, name: name, message: message)
|
|
129
|
+
end
|
|
110
130
|
end
|
|
111
131
|
|
|
112
132
|
# Per-scope OrderedOptions. Validates writes, casts and dups Array/Hash values
|
|
@@ -127,9 +147,45 @@ module StandardId
|
|
|
127
147
|
def []=(key, value)
|
|
128
148
|
validate!(key)
|
|
129
149
|
warn_if_deprecated(key, value)
|
|
150
|
+
assigned_keys << key.to_sym
|
|
130
151
|
super(key.to_sym, value)
|
|
131
152
|
end
|
|
132
153
|
|
|
154
|
+
# Whether the host explicitly assigned +key+ (even to nil).
|
|
155
|
+
#
|
|
156
|
+
# Unlike #key?, which is also true for every field whose schema default
|
|
157
|
+
# was written when the config was built (so it is true for a provider
|
|
158
|
+
# field the host never touched), this is only true after an assignment.
|
|
159
|
+
#
|
|
160
|
+
# @param key [Symbol, String]
|
|
161
|
+
# @return [Boolean]
|
|
162
|
+
def assigned?(key) = assigned_keys.include?(key.to_sym)
|
|
163
|
+
|
|
164
|
+
# Deleting a key also forgets that it was assigned, so reads fall back
|
|
165
|
+
# to the (live) schema default again.
|
|
166
|
+
def delete(key)
|
|
167
|
+
assigned_keys.delete(key.to_sym)
|
|
168
|
+
super(key.to_sym)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Re-evaluate the schema default of every field the host never assigned.
|
|
172
|
+
#
|
|
173
|
+
# Defaults are resolved once, when the config is built — including the
|
|
174
|
+
# ENV fallback of provider fields (`GOOGLE_CLIENT_ID` etc.). Call this
|
|
175
|
+
# after changing ENV to make unassigned fields pick the new value up;
|
|
176
|
+
# explicitly assigned fields are left alone. Meant for tests — see
|
|
177
|
+
# StandardId::Testing.with_provider_env.
|
|
178
|
+
#
|
|
179
|
+
# @return [self]
|
|
180
|
+
def refresh_defaults!
|
|
181
|
+
@schema.scopes[@scope_name]&.each_value do |field|
|
|
182
|
+
next if assigned?(field.name)
|
|
183
|
+
|
|
184
|
+
RAW_SET.bind_call(self, field.name, field.default_value)
|
|
185
|
+
end
|
|
186
|
+
self
|
|
187
|
+
end
|
|
188
|
+
|
|
133
189
|
def [](key)
|
|
134
190
|
sym = key.to_sym
|
|
135
191
|
validate!(sym) unless key?(sym) || resolver
|
|
@@ -153,8 +209,14 @@ module StandardId
|
|
|
153
209
|
|
|
154
210
|
private
|
|
155
211
|
|
|
212
|
+
def assigned_keys = (@assigned_keys ||= Set.new)
|
|
213
|
+
|
|
156
214
|
def validate!(key)
|
|
157
215
|
return if @schema.field?(@scope_name, key)
|
|
216
|
+
if (message = @schema.removed_field_message(@scope_name, key))
|
|
217
|
+
raise StandardId::ConfigurationError,
|
|
218
|
+
"StandardId.config.#{config_path(key)} was removed in StandardId 0.43: #{message}"
|
|
219
|
+
end
|
|
158
220
|
raise StandardId::ConfigurationError,
|
|
159
221
|
"Unknown field '#{key}' for scope '#{@scope_name}'. Valid fields: #{@schema.scopes[@scope_name]&.keys}"
|
|
160
222
|
end
|
|
@@ -215,6 +277,9 @@ module StandardId
|
|
|
215
277
|
sym = key.to_sym
|
|
216
278
|
if __schema__ && !__schema__.scope?(sym) && !key?(sym) && (target = unique_scope_for(sym))
|
|
217
279
|
self[target][sym] = value
|
|
280
|
+
elsif __schema__ && (message = __schema__.removed_field_message(:base, sym))
|
|
281
|
+
raise StandardId::ConfigurationError,
|
|
282
|
+
"StandardId.config.#{sym} was removed in StandardId 0.43: #{message}"
|
|
218
283
|
else
|
|
219
284
|
super(sym, value)
|
|
220
285
|
end
|
data/lib/standard_id/engine.rb
CHANGED
|
@@ -97,6 +97,7 @@ module StandardId
|
|
|
97
97
|
# surfaces typos at boot instead of at callback time in production.
|
|
98
98
|
StandardId::Config::CallableValidator.validate!
|
|
99
99
|
StandardId::Config::ScopeClaimsValidator.validate!
|
|
100
|
+
StandardId::ScopeConfig.validate_all!
|
|
100
101
|
|
|
101
102
|
StandardId::Engine.verify_host_cookie_encryption!(app)
|
|
102
103
|
StandardId::MigrationCheck.verify_at_boot!
|
|
@@ -1,28 +1,56 @@
|
|
|
1
1
|
module StandardId
|
|
2
2
|
module Events
|
|
3
3
|
module Subscribers
|
|
4
|
+
# Built-in OTP email delivery (`c.passwordless.delivery = :built_in`).
|
|
5
|
+
#
|
|
6
|
+
# Sign-in codes (realm "authentication") get PasswordlessMailer#otp_email;
|
|
7
|
+
# every other realm — WebEngine email verification, Otp.issue step-up /
|
|
8
|
+
# contact verification — gets #verification_email, so a verification code
|
|
9
|
+
# never arrives as "your sign-in code". There is no built-in SMS delivery.
|
|
10
|
+
#
|
|
11
|
+
# On a successful enqueue it marks the code challenge
|
|
12
|
+
# (CodeChallenge#built_in_delivered), which is how the strategy knows to
|
|
13
|
+
# report PASSWORDLESS_CODE_SENT as "sent" rather than "failed".
|
|
4
14
|
class PasswordlessDeliverySubscriber < Base
|
|
5
15
|
subscribe_to StandardId::Events::PASSWORDLESS_CODE_GENERATED
|
|
6
16
|
|
|
17
|
+
# Whether the built-in mailer is responsible for delivering this
|
|
18
|
+
# PASSWORDLESS_CODE_GENERATED payload.
|
|
19
|
+
#
|
|
20
|
+
# @param payload [Hash] event payload (symbol or string keys)
|
|
21
|
+
# @return [Boolean]
|
|
22
|
+
def self.handles?(payload)
|
|
23
|
+
payload = payload.with_indifferent_access
|
|
24
|
+
# Per-call manual delivery (Otp.issue(delivery: :manual)) and
|
|
25
|
+
# Otp.issue(delivery: :custom) leave delivery to the caller / host
|
|
26
|
+
# subscriber, even when the engine mailer is the global default.
|
|
27
|
+
return false if payload[:skip_sender]
|
|
28
|
+
return false if payload[:delivery]&.to_sym == :custom
|
|
29
|
+
|
|
30
|
+
StandardId.config.passwordless.delivery == :built_in && payload[:channel].to_s == "email"
|
|
31
|
+
end
|
|
32
|
+
|
|
7
33
|
def call(event)
|
|
8
|
-
|
|
9
|
-
# delivery config. Honors Otp.issue(delivery: :manual) callers who
|
|
10
|
-
# deliver the code themselves (custom widget flows, step-up
|
|
11
|
-
# challenges, etc.) and would otherwise receive a duplicate email
|
|
12
|
-
# from this subscriber when c.passwordless.delivery == :built_in.
|
|
13
|
-
return if event[:skip_sender]
|
|
14
|
-
return unless built_in_delivery?
|
|
15
|
-
return unless event[:channel] == "email"
|
|
34
|
+
return unless self.class.handles?(event.payload)
|
|
16
35
|
|
|
17
36
|
identifier = event[:identifier]
|
|
18
|
-
|
|
37
|
+
challenge = event[:code_challenge]
|
|
38
|
+
code = challenge&.code
|
|
19
39
|
|
|
20
40
|
return if identifier.blank? || code.blank?
|
|
21
41
|
|
|
22
|
-
StandardId::PasswordlessMailer.with(
|
|
42
|
+
enqueued = StandardId::PasswordlessMailer.with(
|
|
23
43
|
email: identifier,
|
|
24
|
-
otp_code: code
|
|
25
|
-
|
|
44
|
+
otp_code: code,
|
|
45
|
+
realm: event[:realm],
|
|
46
|
+
expires_in_minutes: expires_in_minutes(event[:expires_at])
|
|
47
|
+
).public_send(mailer_action(event[:realm])).deliver_later
|
|
48
|
+
|
|
49
|
+
# deliver_later returns false (without raising) when Active Job
|
|
50
|
+
# refuses the enqueue, e.g. an aborting before_enqueue callback.
|
|
51
|
+
if enqueued && challenge.respond_to?(:built_in_delivered=)
|
|
52
|
+
challenge.built_in_delivered = true
|
|
53
|
+
end
|
|
26
54
|
end
|
|
27
55
|
|
|
28
56
|
def handle_error(error, event)
|
|
@@ -34,8 +62,17 @@ module StandardId
|
|
|
34
62
|
|
|
35
63
|
private
|
|
36
64
|
|
|
37
|
-
def
|
|
38
|
-
|
|
65
|
+
def mailer_action(realm)
|
|
66
|
+
realm.blank? || realm.to_s == StandardId::Passwordless::DEFAULT_REALM ? :otp_email : :verification_email
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def expires_in_minutes(expires_at)
|
|
70
|
+
return nil if expires_at.blank?
|
|
71
|
+
|
|
72
|
+
expires_at = Time.zone.parse(expires_at) if expires_at.is_a?(String)
|
|
73
|
+
[((expires_at - Time.current) / 60.0).ceil, 1].max
|
|
74
|
+
rescue ArgumentError, TypeError, NoMethodError
|
|
75
|
+
nil
|
|
39
76
|
end
|
|
40
77
|
end
|
|
41
78
|
end
|
data/lib/standard_id/otp.rb
CHANGED
|
@@ -44,15 +44,24 @@ module StandardId
|
|
|
44
44
|
#
|
|
45
45
|
# == Delivery modes
|
|
46
46
|
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
# * +:
|
|
52
|
-
# +
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
47
|
+
# Every mode publishes +StandardId::Events::PASSWORDLESS_CODE_GENERATED+
|
|
48
|
+
# (synchronously, inside the call); the payload carries +delivery:+ and
|
|
49
|
+
# +skip_sender:+ so subscribers can tell the modes apart.
|
|
50
|
+
#
|
|
51
|
+
# * +:built_in+ (default) — follow the global
|
|
52
|
+
# +StandardId.config.passwordless.delivery+: when that is +:built_in+ the
|
|
53
|
+
# engine's +PasswordlessMailer+ sends the code (+channel: :email+ only);
|
|
54
|
+
# otherwise the host's own +PASSWORDLESS_CODE_GENERATED+ subscriber does.
|
|
55
|
+
# * +:custom+ — the host's +PASSWORDLESS_CODE_GENERATED+ subscriber delivers
|
|
56
|
+
# this code; the engine mailer never does, even when the global setting is
|
|
57
|
+
# +:built_in+. Use it for a realm whose message differs from the sign-in
|
|
58
|
+
# email. Nothing is sent unless the host subscribes. (Before 0.43 this
|
|
59
|
+
# called +passwordless_email_sender+ / +passwordless_sms_sender+, both
|
|
60
|
+
# removed in 0.43.)
|
|
61
|
+
# * +:manual+ — no subscriber should deliver (+skip_sender: true+); the raw
|
|
62
|
+
# +code+ is returned on the result so the caller can deliver it however
|
|
63
|
+
# they like. Useful for custom widget/embedded flows that want full
|
|
64
|
+
# control over the channel.
|
|
56
65
|
module Otp
|
|
57
66
|
VALID_CHANNELS = %w[email sms].freeze
|
|
58
67
|
VALID_DELIVERIES = %i[built_in custom manual].freeze
|
|
@@ -112,13 +121,6 @@ module StandardId
|
|
|
112
121
|
return failure_issue_result(:invalid_request, "target: is required")
|
|
113
122
|
end
|
|
114
123
|
|
|
115
|
-
# Fail loud when the caller asked for :custom delivery but has not
|
|
116
|
-
# configured the corresponding sender callback. Without this guard
|
|
117
|
-
# BaseStrategy#start! would silently skip delivery (the sender_callback
|
|
118
|
-
# is nil and `&.call` no-ops) while Otp.issue still returned a success
|
|
119
|
-
# result — making host apps believe the OTP was sent when it was not.
|
|
120
|
-
assert_custom_sender_configured!(channel_s) if delivery_sym == :custom
|
|
121
|
-
|
|
122
124
|
strategy = build_strategy(channel_s, request, realm: realm_s)
|
|
123
125
|
|
|
124
126
|
begin
|
|
@@ -127,7 +129,8 @@ module StandardId
|
|
|
127
129
|
code_length: code_length,
|
|
128
130
|
expires_in: normalize_expires_in(expires_in),
|
|
129
131
|
metadata: metadata,
|
|
130
|
-
skip_sender: delivery_sym == :manual
|
|
132
|
+
skip_sender: delivery_sym == :manual,
|
|
133
|
+
delivery: delivery_sym
|
|
131
134
|
)
|
|
132
135
|
rescue StandardId::InvalidRequestError => e
|
|
133
136
|
# Validation failures from the strategy (invalid email/phone format,
|
|
@@ -226,17 +229,6 @@ module StandardId
|
|
|
226
229
|
error_message: error_message
|
|
227
230
|
)
|
|
228
231
|
end
|
|
229
|
-
|
|
230
|
-
def assert_custom_sender_configured!(channel)
|
|
231
|
-
attr = channel == "email" ? :passwordless_email_sender : :passwordless_sms_sender
|
|
232
|
-
sender = StandardId.config.public_send(attr)
|
|
233
|
-
return if sender.respond_to?(:call)
|
|
234
|
-
|
|
235
|
-
raise StandardId::ConfigurationError,
|
|
236
|
-
"Otp.issue(delivery: :custom) requires StandardId.config.#{attr} to be a callable " \
|
|
237
|
-
"(got #{sender.inspect}). Configure it in an initializer, or use delivery: :built_in " \
|
|
238
|
-
"to let the engine's event subscriber deliver, or delivery: :manual to receive the raw code."
|
|
239
|
-
end
|
|
240
232
|
end
|
|
241
233
|
|
|
242
234
|
# Minimal stand-in used when no request is available (e.g. jobs, tests).
|
|
@@ -12,14 +12,23 @@ module StandardId
|
|
|
12
12
|
raise NotImplementedError
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# Start flow: validate recipient, create challenge, and
|
|
16
|
-
#
|
|
15
|
+
# Start flow: validate recipient, create challenge, and publish
|
|
16
|
+
# PASSWORDLESS_CODE_GENERATED — delivery happens in a subscriber to that
|
|
17
|
+
# event (the engine's PasswordlessDeliverySubscriber when
|
|
18
|
+
# c.passwordless.delivery == :built_in, otherwise the host's own).
|
|
19
|
+
#
|
|
20
|
+
# attrs: { connection:, username:, code_length:, expires_in:, metadata:, skip_sender:, delivery: }
|
|
21
|
+
#
|
|
22
|
+
# +delivery+ (:built_in / :custom / :manual, or nil when not called via
|
|
23
|
+
# StandardId::Otp.issue) is forwarded in the event payload; see
|
|
24
|
+
# Otp.issue for what each value means to the subscribers.
|
|
17
25
|
def start!(attrs)
|
|
18
26
|
username = attrs[:username]
|
|
19
27
|
code_length = attrs[:code_length]
|
|
20
28
|
expires_in = attrs[:expires_in]
|
|
21
29
|
metadata = attrs[:metadata] || {}
|
|
22
30
|
skip_sender = attrs[:skip_sender] == true
|
|
31
|
+
delivery = attrs[:delivery]&.to_sym
|
|
23
32
|
|
|
24
33
|
validate_username!(username)
|
|
25
34
|
run_username_validator!(username)
|
|
@@ -33,12 +42,11 @@ module StandardId
|
|
|
33
42
|
)
|
|
34
43
|
# skip_sender is forwarded into the event payload so subscribers that
|
|
35
44
|
# deliver on PASSWORDLESS_CODE_GENERATED (e.g. PasswordlessDeliverySubscriber)
|
|
36
|
-
# can honor a per-call manual-delivery request
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
emit_code_generated(challenge, username, skip_sender: skip_sender)
|
|
40
|
-
|
|
41
|
-
emit_code_sent(username) unless skip_sender
|
|
45
|
+
# can honor a per-call manual-delivery request. Without this,
|
|
46
|
+
# Otp.issue(delivery: :manual) silently double-delivers when
|
|
47
|
+
# c.passwordless.delivery == :built_in.
|
|
48
|
+
generated_payload = emit_code_generated(challenge, username, skip_sender: skip_sender, delivery: delivery)
|
|
49
|
+
emit_code_sent(username, delivery_status: delivery_status_for(challenge, generated_payload)) unless skip_sender
|
|
42
50
|
challenge
|
|
43
51
|
end
|
|
44
52
|
|
|
@@ -156,11 +164,6 @@ module StandardId
|
|
|
156
164
|
raise NotImplementedError
|
|
157
165
|
end
|
|
158
166
|
|
|
159
|
-
def sender_callback
|
|
160
|
-
# Implement in subclasses
|
|
161
|
-
nil
|
|
162
|
-
end
|
|
163
|
-
|
|
164
167
|
private
|
|
165
168
|
|
|
166
169
|
# Extract request parameters safely. Returns an empty hash if the request
|
|
@@ -187,25 +190,41 @@ module StandardId
|
|
|
187
190
|
)
|
|
188
191
|
end
|
|
189
192
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
+
# @return [Hash] the published payload (used to decide the delivery status)
|
|
194
|
+
def emit_code_generated(challenge, username, skip_sender: false, delivery: nil)
|
|
195
|
+
payload = {
|
|
193
196
|
code_challenge: challenge,
|
|
194
197
|
identifier: username,
|
|
195
198
|
channel: connection_type,
|
|
196
199
|
realm: @realm,
|
|
197
200
|
expires_at: challenge.expires_at,
|
|
198
|
-
skip_sender: skip_sender
|
|
199
|
-
|
|
201
|
+
skip_sender: skip_sender,
|
|
202
|
+
delivery: delivery
|
|
203
|
+
}
|
|
204
|
+
StandardId::Events.publish(StandardId::Events::PASSWORDLESS_CODE_GENERATED, payload)
|
|
205
|
+
payload
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# delivery_status of PASSWORDLESS_CODE_SENT:
|
|
209
|
+
# - "sent" — the built-in mailer (delivery :built_in) enqueued the email.
|
|
210
|
+
# - "failed" — the built-in mailer was responsible but did not enqueue
|
|
211
|
+
# it (its error was logged by the subscriber).
|
|
212
|
+
# - "delegated" — delivery belongs to the host's PASSWORDLESS_CODE_GENERATED
|
|
213
|
+
# subscriber (delivery :custom, or SMS); the engine cannot
|
|
214
|
+
# know whether it sent anything.
|
|
215
|
+
def delivery_status_for(challenge, generated_payload)
|
|
216
|
+
return "delegated" unless StandardId::Events::Subscribers::PasswordlessDeliverySubscriber.handles?(generated_payload)
|
|
217
|
+
|
|
218
|
+
challenge.built_in_delivered ? "sent" : "failed"
|
|
200
219
|
end
|
|
201
220
|
|
|
202
|
-
def emit_code_sent(username)
|
|
221
|
+
def emit_code_sent(username, delivery_status:)
|
|
203
222
|
StandardId::Events.publish(
|
|
204
223
|
StandardId::Events::PASSWORDLESS_CODE_SENT,
|
|
205
224
|
identifier: username,
|
|
206
225
|
channel: connection_type,
|
|
207
226
|
realm: @realm,
|
|
208
|
-
delivery_status:
|
|
227
|
+
delivery_status: delivery_status
|
|
209
228
|
)
|
|
210
229
|
end
|
|
211
230
|
end
|
|
@@ -20,11 +20,6 @@ module StandardId
|
|
|
20
20
|
identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: normalized)
|
|
21
21
|
identifier&.account
|
|
22
22
|
end
|
|
23
|
-
|
|
24
|
-
def sender_callback
|
|
25
|
-
return nil if StandardId.config.passwordless.delivery == :built_in
|
|
26
|
-
StandardId.config.passwordless_email_sender
|
|
27
|
-
end
|
|
28
23
|
end
|
|
29
24
|
end
|
|
30
25
|
end
|
|
@@ -9,11 +9,6 @@ module StandardId
|
|
|
9
9
|
# StandardId (see Providers::Base) rather than to ConfigSchema.
|
|
10
10
|
PROVIDER_FIELD_OPTIONS = %i[env required].freeze
|
|
11
11
|
|
|
12
|
-
# The gem-wide StandardId.deprecator (registered in
|
|
13
|
-
# Rails.application.deprecators), kept as a constant for existing callers.
|
|
14
|
-
# The Base.setup message names its own removal version (1.0).
|
|
15
|
-
DEPRECATOR = StandardId.deprecator
|
|
16
|
-
|
|
17
12
|
@providers = Concurrent::Map.new
|
|
18
13
|
|
|
19
14
|
class << self
|
|
@@ -28,7 +23,6 @@ module StandardId
|
|
|
28
23
|
validate_provider!(provider_class)
|
|
29
24
|
providers[name.to_s] = provider_class
|
|
30
25
|
declare_config_schema(provider_class)
|
|
31
|
-
run_deprecated_setup(provider_class)
|
|
32
26
|
provider_class
|
|
33
27
|
end
|
|
34
28
|
|
|
@@ -53,9 +47,8 @@ module StandardId
|
|
|
53
47
|
# populated before any initializer runs.
|
|
54
48
|
#
|
|
55
49
|
# Only FIELD DECLARATION moves earlier. Full `register` — which also runs
|
|
56
|
-
# `validate_provider!`
|
|
57
|
-
#
|
|
58
|
-
# `setup` can rely on it.
|
|
50
|
+
# `validate_provider!` — deliberately stays in `after_initialize`, where
|
|
51
|
+
# the host's configuration is complete.
|
|
59
52
|
#
|
|
60
53
|
# Idempotent: `ConfigSchema#add_field` uses `compute_if_absent`, so a field
|
|
61
54
|
# already declared here is untouched when the plugin later calls `register`.
|
|
@@ -207,20 +200,6 @@ module StandardId
|
|
|
207
200
|
end
|
|
208
201
|
end
|
|
209
202
|
|
|
210
|
-
# Providers::Base.setup was removed in 0.42: no known plugin overrode
|
|
211
|
-
# it, and register — its only caller — runs from after_initialize, where
|
|
212
|
-
# a plugin's own Railtie can do the same work. A provider that still
|
|
213
|
-
# defines `setup` keeps working, with a deprecation warning.
|
|
214
|
-
def run_deprecated_setup(provider_class)
|
|
215
|
-
return unless provider_class.respond_to?(:setup)
|
|
216
|
-
|
|
217
|
-
DEPRECATOR.warn(
|
|
218
|
-
"#{provider_class.name || provider_class}.setup is deprecated and will not be called " \
|
|
219
|
-
"by StandardId 1.0. Move provider initialization into the plugin's Railtie."
|
|
220
|
-
)
|
|
221
|
-
provider_class.setup
|
|
222
|
-
end
|
|
223
|
-
|
|
224
203
|
def production?
|
|
225
204
|
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
|
|
226
205
|
end
|
|
@@ -24,35 +24,35 @@ module StandardId
|
|
|
24
24
|
:allow_registration,
|
|
25
25
|
:authorizer
|
|
26
26
|
|
|
27
|
-
# Kept as an alias for hosts/specs that reference it; it is the gem-wide
|
|
28
|
-
# StandardId.deprecator, registered in Rails.application.deprecators.
|
|
29
|
-
DEPRECATOR = StandardId.deprecator
|
|
30
|
-
|
|
31
27
|
# Normalize profile-type inputs from config.
|
|
32
28
|
#
|
|
33
|
-
# Accepts:
|
|
34
|
-
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
29
|
+
# Accepts :profile_types — an Array of profile-type class names (a single
|
|
30
|
+
# String is wrapped). The singular :profile_type key was removed in 0.43
|
|
31
|
+
# (deprecated in 0.42) and now raises: silently ignoring it would leave the
|
|
32
|
+
# scope with NO profile requirement, admitting every account.
|
|
37
33
|
#
|
|
38
|
-
#
|
|
34
|
+
# @return [Array<String>] possibly empty
|
|
35
|
+
# @raise [StandardId::ConfigurationError] when the removed :profile_type key is present
|
|
39
36
|
def self.extract_profile_types(config)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
raise ArgumentError, "Scope config cannot set both :profile_type and :profile_types — use :profile_types"
|
|
37
|
+
if config.key?(:profile_type) || config.key?("profile_type")
|
|
38
|
+
raise StandardId::ConfigurationError,
|
|
39
|
+
"StandardId scope config key :profile_type was removed in StandardId 0.43. " \
|
|
40
|
+
"Use profile_types: [...] (an Array of profile-type class names) instead."
|
|
45
41
|
end
|
|
46
42
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
43
|
+
Array(config[:profile_types]).map(&:to_s).reject(&:blank?)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build every scope in StandardId.config.scopes once, so a scope config the
|
|
47
|
+
# gem can no longer read (e.g. the removed :profile_type key) fails at boot
|
|
48
|
+
# rather than on the first sign-in under that scope. Run by the engine.
|
|
49
|
+
#
|
|
50
|
+
# @return [void]
|
|
51
|
+
# @raise [StandardId::ConfigurationError]
|
|
52
|
+
def self.validate_all!(scopes = StandardId.config.scopes)
|
|
53
|
+
return if scopes.blank?
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
scopes.each { |name, scope_hash| new(name, scope_hash || {}) }
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def initialize(name, config = {})
|