standard_id 0.41.1 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -0
  3. data/README.md +310 -17
  4. data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
  5. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +1 -0
  6. data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
  7. data/app/controllers/concerns/standard_id/rate_limit_handling.rb +5 -19
  8. data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
  9. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
  10. data/app/controllers/standard_id/web/login_controller.rb +1 -2
  11. data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
  12. data/app/controllers/standard_id/web/verify_email/start_controller.rb +12 -8
  13. data/app/controllers/standard_id/web/verify_phone/start_controller.rb +12 -8
  14. data/app/jobs/standard_id/cleanup_all_job.rb +46 -0
  15. data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
  16. data/app/models/concerns/standard_id/credentiable.rb +8 -1
  17. data/app/models/standard_id/application_record.rb +26 -0
  18. data/app/models/standard_id/authorization_code.rb +3 -1
  19. data/app/models/standard_id/identifier.rb +1 -0
  20. data/app/models/standard_id/session.rb +2 -1
  21. data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
  22. data/app/views/standard_id/web/login/show.html.erb +5 -5
  23. data/app/views/standard_id/web/signup/show.html.erb +3 -3
  24. data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
  25. data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
  26. data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
  27. data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
  28. data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
  29. data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
  30. data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
  31. data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
  32. data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
  33. data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
  34. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
  35. data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
  36. data/lib/generators/standard_id/install/install_generator.rb +64 -3
  37. data/lib/generators/standard_id/install/templates/standard_id.rb +41 -13
  38. data/lib/standard_id/checks/migrations.rb +61 -0
  39. data/lib/standard_id/config/schema.rb +34 -20
  40. data/lib/standard_id/config_schema.rb +94 -6
  41. data/lib/standard_id/deprecator.rb +17 -0
  42. data/lib/standard_id/engine.rb +22 -0
  43. data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +3 -0
  44. data/lib/standard_id/instrumentation.rb +49 -0
  45. data/lib/standard_id/migration_check.rb +183 -0
  46. data/lib/standard_id/migration_helpers.rb +65 -0
  47. data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
  48. data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
  49. data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
  50. data/lib/standard_id/otp.rb +20 -28
  51. data/lib/standard_id/passwordless/base_strategy.rb +18 -14
  52. data/lib/standard_id/passwordless/email_strategy.rb +0 -5
  53. data/lib/standard_id/passwordless/sms_strategy.rb +0 -4
  54. data/lib/standard_id/provider_registry.rb +95 -5
  55. data/lib/standard_id/providers/base.rb +222 -8
  56. data/lib/standard_id/providers/plugin_railtie.rb +59 -0
  57. data/lib/standard_id/scope_config.rb +43 -26
  58. data/lib/standard_id/testing/config_helpers.rb +55 -0
  59. data/lib/standard_id/testing/provider_examples.rb +117 -0
  60. data/lib/standard_id/testing.rb +2 -0
  61. data/lib/standard_id/version.rb +1 -1
  62. data/lib/standard_id.rb +26 -0
  63. metadata +25 -17
  64. data/config/initializers/migration_helpers.rb +0 -32
