standard_id 0.41.1 → 0.42.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +251 -13
  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/social_authentication.rb +1 -1
  8. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
  9. data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
  10. data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
  11. data/app/models/concerns/standard_id/credentiable.rb +8 -1
  12. data/app/models/standard_id/application_record.rb +26 -0
  13. data/app/models/standard_id/authorization_code.rb +3 -1
  14. data/app/models/standard_id/identifier.rb +1 -0
  15. data/app/models/standard_id/session.rb +2 -1
  16. data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
  17. data/app/views/standard_id/web/login/show.html.erb +5 -5
  18. data/app/views/standard_id/web/signup/show.html.erb +3 -3
  19. data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
  20. data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
  21. data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
  22. data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
  23. data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
  24. data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
  25. data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
  26. data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
  27. data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
  28. data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
  29. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
  30. data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
  31. data/lib/generators/standard_id/install/install_generator.rb +64 -3
  32. data/lib/generators/standard_id/install/templates/standard_id.rb +42 -13
  33. data/lib/standard_id/checks/migrations.rb +61 -0
  34. data/lib/standard_id/config/schema.rb +36 -9
  35. data/lib/standard_id/config_schema.rb +28 -5
  36. data/lib/standard_id/deprecator.rb +17 -0
  37. data/lib/standard_id/engine.rb +21 -0
  38. data/lib/standard_id/instrumentation.rb +49 -0
  39. data/lib/standard_id/migration_check.rb +183 -0
  40. data/lib/standard_id/migration_helpers.rb +65 -0
  41. data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
  42. data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
  43. data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
  44. data/lib/standard_id/provider_registry.rb +113 -2
  45. data/lib/standard_id/providers/base.rb +222 -8
  46. data/lib/standard_id/providers/plugin_railtie.rb +59 -0
  47. data/lib/standard_id/scope_config.rb +24 -7
  48. data/lib/standard_id/testing/provider_examples.rb +117 -0
  49. data/lib/standard_id/testing.rb +1 -0
  50. data/lib/standard_id/version.rb +1 -1
  51. data/lib/standard_id.rb +26 -0
  52. metadata +23 -17
  53. data/config/initializers/migration_helpers.rb +0 -32
@@ -1,5 +1,6 @@
1
1
  require "active_support/ordered_options"
2
2
  require "concurrent/map"
3
+ require "standard_id/deprecator"
3
4
 
4
5
  module StandardId
5
6
  # Lightweight configuration schema backed by ActiveSupport::OrderedOptions.
@@ -9,7 +10,10 @@ module StandardId
9
10
  # to the owning scope (so host apps can read base-scope fields like
10
11
  # `config.account_class_name` without the `base.` prefix).
11
12
  class ConfigSchema
12
- Field = Struct.new(:name, :type, :default) do
13
+ # +deprecation+ is a message (String) for a field kept only so existing
14
+ # host initializers still boot. Assigning a non-nil value warns through
15
+ # StandardId.deprecator; reads and schema defaults never warn.
16
+ Field = Struct.new(:name, :type, :default, :deprecation) do
13
17
  def default_value
14
18
  return default.call if default.respond_to?(:call)
15
19
  return default.dup if default.is_a?(Array) || default.is_a?(Hash)
@@ -35,9 +39,9 @@ module StandardId
35
39
  self
36
40
  end
37
41
 
38
- def add_field(scope:, name:, type: :string, default: nil)
42
+ def add_field(scope:, name:, type: :string, default: nil, deprecated: nil)
39
43
  fields = ensure_scope(scope)
40
- fields.compute_if_absent(name.to_sym) { Field.new(name.to_sym, type, default) }
44
+ fields.compute_if_absent(name.to_sym) { Field.new(name.to_sym, type, default, deprecated) }
41
45
  end
42
46
 
43
47
  # Register a scope without adding a field. Allows `define { scope :foo }` so
@@ -100,8 +104,8 @@ module StandardId
100
104
  DSL.new(@schema, name.to_sym).instance_eval(&block) if block
101
105
  end
102
106
 
