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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +251 -13
- 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/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_verify_controller.rb +2 -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 +42 -13
- data/lib/standard_id/checks/migrations.rb +61 -0
- data/lib/standard_id/config/schema.rb +36 -9
- data/lib/standard_id/config_schema.rb +28 -5
- data/lib/standard_id/deprecator.rb +17 -0
- data/lib/standard_id/engine.rb +21 -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/provider_registry.rb +113 -2
- 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 +24 -7
- data/lib/standard_id/testing/provider_examples.rb +117 -0
- data/lib/standard_id/testing.rb +1 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +26 -0
- metadata +23 -17
- 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
|
-
|
|
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
|
data/lib/standard_id/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
@@ -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
|
|
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
|
-
|
|
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,
|