@@ -0,0 +1,183 @@
1
+ module StandardId
2
+ # Detects StandardId migrations a host never installed (or installed but
3
+ # never ran).
4
+ #
5
+ # WHY
6
+ #
7
+ # `bin/rails standard_id:install:migrations` copies the engine's migrations
8
+ # into the host with NEW timestamps. Rails' pending-migration check only
9
+ # sees files that are in the host's db/migrate, so a gem migration that was
10
+ # never copied is invisible to it — fundbright-web, luminality-web and
11
+ # nutripod-web each ran for months without 20260416180511's indexes, and
12
+ # two still lack 20260414200000. This compares by migration NAME (the part
13
+ # after the timestamp, with or without the `.standard_id` suffix Rails adds),
14
+ # which survives the re-timestamping.
15
+ #
16
+ # COST
17
+ #
18
+ # A Dir.glob over the host's migration paths; the database is only consulted
19
+ # (one `schema_migrations` read) when `check_database: true`, which the boot
20
+ # check never passes.
21
+ module MigrationCheck
22
+ GEM_MIGRATIONS_PATH = File.expand_path("../../db/migrate", __dir__)
23
+ FILENAME = /\A(\d+)_(\w+?)(?:\.[a-z_]+)?\.rb\z/
24
+
25
+ # A gem migration whose job is fully done by a LATER gem migration, so a
26
+ # host that installed the later one may skip it. name => superseding name.
27
+ #
28
+ # 20260414200000 adds a plain, non-concurrent 4-column index on
29
+ # standard_id_code_challenges; 20260416180511 adds the partial
30
+ # `index_code_challenges_on_active_target_created_at` (same columns,
31
+ # WHERE used_at IS NULL, built CONCURRENTLY) that serves the same lookups.
32
+ # Hosts on busy tables (fundbright-web, luminality-web) skipped the former
33
+ # on purpose rather than take the write lock.
34
+ SUPERSEDED_BY = {
35
+ "add_target_created_at_index_to_code_challenges" =>
36
+ "add_partial_indexes_for_active_session_and_challenge_lookups"
37
+ }.freeze
38
+
39
+ # Gem migrations hosts are EXPECTED to hold back for a while — reported as
40
+ # a pending upgrade step (severity :info), never as an error, so they do
41
+ # not warn at boot, fail boot in :raise mode, or degrade the health check.
42
+ #
43
+ # 20260915000000 drops a column 0.41.1 ignores; per the 0.41.1 upgrade
44
+ # notes it must only run once 0.41.1+ is deployed everywhere.
45
+ DEFERRED_UPGRADE_STEPS = {
46
+ "remove_refresh_token_lifetime_from_standard_id_client_applications" =>
47
+ "run once StandardId >= 0.41.1 is deployed to every process (it drops a column 0.41.1 ignores)"
48
+ }.freeze
49
+
50
+ # state: :not_installed — no host migration file with this name
51
+ # :not_run — the host file exists but its version is not in schema_migrations
52
+ # severity: :error — a migration the host should have
53
+ # :info — a DEFERRED_UPGRADE_STEPS entry: pending, but intentionally
54
+ Missing = Data.define(:name, :version, :state, :severity) do
55
+ def initialize(name:, version:, state:, severity: :error) = super
56
+
57
+ def info? = severity == :info
58
+
59
+ def to_s
60
+ label = state.to_s.tr("_", " ")
61
+ info? ? "#{version}_#{name} (#{label}; pending upgrade step: #{DEFERRED_UPGRADE_STEPS[name]})" : "#{version}_#{name} (#{label})"
62
+ end
63
+ end
64
+
65
+ MODES = %i[warn raise ignore].freeze
66
+
67
+ module_function
68
+
69
+ # @return [Array<Array(String, String)>] [[original_version, name], ...] for every gem migration
70
+ def gem_migrations
71
+ @gem_migrations ||= parse_dir(GEM_MIGRATIONS_PATH).sort.freeze
72
+ end
73
+
74
+ # @param paths [Array<String>] host migration directories
75
+ # @param check_database [Boolean] also report installed-but-unrun migrations
76
+ # @param ignore [Array<String>] migration names (or original versions) to skip
77
+ # @return [Array<Missing>]
78
+ def pending(paths: host_migration_paths, check_database: false, ignore: StandardId.config.ignored_migrations)
79
+ ignore = Array(ignore).map(&:to_s)
80
+ host = host_versions_by_name(paths)
81
+ applied = check_database ? applied_versions : nil
82
+
83
+ present = ->(migration_name) { present_in_host?(host[migration_name], applied) }
84
+
85
+ gem_migrations.filter_map do |version, name|
86
+ next if ignore.include?(name) || ignore.include?(version)
87
+
88
+ host_versions = host[name]
89
+ state = if host_versions.blank?
90
+ :not_installed
91
+ elsif applied && (host_versions & applied).empty?
92
+ :not_run
93
+ end
94
+ next if state.nil?
95
+ next if (successor = SUPERSEDED_BY[name]) && present.call(successor)
96
+
97
+ severity = DEFERRED_UPGRADE_STEPS.key?(name) ? :info : :error
98
+ Missing.new(name: name, version: version, state: state, severity: severity)
99
+ end
100
+ end
101
+
102
+ # Installed (and, when +applied+ is given, run).
103
+ def present_in_host?(host_versions, applied)
104
+ return false if host_versions.blank?
105
+
106
+ applied.nil? || (host_versions & applied).any?
107
+ end
108
+
109
+ # The mode in effect: config.missing_migrations, else :warn in
110
+ # development/test and :ignore everywhere else (never raise in production
111
+ # by default).
112
+ def mode
113
+ configured = StandardId.config.missing_migrations
114
+ return configured.to_sym if configured.present?
115
+
116
+ Rails.env.local? ? :warn : :ignore
117
+ end
118
+
119
+ # Called from the engine's after_initialize. File-system only — boot must
120
+ # not need a database (assets:precompile, db:create).
121
+ #
122
+ # @raise [StandardId::ConfigurationError] in :raise mode when migrations are missing
123
+ def verify_at_boot!
124
+ current = mode
125
+ unless MODES.include?(current)
126
+ raise StandardId::ConfigurationError,
127
+ "StandardId.config.missing_migrations must be one of #{MODES.inspect} (got #{current.inspect})"
128
+ end
129
+ return if current == :ignore
130
+
131
+ all_missing = pending
132
+ deferred, missing = all_missing.partition(&:info?)
133
+ Rails.logger.info(deferred_message(deferred)) if deferred.any?
134
+ return if missing.empty?
135
+
136
+ message = boot_message(missing)
137
+ raise StandardId::ConfigurationError, message if current == :raise
138
+
139
+ Rails.logger.warn(message)
140
+ warn(message) if Rails.env.local?
141
+ end
142
+
143
+ def boot_message(missing)
144
+ <<~MESSAGE.strip
145
+ [StandardId] #{missing.size} StandardId migration(s) are not installed in this app:
146
+ #{missing.map(&:to_s).join("\n ")}
147
+ Run `bin/rails standard_id:install:migrations && bin/rails db:migrate`.
148
+ If one is deliberately skipped (e.g. superseded by a host migration), list its name in
149
+ `StandardId.config.ignored_migrations`. Set `config.missing_migrations = :raise` to fail
150
+ boot instead, or `:ignore` to silence this check.
151
+ MESSAGE
152
+ end
153
+
154
+ def deferred_message(deferred)
155
+ "[StandardId] Pending upgrade step(s), held back intentionally: #{deferred.map(&:to_s).join('; ')}"
156
+ end
157
+
158
+ def host_migration_paths
159
+ paths = Rails.application.paths["db/migrate"].existent
160
+ paths += Array(ActiveRecord::Migrator.migrations_paths).map { |p| File.expand_path(p, Rails.root) }
161
+ paths.uniq.select { |p| File.directory?(p) }
162
+ end
163
+
164
+ def host_versions_by_name(paths)
165
+ Array(paths).flat_map { |dir| parse_dir(dir) }.each_with_object({}) do |(version, name), acc|
166
+ (acc[name] ||= []) << version
167
+ end
168
+ end
169
+
170
+ def parse_dir(dir)
171
+ Dir.children(dir).filter_map do |file|
172
+ match = FILENAME.match(file)
173
+ [match[1], match[2]] if match
174
+ end
175
+ rescue Errno::ENOENT
176
+ []
177
+ end
178
+
179
+ def applied_versions
180
+ ActiveRecord::Base.connection_pool.schema_migration.versions
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,65 @@
1
+ require "active_support/concern"
2
+
3
+ module StandardId
4
+ # `primary_key_type` / `foreign_key_type` for StandardId's own migrations:
5
+ # the host's generator `primary_key_type` (e.g. :uuid), else :bigint.
6
+ #
7
+ # These used to be monkey-patched onto ActiveRecord::Migration itself, so
8
+ # every migration in every host app gained three methods it never asked for.
9
+ # They are now scoped to StandardId migrations:
10
+ #
11
+ # * the gem's migrations `include StandardId::MigrationHelpers` explicitly
12
+ # (so newly installed copies carry the include with them), and
13
+ # * copies installed BEFORE that line existed — which call the helpers with
14
+ # no include — are recognised by file name (a StandardId migration name,
15
+ # with or without the `.standard_id` suffix) when their class is defined,
16
+ # and get the module then. No other migration is touched.
17
+ module MigrationHelpers
18
+ extend ActiveSupport::Concern
19
+
20
+ class_methods do
21
+ def primary_and_foreign_key_types
22
+ config = Rails.configuration.generators
23
+ config.options[config.orm][:primary_key_type] || :bigint
24
+ end
25
+
26
+ def primary_key_type = primary_and_foreign_key_types
27
+ def foreign_key_type = primary_and_foreign_key_types
28
+ end
29
+
30
+ def primary_and_foreign_key_types = self.class.primary_and_foreign_key_types
31
+ def primary_key_type = self.class.primary_key_type
32
+ def foreign_key_type = self.class.foreign_key_type
33
+
34
+ # Is +path+ a (possibly host-copied) StandardId migration file?
35
+ def self.standard_id_migration_file?(path)
36
+ return false if path.nil?
37
+
38
+ match = StandardId::MigrationCheck::FILENAME.match(File.basename(path))
39
+ return false unless match
40
+
41
+ gem_migration_names.include?(match[2])
42
+ end
43
+
44
+ def self.gem_migration_names
45
+ @gem_migration_names ||= StandardId::MigrationCheck.gem_migrations.to_set(&:last).freeze
46
+ end
47
+
48
+ # Prepended onto ActiveRecord::Migration's singleton class. `inherited`
49
+ # fires while the migration's `class ... < ActiveRecord::Migration[x]`
50
+ # line runs, so the caller location is the migration file itself.
51
+ module LegacyCopySupport
52
+ def inherited(subclass)
53
+ super
54
+ path = caller_locations(1, 1).first&.path
55
+ return unless StandardId::MigrationHelpers.standard_id_migration_file?(path)
56
+
57
+ subclass.include(StandardId::MigrationHelpers)
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ ActiveSupport.on_load(:active_record) do
64
+ ActiveRecord::Migration.singleton_class.prepend(StandardId::MigrationHelpers::LegacyCopySupport)
65
+ end
@@ -78,6 +78,14 @@ module StandardId
78
78
  # @raise [StandardId::NoBoundProfileError]