103
- def field(name, type: :string, default: nil, **)
104
- @schema.add_field(scope: @scope_name, name: name, type: type, default: default)
107
+ def field(name, type: :string, default: nil, deprecated: nil, **)
108
+ @schema.add_field(scope: @scope_name, name: name, type: type, default: default, deprecated: deprecated)
105
109
  end
106
110
  end
107
111
 
@@ -122,6 +126,7 @@ module StandardId
122
126
 
123
127
  def []=(key, value)
124
128
  validate!(key)
129
+ warn_if_deprecated(key, value)
125
130
  super(key.to_sym, value)
126
131
  end
127
132
 
@@ -154,6 +159,24 @@ module StandardId
154
159
  "Unknown field '#{key}' for scope '#{@scope_name}'. Valid fields: #{@schema.scopes[@scope_name]&.keys}"
155
160
  end
156
161
 
162
+ def warn_if_deprecated(key, value)
163
+ return if value.nil?
164
+
165
+ message = @schema.field_for(@scope_name, key)&.deprecation
166
+ return if message.nil?
167
+
168
+ # Point the warning at the host's assignment, not at this file.
169
+ # (OrderedOptions' method_missing forwards `c.foo = x` to #[]=.)
170
+ callstack = caller_locations(1).reject do |location|
171
+ location.path == __FILE__ || location.path.end_with?("active_support/ordered_options.rb")
172
+ end
173
+ StandardId.deprecator.warn("StandardId.config.#{config_path(key)} is deprecated: #{message}", callstack)
174
+ end
175
+
176
+ def config_path(key)
177
+ @scope_name == :base ? key.to_s : "#{@scope_name}.#{key}"
178
+ end
179
+
157
180
  def cast_read(key, value)
158
181
  field = @schema.field_for(@scope_name, key)
159
182
  return value unless field
@@ -0,0 +1,17 @@
1
+ require "active_support/deprecation"
2
+
3
+ module StandardId
4
+ # The gem's single ActiveSupport::Deprecation instance.
5
+ #
6
+ # The engine registers it in `Rails.application.deprecators[:standard_id]`,
7
+ # so the host's `config.active_support.deprecation` behaviour (:raise in
8
+ # test, :log / :notify in production, ...) and
9
+ # `Rails.application.deprecators.silence` apply to StandardId warnings exactly
10
+ # as they do to Rails' own. An unregistered deprecator only ever printed to
11
+ # stderr, whatever the host configured.
12
+ #
13
+ # @return [ActiveSupport::Deprecation]
14
+ def self.deprecator
15
+ @deprecator ||= ActiveSupport::Deprecation.new("2.0", "StandardId")
16
+ end
17
+ end
@@ -37,6 +37,26 @@ module StandardId
37
37
  StandardId::ProviderRegistry.declare_config_schemas!
38
38
  end
39
39
 
40
+ # Route StandardId deprecation warnings through the host's deprecation
41
+ # behaviour (`config.active_support.deprecation`, `report_deprecations`,
42
+ # `Rails.application.deprecators.silence`).
43
+ initializer "standard_id.deprecator" do |app|
44
+ app.deprecators[:standard_id] = StandardId.deprecator if app.respond_to?(:deprecators)
45
+ end
46
+
47
+ # Check every enabled social provider is fully configured, once all of
48
+ # them have registered.
49
+ #
50
+ # Provider plugins register from `config.after_initialize` hooks added
51
+ # when their gem is required — before any initializer runs. A hook added
52
+ # from inside an initializer is appended after all of those, so it sees
53
+ # the complete registry.
54
+ initializer "standard_id.validate_social_providers" do |app|
55
+ app.config.after_initialize do
56
+ StandardId::ProviderRegistry.validate_configuration!
57
+ end
58
+ end
59
+
40
60
  initializer "standard_id.filter_parameters" do |app|
