standard_id 0.41.0 → 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 +102 -1
- 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 +13 -3
- 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/client_application.rb +11 -0
- 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/db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb +115 -0
- data/lib/generators/standard_id/install/install_generator.rb +64 -3
- data/lib/generators/standard_id/install/templates/standard_id.rb +45 -15
- data/lib/standard_id/checks/migrations.rb +61 -0
- data/lib/standard_id/config/schema.rb +40 -10
- 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/oauth_session_persistence.rb +66 -24
- data/lib/standard_id/oauth/refresh_token_flow.rb +23 -2
- data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
- data/lib/standard_id/passwordless.rb +13 -1
- 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 +24 -17
- data/config/initializers/migration_helpers.rb +0 -32
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module Checks
|
|
3
|
+
# A StandardHealth-compatible readiness check reporting StandardId
|
|
4
|
+
# migrations the host never installed or never ran (see
|
|
5
|
+
# StandardId::MigrationCheck).
|
|
6
|
+
#
|
|
7
|
+
# Duck-typed like StandardAudit::Checks::Retention — no dependency on
|
|
8
|
+
# standard_health; it exposes the `#initialize(name:, critical:)` + `#run`
|
|
9
|
+
# contract the aggregator calls. Register it NON-critical, so a missing
|
|
10
|
+
# index degrades /health/ready (HTTP 200) instead of failing the probe:
|
|
11
|
+
#
|
|
12
|
+
# c.register_check :standard_id_migrations,
|
|
13
|
+
# StandardId::Checks::Migrations,
|
|
14
|
+
# critical: false
|
|
15
|
+
#
|
|
16
|
+
# Cheap: one directory scan plus one `schema_migrations` read, and once
|
|
17
|
+
# everything is present the :ok result is memoized for the life of the
|
|
18
|
+
# process (migration files cannot change without a deploy).
|
|
19
|
+
class Migrations
|
|
20
|
+
attr_reader :name
|
|
21
|
+
|
|
22
|
+
def initialize(name: :standard_id_migrations, critical: false)
|
|
23
|
+
@name = name
|
|
24
|
+
@critical = critical
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def critical? = !!@critical
|
|
28
|
+
|
|
29
|
+
def run
|
|
30
|
+
return { status: :ok } if self.class.all_present?
|
|
31
|
+
|
|
32
|
+
all_missing = StandardId::MigrationCheck.pending(check_database: true)
|
|
33
|
+
if all_missing.empty?
|
|
34
|
+
self.class.all_present = true
|
|
35
|
+
return { status: :ok }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Deferred upgrade steps (MigrationCheck::DEFERRED_UPGRADE_STEPS) are
|
|
39
|
+
# reported but never degrade readiness.
|
|
40
|
+
deferred, missing = all_missing.partition(&:info?)
|
|
41
|
+
if missing.empty?
|
|
42
|
+
return { status: :ok, pending_upgrade_steps: deferred.map(&:to_s) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
status: :warn,
|
|
47
|
+
message: "#{missing.size} StandardId migration(s) missing: #{missing.map(&:to_s).join(', ')}",
|
|
48
|
+
missing: missing.map { |m| { name: m.name, version: m.version, state: m.state } }
|
|
49
|
+
}
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
{ status: :fail, error: e.message, error_class: e.class.name }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
attr_writer :all_present
|
|
56
|
+
|
|
57
|
+
def all_present? = !!@all_present
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -9,8 +9,11 @@ StandardId::ConfigSchema.define do
|
|
|
9
9
|
field :cache_store, type: :any, default: nil
|
|
10
10
|
field :logger, type: :any, default: nil
|
|
11
11
|
field :web_layout, type: :string, default: nil
|
|
12
|
-
|
|
13
|
-
field :
|
|
12
|
+
# Deprecated since 0.1.7 (see docs/MIGRATION_GUIDE.md), still honoured.
|
|
13
|
+
field :passwordless_email_sender, type: :any, default: nil,
|
|
14
|
+
deprecated: "deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber instead (it runs synchronously in the request, so I18n.locale is still available; skip it when event[:skip_sender] is true) and call Otp.issue with its default delivery: :built_in. Removal in v2.0; see docs/MIGRATION_GUIDE.md."
|
|
15
|
+
field :passwordless_sms_sender, type: :any, default: nil,
|
|
16
|
+
deprecated: "deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber instead (it runs synchronously in the request, so I18n.locale is still available; skip it when event[:skip_sender] is true) and call Otp.issue with its default delivery: :built_in. Removal in v2.0; see docs/MIGRATION_GUIDE.md."
|
|
14
17
|
field :issuer, type: :string, default: nil
|
|
15
18
|
|
|
16
19
|
# Whether `JwtService.decode` REQUIRES a matching `iss` claim.
|
|
@@ -55,6 +58,17 @@ StandardId::ConfigSchema.define do
|
|
|
55
58
|
# loading for every app that never asked for it.
|
|
56
59
|
field :association_strict_loading, type: :any, default: nil
|
|
57
60
|
|
|
61
|
+
# Boot-time check for StandardId migrations the host never copied in
|
|
62
|
+
# (StandardId::MigrationCheck). :warn, :raise or :ignore. nil (default)
|
|
63
|
+
# means :warn in development/test and :ignore elsewhere — it never raises
|
|
64
|
+
# in production unless you ask it to.
|
|
65
|
+
field :missing_migrations, type: :symbol, default: nil
|
|
66
|
+
|
|
67
|
+
# Gem migration names (e.g. "add_target_created_at_index_to_code_challenges")
|
|
68
|
+
# or original versions the check should skip — for a migration the host
|
|
69
|
+
# deliberately superseded or deferred.
|
|
70
|
+
field :ignored_migrations, type: :array, default: -> { [] }
|
|
71
|
+
|
|
58
72
|
# Scope-aware authentication: maps scope names to profile-based access config.
|
|
59
73
|
# Each scope is a hash with keys: :profile_types (Array<String>), :after_sign_in_path,
|
|
60
74
|
# :no_profile_message, :label, :allow_registration, :authorizer.
|
|
@@ -139,9 +153,11 @@ StandardId::ConfigSchema.define do
|
|
|
139
153
|
end
|
|
140
154
|
|
|
141
155
|
scope :passwordless do
|
|
142
|
-
# Deprecated: use web.passwordless_login to control WebEngine
|
|
143
|
-
#
|
|
144
|
-
|
|
156
|
+
# Deprecated since 0.8: use web.passwordless_login to control WebEngine
|
|
157
|
+
# passwordless login. Never read; retained so host initializers that set it
|
|
158
|
+
# still boot.
|
|
159
|
+
field :enabled, type: :boolean, default: false,
|
|
160
|
+
deprecated: "it has had no effect since 0.8. Use web.passwordless_login (WebEngine) instead, and remove this line. Removal in v2.0."
|
|
145
161
|
field :connection, type: :string, default: "email"
|
|
146
162
|
field :code_ttl, type: :integer, default: 600 # 10 minutes in seconds
|
|
147
163
|
|
|
@@ -162,7 +178,10 @@ StandardId::ConfigSchema.define do
|
|
|
162
178
|
# rate limit (config.rate_limits.otp_verify_per_ip) — the per-IP limit
|
|
163
179
|
# prevents brute-forcing from a single source, while this ceiling defends
|
|
164
180
|
# against distributed brute-force attempts against the same challenge.
|
|
165
|
-
# When nil, falls back to :max_attempts for backwards compatibility
|
|
181
|
+
# When nil, falls back to :max_attempts for backwards compatibility, so the
|
|
182
|
+
# effective default is 3 (:max_attempts' default), not 5. 5 is only the
|
|
183
|
+
# last resort when both are unset or non-positive
|
|
184
|
+
# (StandardId::Passwordless::FALLBACK_MAX_ATTEMPTS_PER_CHALLENGE).
|
|
166
185
|
field :max_attempts_per_challenge, type: :integer, default: nil
|
|
167
186
|
|
|
168
187
|
field :retry_delay, type: :integer, default: 30 # 30 seconds
|
|
@@ -293,8 +312,12 @@ StandardId::ConfigSchema.define do
|
|
|
293
312
|
# RefreshTokenFlow::MAX_REUSE_LEEWAY_SECONDS.
|
|
294
313
|
field :refresh_token_reuse_leeway, type: :integer, default: 0
|
|
295
314
|
field :token_lifetimes, type: :hash, default: -> { {} }
|
|
296
|
-
|
|
297
|
-
|
|
315
|
+
# Never read by the gem. OAuth clients are StandardId::ClientApplication
|
|
316
|
+
# rows with ClientSecretCredential secrets.
|
|
317
|
+
field :client_id, type: :string, default: nil,
|
|
318
|
+
deprecated: "it is not read anywhere — OAuth clients are StandardId::ClientApplication records. Remove this line. Removal in v2.0."
|
|
319
|
+
field :client_secret, type: :string, default: nil,
|
|
320
|
+
deprecated: "it is not read anywhere — client secrets are StandardId::ClientSecretCredential records. Remove this line. Removal in v2.0."
|
|
298
321
|
field :scope_claims, type: :hash, default: -> { {} }
|
|
299
322
|
field :claim_resolvers, type: :hash, default: -> { {} }
|
|
300
323
|
# List of audience values that tokens issued and accepted by this app may
|
|
@@ -548,6 +571,11 @@ StandardId::ConfigSchema.define do
|
|
|
548
571
|
field :allowed_redirect_url_prefixes, type: :array, default: []
|
|
549
572
|
field :available_scopes, type: :array, default: -> { [] }
|
|
550
573
|
field :link_strategy, type: :symbol, default: :strict
|
|
574
|
+
# What to do at boot when an enabled social provider is missing required
|
|
575
|
+
# config (e.g. apple_client_id set without apple_private_key). :warn logs
|
|
576
|
+
# in every environment; :raise raises StandardId::ConfigurationError in
|
|
577
|
+
# production and logs elsewhere. See ProviderRegistry.validate_configuration!
|
|
578
|
+
field :provider_misconfiguration, type: :symbol, default: :warn
|
|
551
579
|
end
|
|
552
580
|
|
|
553
581
|
scope :web do
|
|
@@ -580,8 +608,10 @@ StandardId::ConfigSchema.define do
|
|
|
580
608
|
# these values (see StandardId::RateLimitHandling.login_per_ip). New name
|
|
581
609
|
# wins when explicitly set. Mirrors the max_attempts ->
|
|
582
610
|
# max_attempts_per_challenge deprecation-alias precedent.
|
|
583
|
-
field :password_login_per_ip, type: :integer, default: 20
|
|
584
|
-
|
|
611
|
+
field :password_login_per_ip, type: :integer, default: 20, # per 15 minutes; deprecated alias of login_per_ip
|
|
612
|
+
deprecated: "use rate_limits.login_per_ip (same meaning; it also governs passwordless OTP sends). Removal in v2.0."
|
|
613
|
+
field :password_login_per_email, type: :integer, default: 5, # per 15 minutes; deprecated alias of login_per_email
|
|
614
|
+
deprecated: "use rate_limits.login_per_email (same meaning; it also governs passwordless OTP sends). Removal in v2.0."
|
|
585
615
|
|
|
586
616
|
# RAR-60: OTP verification
|
|
587
617
|
field :otp_verify_per_ip, type: :integer, default: 20 # per 15 minutes
|
|
@@ -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
|
|