79
79
  # @raise [StandardId::AmbiguousProfileError]
80
80
  def resolve!(account:, audience:)
81
+ StandardId::Instrumentation.instrument(
82
+ StandardId::Instrumentation::AUDIENCE_PROFILE_RESOLVE, audience: audience
83
+ ) { resolve_bound_profile!(account, audience) }
84
+ end
85
+
86
+ private
87
+
88
+ def resolve_bound_profile!(account, audience)
81
89
  types = profile_types_for(audience)
82
90
  raise ArgumentError, "audience #{audience.inspect} has no profile binding" if types.empty?
83
91
 
@@ -101,8 +109,6 @@ module StandardId
101
109
  strict_default_lookup(account, audience, types)
102
110
  end
103
111
 
104
- private
105
-
106
112
  def default_lookup(account, types)
107
113
  return nil unless account.respond_to?(:profiles)
108
114
 
@@ -14,7 +14,7 @@ module StandardId
14
14
  # Only the normal rotation path (revoke old + create new) is wrapped
15
15
  # in a transaction for atomicity.
16
16
  def execute
17
- authenticate!
17
+ instrumented_authenticate!
18
18
  response = nil
19
19
  StandardId::RefreshToken.transaction do
20
20
  rotate_current_refresh_token!
@@ -16,18 +16,37 @@ module StandardId
16
16
  end
