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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +70 -0
  3. data/README.md +69 -7
  4. data/app/controllers/concerns/standard_id/rate_limit_handling.rb +5 -19
  5. data/app/controllers/standard_id/web/login_controller.rb +1 -2
  6. data/app/controllers/standard_id/web/verify_email/start_controller.rb +16 -9
  7. data/app/controllers/standard_id/web/verify_phone/start_controller.rb +16 -9
  8. data/app/jobs/standard_id/cleanup_all_job.rb +46 -0
  9. data/app/mailers/standard_id/passwordless_mailer.rb +59 -3
  10. data/app/models/standard_id/code_challenge.rb +4 -0
  11. data/app/views/standard_id/passwordless_mailer/otp_email.html.erb +5 -5
  12. data/app/views/standard_id/passwordless_mailer/otp_email.text.erb +5 -5
  13. data/app/views/standard_id/passwordless_mailer/verification_email.html.erb +24 -0
  14. data/app/views/standard_id/passwordless_mailer/verification_email.text.erb +12 -0
  15. data/config/locales/en.yml +22 -0
  16. data/lib/generators/standard_id/install/templates/standard_id.rb +5 -6
  17. data/lib/standard_id/config/schema.rb +18 -31
  18. data/lib/standard_id/config_schema.rb +66 -1
  19. data/lib/standard_id/engine.rb +1 -0
  20. data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +51 -14
  21. data/lib/standard_id/otp.rb +20 -28
  22. data/lib/standard_id/passwordless/base_strategy.rb +39 -20
  23. data/lib/standard_id/passwordless/email_strategy.rb +0 -5
  24. data/lib/standard_id/passwordless/sms_strategy.rb +0 -4
  25. data/lib/standard_id/provider_registry.rb +2 -23
  26. data/lib/standard_id/scope_config.rb +22 -22
  27. data/lib/standard_id/testing/config_helpers.rb +55 -0
  28. data/lib/standard_id/testing/provider_examples.rb +1 -1
  29. data/lib/standard_id/testing.rb +1 -0
  30. data/lib/standard_id/version.rb +1 -1
  31. 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
- # Deprecated since 0.1.7 (see docs/MIGRATION_GUIDE.md), still honoured.
13
- field :passwordless_email_sender, type: :any, default: nil,
14
- deprecated: "deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber instead (it runs synchronously in the request, so I18n.locale is still available; skip it when event[:skip_sender] is true) and call Otp.issue with its default delivery: :built_in. Removal in v2.0; see docs/MIGRATION_GUIDE.md."
15
- field :passwordless_sms_sender, type: :any, default: nil,
16
- deprecated: "deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber instead (it runs synchronously in the request, so I18n.locale is still available; skip it when event[:skip_sender] is true) and call Otp.issue with its default delivery: :built_in. Removal in v2.0; see docs/MIGRATION_GUIDE.md."
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 String) key is still accepted for backward
76
- # compatibility and coerced into a single-element :profile_types array (deprecation
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
- # Deprecated since 0.8: use web.passwordless_login to control WebEngine
157
- # passwordless login. Never read; retained so host initializers that set it
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
- # Never read by the gem. OAuth clients are StandardId::ClientApplication
316
- # rows with ClientSecretCredential secrets.
317
- field :client_id, type: :string, default: nil,
318
- deprecated: "it is not read anywhere — OAuth clients are StandardId::ClientApplication records. Remove this line. Removal in v2.0."
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
- # Deprecated mechanism-specific names, retained for backwards compatibility.
605
- # The schema raises ConfigurationError on unknown fields at boot, so these
606
- # must NOT be removed while hosts still set them. When a host leaves the
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_* / the deprecated password_login_*): the API passwordless
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 = @scopes = Concurrent::Map.new
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
@@ -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
- # Per-call manual-delivery request takes precedence over the global
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
- code = event[:code_challenge]&.code
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
- ).otp_email.deliver_later
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 built_in_delivery?
38
- StandardId.config.passwordless.delivery == :built_in
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
@@ -44,15 +44,24 @@ module StandardId
44
44
  #
45
45
  # == Delivery modes
46
46
  #
47
- # * +:built_in+ — uses the engine's bundled mailer
48
- # (+StandardId::PasswordlessMailer+) when
49
- # +StandardId.config.passwordless.delivery+ is +:built_in+. Works for
50
- # +channel: :email+ only.
51
- # * +:custom+ — calls the configured +passwordless_email_sender+ or
52
- # +passwordless_sms_sender+ callback.
53
- # * +:manual+ — skip delivery entirely; the raw +code+ is returned on the
54
- # result so the caller can deliver it however they like. Useful for
55
- # custom widget/embedded flows that want full control over the channel.
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 trigger sender
16
- # attrs: { connection:, username:, code_length:, expires_in:, metadata:, skip_sender: }
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 — not just the legacy
37
- # sender_callback. Without this, Otp.issue(delivery: :manual) silently
38
- # double-delivers when c.passwordless.delivery == :built_in.
39
- emit_code_generated(challenge, username, skip_sender: skip_sender)
40
- sender_callback&.call(username, challenge.code) unless skip_sender
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
- def emit_code_generated(challenge, username, skip_sender: false)
191
- StandardId::Events.publish(
192
- StandardId::Events::PASSWORDLESS_CODE_GENERATED,
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: "sent"
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
@@ -25,10 +25,6 @@ module StandardId
25
25
  identifier = StandardId::PhoneNumberIdentifier.includes(:account).find_by(value: phone_number)
26
26
  identifier&.account
27
27
  end
28
-
29
- def sender_callback
30
- StandardId.config.passwordless_sms_sender
31
- end
32
28
  end
33
29
  end
34
30
  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!` and the provider's `setup` — deliberately stays in
57
- # `after_initialize`, where the host's configuration is complete and
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
- # - :profile_types (plural) — array of strings (preferred).
35
- # - :profile_type (singular) — single string, retained for back-compat. Emits a
36
- # deprecation warning when present.
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
- # Returns an Array<String> (possibly empty).
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
- plural = config[:profile_types]
41
- singular = config[:profile_type]
42
-
43
- if singular && plural
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
- if singular
48
- DEPRECATOR.warn(
49
- "StandardId scope config key :profile_type is deprecated and will be removed in v2.0. " \
50
- "Use :profile_types (an Array of profile-type strings) instead."
51
- )
52
- return Array(singular).map(&:to_s).reject(&:blank?)
53
- end
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
- Array(plural).map(&:to_s).reject(&:blank?)
55
+ scopes.each { |name, scope_hash| new(name, scope_hash || {}) }
56
56
  end
57
57
 
58
58
  def initialize(name, config = {})