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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -0
- data/README.md +310 -17
- data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +1 -0
- data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
- data/app/controllers/concerns/standard_id/rate_limit_handling.rb +5 -19
- data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
- data/app/controllers/standard_id/web/login_controller.rb +1 -2
- data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
- data/app/controllers/standard_id/web/verify_email/start_controller.rb +12 -8
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +12 -8
- data/app/jobs/standard_id/cleanup_all_job.rb +46 -0
- data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
- data/app/models/concerns/standard_id/credentiable.rb +8 -1
- data/app/models/standard_id/application_record.rb +26 -0
- data/app/models/standard_id/authorization_code.rb +3 -1
- data/app/models/standard_id/identifier.rb +1 -0
- data/app/models/standard_id/session.rb +2 -1
- data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
- data/app/views/standard_id/web/login/show.html.erb +5 -5
- data/app/views/standard_id/web/signup/show.html.erb +3 -3
- data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
- data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
- data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
- data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
- data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
- data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
- data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
- data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
- data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
- data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
- data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
- data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
- data/lib/generators/standard_id/install/install_generator.rb +64 -3
- data/lib/generators/standard_id/install/templates/standard_id.rb +41 -13
- data/lib/standard_id/checks/migrations.rb +61 -0
- data/lib/standard_id/config/schema.rb +34 -20
- data/lib/standard_id/config_schema.rb +94 -6
- data/lib/standard_id/deprecator.rb +17 -0
- data/lib/standard_id/engine.rb +22 -0
- data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +3 -0
- data/lib/standard_id/instrumentation.rb +49 -0
- data/lib/standard_id/migration_check.rb +183 -0
- data/lib/standard_id/migration_helpers.rb +65 -0
- data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
- data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
- data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
- data/lib/standard_id/otp.rb +20 -28
- data/lib/standard_id/passwordless/base_strategy.rb +18 -14
- 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 +95 -5
- data/lib/standard_id/providers/base.rb +222 -8
- data/lib/standard_id/providers/plugin_railtie.rb +59 -0
- data/lib/standard_id/scope_config.rb +43 -26
- data/lib/standard_id/testing/config_helpers.rb +55 -0
- data/lib/standard_id/testing/provider_examples.rb +117 -0
- data/lib/standard_id/testing.rb +2 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +26 -0
- metadata +25 -17
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
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,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
|
|
37
|
-
#
|
|
38
|
-
#
|
|
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
|
|
@@ -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!`
|
|
48
|
-
#
|
|
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
|
-
|
|
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,
|