17
17
 
18
18
  def execute
19
- authenticate!
19
+ instrumented_authenticate!
20
20
  generate_token_response
21
21
  end
22
22
 
23
23
  private
24
24
 
25
+ # authenticate!, wrapped in the Instrumentation::AUTHENTICATE event.
26
+ def instrumented_authenticate!
27
+ StandardId::Instrumentation.instrument(StandardId::Instrumentation::AUTHENTICATE, instrumentation_payload) do
28
+ authenticate!
29
+ end
30
+ end
31
+
32
+ def instrumentation_payload
33
+ { flow: self.class.name, grant_type: instrumentation_grant_type }
34
+ end
35
+
36
+ # Abstract/anonymous flows (specs) may not implement grant_type; the
37
+ # instrumentation payload must never be what raises.
38
+ def instrumentation_grant_type
39
+ grant_type
40
+ rescue NotImplementedError
41
+ nil
42
+ end
43
+
25
44
  def authenticate!
26
45
  raise NotImplementedError, "Subclasses must implement authenticate!"
27
46
  end
28
47
 
29
48
  def validate_client_secret!(client_id, client_secret)
30
- client_secret_credential = StandardId::ClientSecretCredential.active.find_by(client_id: client_id)
49
+ client_secret_credential = StandardId::ClientSecretCredential.active.includes(:client_application).find_by(client_id: client_id)
31
50
  unless client_secret_credential&.authenticate_client_secret(client_secret)