41
61
  app.config.filter_parameters += %i[
42
62
  code_verifier
@@ -79,6 +99,7 @@ module StandardId
79
99
  StandardId::Config::ScopeClaimsValidator.validate!
80
100
 
81
101
  StandardId::Engine.verify_host_cookie_encryption!(app)
102
+ StandardId::MigrationCheck.verify_at_boot!
82
103
  StandardId::Engine.warn_if_allowed_audiences_empty_in_production!
83
104
  end
84
105
 
@@ -0,0 +1,49 @@
1
+ require "active_support/notifications"
2
+
3
+ module StandardId
4
+ # ActiveSupport::Notifications hooks around the expensive, opaque steps of
5
+ # the OAuth token endpoint, so hosts can attach tracing spans (Sentry,
6
+ # OpenTelemetry, Datadog) or timing metrics without prepending onto private
7
+ # gem methods.
8
+ #
9
+ # Names follow the Rails `<event>.<library>` convention (like
10
+ # `process_action.action_controller`), deliberately distinct from the
11
+ # `standard_id.<domain>.<event>` names StandardId::Events publishes: these
12
+ # are timing hooks, not audit events, and must not reach audit subscribers
13
+ # listening on `standard_id.*`.
14
+ #
15
+ # Every event is a block instrument, so subscribers get start/finish (and
16
+ # `:exception` / `:exception_object` in the payload when the step raised).
17
+ # Events nest: AUDIENCE_PROFILE_RESOLVE fires inside AUDIENCE_PROFILE_BINDING.
18
+ #
19
+ # Subscribe to all of them with:
20
+ #
21
+ # ActiveSupport::Notifications.subscribe(StandardId::Instrumentation::PATTERN) { |event| ... }
22
+ module Instrumentation
23
+ # TokenGrantFlow#authenticate! — client authentication plus grant
24
+ # validation (for refresh_token: JWT decode + token row lookup + reuse
25
+ # detection). Payload: :flow (class name), :grant_type.
26
+ AUTHENTICATE = "authenticate.standard_id".freeze
27
+
28
+ # TokenGrantFlow#enforce_audience_profile_binding! — the account load and
29
+ # profile resolution for audience→profile binding. Fires on every token
30
+ # grant, including when no binding is configured (then it is a no-op).
31
+ # Payload: :flow, :grant_type, :audience (Array<String>).
32
+ AUDIENCE_PROFILE_BINDING = "audience_profile_binding.standard_id".freeze
33
+
34
+ # Oauth::AudienceProfileResolver.resolve! — just the resolver call (the
35
+ # host's `oauth.audience_profile_resolver` or the built-in strict lookup).
36
+ # Payload: :audience (String).
37
+ AUDIENCE_PROFILE_RESOLVE = "audience_profile_resolve.standard_id".freeze
38
+
39
+ EVENTS = [AUTHENTICATE, AUDIENCE_PROFILE_BINDING, AUDIENCE_PROFILE_RESOLVE].freeze
40
+
41
+ # Matches every instrumentation event above and nothing StandardId::Events
42
+ # publishes.
43
+ PATTERN = /\.standard_id\z/
44
+
45
+ def self.instrument(name, payload = {}, &)
46
+ ActiveSupport::Notifications.instrument(name, payload, &)
47
+ end
48
+ end
49
+ end
@@ -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)
@@ -5,6 +5,15 @@ 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
+
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
+
8
17
  @providers = Concurrent::Map.new
9
18
 
10
19
  class << self
@@ -19,7 +28,7 @@ module StandardId
19
28
  validate_provider!(provider_class)
20
29
  providers[name.to_s] = provider_class
21
30
  declare_config_schema(provider_class)
22
- provider_class.setup if provider_class.respond_to?(:setup)
31
+ run_deprecated_setup(provider_class)
23
32
  provider_class
24
33
  end
25
34
 
@@ -84,10 +93,80 @@ module StandardId
84
93
  return if schema.nil? || schema.empty?
85
94
 
86
95
  schema.each do |field_name, options|
87
- StandardId::ConfigSchema.add_field(scope: :social, name: field_name, **options)
96
+ field_options = options.except(*PROVIDER_FIELD_OPTIONS)
97
+ env_name = env_var_for(field_name, options)
98
+ field_options[:default] = env_default(env_name, options[:default]) if env_name
99
+
100
+ StandardId::ConfigSchema.add_field(scope: :social, name: field_name, **field_options)
88
101
  end