32
51
  raise StandardId::InvalidClientError, "Client authentication failed"
33
52
  end
@@ -36,7 +55,10 @@ module StandardId
36
55
 
37
56
  def generate_token_response
38
57
  validate_audience!
39
- enforce_audience_profile_binding!
58
+ StandardId::Instrumentation.instrument(
59
+ StandardId::Instrumentation::AUDIENCE_PROFILE_BINDING,
60
+ instrumentation_payload.merge(audience: Array(audience).reject(&:blank?))
61
+ ) { enforce_audience_profile_binding! }
40
62
  emit_token_issuing
41
63
  expires_in = token_expiry
42
64
  payload = build_jwt_payload(expires_in)
@@ -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,11 +42,10 @@ 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
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
+ emit_code_generated(challenge, username, skip_sender: skip_sender, delivery: delivery)
41
49
  emit_code_sent(username) unless skip_sender
42
50
  challenge
43
51
  end
@@ -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,7 +190,7 @@ module StandardId
187
190
  )
188
191
  end
189
192
 
190
- def emit_code_generated(challenge, username, skip_sender: false)
193
+ def emit_code_generated(challenge, username, skip_sender: false, delivery: nil)
191
194
  StandardId::Events.publish(
192
195
  StandardId::Events::PASSWORDLESS_CODE_GENERATED,
193
196
  code_challenge: challenge,
@@ -195,7 +198,8 @@ module StandardId
195
198
  channel: connection_type,
196
199
  realm: @realm,
197
200
  expires_at: challenge.expires_at,
198
- skip_sender: skip_sender
201
+ skip_sender: skip_sender,
202
+ delivery: delivery
199
203
  )
200
204
  end
201
205
 
@@ -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
@@ -5,6 +5,10 @@ module StandardId
5
5
  class ProviderNotFoundError < StandardError; end
6
6
  class InvalidProviderError < StandardError; end
7
7
 
8
+ # Keys a provider's `config_schema` entry may carry that belong to
9
+ # StandardId (see Providers::Base) rather than to ConfigSchema.
10
+ PROVIDER_FIELD_OPTIONS = %i[env required].freeze
11
+
8
12
  @providers = Concurrent::Map.new
9
13
 
10
14
  class << self
@@ -19,7 +23,6 @@ module StandardId
19
23
  validate_provider!(provider_class)
20
24
  providers[name.to_s] = provider_class
21
25
  declare_config_schema(provider_class)
22
- provider_class.setup if provider_class.respond_to?(:setup)
23
26
  provider_class
24
27
  end
25
28
 
@@ -44,9 +47,8 @@ module StandardId
44
47
  # populated before any initializer runs.
45
48
  #
46
49
  # Only FIELD DECLARATION moves earlier. Full `register` — which also runs
47
- # `validate_provider!` and the provider's `setup` — deliberately stays in
48
- # `after_initialize`, where the host's configuration is complete and
49
- # `setup` can rely on it.
50
+ # `validate_provider!` — deliberately stays in `after_initialize`, where
51
+ # the host's configuration is complete.
50
52
  #
51
53
  # Idempotent: `ConfigSchema#add_field` uses `compute_if_absent`, so a field
52
54
  # already declared here is untouched when the plugin later calls `register`.
@@ -84,10 +86,80 @@ module StandardId
84
86
  return if schema.nil? || schema.empty?
85
87
 
86
88
  schema.each do |field_name, options|