89
102
  end
90
103
 
104
+ # The ENV variable a provider config field falls back to, or nil.
105
+ #
106
+ # Canonical scheme: the upper-cased field name (`apple_private_key` →
107
+ # `APPLE_PRIVATE_KEY`). A provider overrides it per field with
108
+ # `env: "OTHER_NAME"`, or opts out with `env: false`.
109
+ #
110
+ # @param field_name [Symbol, String]
111
+ # @param options [Hash] the field's config_schema entry
112
+ # @return [String, nil]
113
+ def env_var_for(field_name, options = {})
114
+ env = options.fetch(:env, true)
115
+ return nil if env == false || env.nil?
116
+
117
+ env == true ? field_name.to_s.upcase : env.to_s
118
+ end
119
+
120
+ # Registered providers the host app has switched on (see
121
+ # Providers::Base.enabled?).
122
+ #
123
+ # @return [Hash{String => Class}] Provider name => class
124
+ def enabled
125
+ all.select { |_name, provider_class| provider_class.enabled? }
126
+ end
127
+
128
+ # Configuration problems across every registered provider.
129
+ #
130
+ # @return [Hash{String => Array<String>}] Provider name => errors, only
131
+ # for providers that have any
132
+ def configuration_errors
133
+ all.each_with_object({}) do |(name, provider_class), errors|
134
+ provider_errors = provider_class.configuration_errors
135
+ errors[name] = provider_errors if provider_errors.any?
136
+ end
137
+ end
138
+
139
+ # Boot-time check that every enabled provider is fully configured.
140
+ #
141
+ # Run by StandardId::Engine once every plugin has registered. A provider
142
+ # whose client ID is set but whose other required fields are not starts
143
+ # its sign-in flow fine and only fails at the callback — after the user
144
+ # has already authenticated with the provider — so this surfaces it at
145
+ # boot instead.
146
+ #
147
+ # Behaviour follows `c.social.provider_misconfiguration`:
148
+ # - `:warn` (default) — log a warning in every environment.
149
+ # - `:raise` — raise StandardId::ConfigurationError in production; log a
150
+ # warning in every other environment, so a developer without production
151
+ # credentials can still boot the app.
152
+ #
153
+ # @param mode [Symbol] Override the configured mode
154
+ # @param logger [Logger, nil]
155
+ # @return [Hash{String => Array<String>}] the errors found
156
+ # @raise [StandardId::ConfigurationError]
157
+ def validate_configuration!(mode: StandardId.config.social.provider_misconfiguration, logger: StandardId.logger)
158
+ errors = configuration_errors
159
+ return errors if errors.empty?
160
+
161
+ message = "StandardId social provider configuration is incomplete: " +
162
+ errors.map { |name, provider_errors| "#{name} (#{provider_errors.join('; ')})" }.join(", ")
163
+
164
+ raise StandardId::ConfigurationError, message if mode.to_s == "raise" && production?
165
+
166
+ logger&.warn("[StandardId] #{message}")
167
+ errors
168
+ end
169
+
91
170
  # Get provider by name
92
171
  # @param name [Symbol, String] Provider identifier
93
172
  # @return [Class] Provider class
@@ -114,6 +193,38 @@ module StandardId
114
193
 
115
194
  private
116
195
 
196
+ # A default that prefers a non-blank ENV value, then the field's own
197
+ # default. Evaluated lazily (ConfigSchema calls it when the config is
198
+ # built, or on first read of a field declared afterwards), and only when
199
+ # the host never assigned the field — an explicit assignment, even of
200
+ # nil, always wins.
201
+ def env_default(env_name, fallback)
202
+ lambda do
203
+ value = ENV[env_name]
204
+ next value if value.present?
205
+
206
+ fallback.respond_to?(:call) ? fallback.call : fallback
207
+ end
208
+ end
209
+
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
+ def production?
225
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
226
+ end
227
+
117
228
  def validate_provider!(provider_class)
118
229
  unless provider_class.is_a?(Class)
119
230
  raise InvalidProviderError,