87
- StandardId::ConfigSchema.add_field(scope: :social, name: field_name, **options)
89
+ field_options = options.except(*PROVIDER_FIELD_OPTIONS)
90
+ env_name = env_var_for(field_name, options)
91
+ field_options[:default] = env_default(env_name, options[:default]) if env_name
92
+
93
+ StandardId::ConfigSchema.add_field(scope: :social, name: field_name, **field_options)
94
+ end
95
+ end
96
+
97
+ # The ENV variable a provider config field falls back to, or nil.
98
+ #
99
+ # Canonical scheme: the upper-cased field name (`apple_private_key` →
100
+ # `APPLE_PRIVATE_KEY`). A provider overrides it per field with
101
+ # `env: "OTHER_NAME"`, or opts out with `env: false`.
102
+ #
103
+ # @param field_name [Symbol, String]
104
+ # @param options [Hash] the field's config_schema entry
105
+ # @return [String, nil]
106
+ def env_var_for(field_name, options = {})
107
+ env = options.fetch(:env, true)
108
+ return nil if env == false || env.nil?
109
+
110
+ env == true ? field_name.to_s.upcase : env.to_s
111
+ end
112
+
113
+ # Registered providers the host app has switched on (see
114
+ # Providers::Base.enabled?).
115
+ #
116
+ # @return [Hash{String => Class}] Provider name => class
117
+ def enabled
118
+ all.select { |_name, provider_class| provider_class.enabled? }
119
+ end
120
+
121
+ # Configuration problems across every registered provider.
122
+ #
123
+ # @return [Hash{String => Array<String>}] Provider name => errors, only
124
+ # for providers that have any
125
+ def configuration_errors
126
+ all.each_with_object({}) do |(name, provider_class), errors|
127
+ provider_errors = provider_class.configuration_errors
128
+ errors[name] = provider_errors if provider_errors.any?
88
129
  end
89
130
  end
90
131
 
132
+ # Boot-time check that every enabled provider is fully configured.
133
+ #
134
+ # Run by StandardId::Engine once every plugin has registered. A provider
135
+ # whose client ID is set but whose other required fields are not starts
136
+ # its sign-in flow fine and only fails at the callback — after the user
137
+ # has already authenticated with the provider — so this surfaces it at
138
+ # boot instead.
139
+ #
140
+ # Behaviour follows `c.social.provider_misconfiguration`:
141
+ # - `:warn` (default) — log a warning in every environment.
142
+ # - `:raise` — raise StandardId::ConfigurationError in production; log a
143
+ # warning in every other environment, so a developer without production
144
+ # credentials can still boot the app.
145
+ #
146
+ # @param mode [Symbol] Override the configured mode
147
+ # @param logger [Logger, nil]
148
+ # @return [Hash{String => Array<String>}] the errors found
149
+ # @raise [StandardId::ConfigurationError]
150
+ def validate_configuration!(mode: StandardId.config.social.provider_misconfiguration, logger: StandardId.logger)
151
+ errors = configuration_errors
152
+ return errors if errors.empty?
153
+
154
+ message = "StandardId social provider configuration is incomplete: " +
155
+ errors.map { |name, provider_errors| "#{name} (#{provider_errors.join('; ')})" }.join(", ")
156
+
157
+ raise StandardId::ConfigurationError, message if mode.to_s == "raise" && production?
158
+
159
+ logger&.warn("[StandardId] #{message}")
160
+ errors
161
+ end
162
+
91
163
  # Get provider by name
92
164
  # @param name [Symbol, String] Provider identifier
93
165
  # @return [Class] Provider class
@@ -114,6 +186,24 @@ module StandardId
114
186
 
115
187
  private
116
188
 
189
+ # A default that prefers a non-blank ENV value, then the field's own
190
+ # default. Evaluated lazily (ConfigSchema calls it when the config is
191
+ # built, or on first read of a field declared afterwards), and only when
192
+ # the host never assigned the field — an explicit assignment, even of
193
+ # nil, always wins.
194
+ def env_default(env_name, fallback)
195
+ lambda do
196
+ value = ENV[env_name]
197
+ next value if value.present?
198
+
199
+ fallback.respond_to?(:call) ? fallback.call : fallback
200
+ end
201
+ end
202
+
203
+ def production?
204
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
205
+ end
206
+
117
207
  def validate_provider!(provider_class)
118
208
  unless provider_class.is_a?(Class)
119
209
  raise InvalidProviderError,