standard_id 0.41.1 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -0
- data/README.md +310 -17
- data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +1 -0
- data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
- data/app/controllers/concerns/standard_id/rate_limit_handling.rb +5 -19
- data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
- data/app/controllers/standard_id/web/login_controller.rb +1 -2
- data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
- data/app/controllers/standard_id/web/verify_email/start_controller.rb +12 -8
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +12 -8
- data/app/jobs/standard_id/cleanup_all_job.rb +46 -0
- data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
- data/app/models/concerns/standard_id/credentiable.rb +8 -1
- data/app/models/standard_id/application_record.rb +26 -0
- data/app/models/standard_id/authorization_code.rb +3 -1
- data/app/models/standard_id/identifier.rb +1 -0
- data/app/models/standard_id/session.rb +2 -1
- data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
- data/app/views/standard_id/web/login/show.html.erb +5 -5
- data/app/views/standard_id/web/signup/show.html.erb +3 -3
- data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
- data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
- data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
- data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
- data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
- data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
- data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
- data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
- data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
- data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
- data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
- data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
- data/lib/generators/standard_id/install/install_generator.rb +64 -3
- data/lib/generators/standard_id/install/templates/standard_id.rb +41 -13
- data/lib/standard_id/checks/migrations.rb +61 -0
- data/lib/standard_id/config/schema.rb +34 -20
- data/lib/standard_id/config_schema.rb +94 -6
- data/lib/standard_id/deprecator.rb +17 -0
- data/lib/standard_id/engine.rb +22 -0
- data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +3 -0
- data/lib/standard_id/instrumentation.rb +49 -0
- data/lib/standard_id/migration_check.rb +183 -0
- data/lib/standard_id/migration_helpers.rb +65 -0
- data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
- data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
- data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
- data/lib/standard_id/otp.rb +20 -28
- data/lib/standard_id/passwordless/base_strategy.rb +18 -14
- data/lib/standard_id/passwordless/email_strategy.rb +0 -5
- data/lib/standard_id/passwordless/sms_strategy.rb +0 -4
- data/lib/standard_id/provider_registry.rb +95 -5
- data/lib/standard_id/providers/base.rb +222 -8
- data/lib/standard_id/providers/plugin_railtie.rb +59 -0
- data/lib/standard_id/scope_config.rb +43 -26
- data/lib/standard_id/testing/config_helpers.rb +55 -0
- data/lib/standard_id/testing/provider_examples.rb +117 -0
- data/lib/standard_id/testing.rb +2 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +26 -0
- metadata +25 -17
- data/config/initializers/migration_helpers.rb +0 -32
|
@@ -18,6 +18,8 @@ module StandardId
|
|
|
18
18
|
* writes config/initializers/standard_id.rb
|
|
19
19
|
* mounts StandardId::WebEngine and StandardId::ApiEngine in config/routes.rb
|
|
20
20
|
* copies the engine's migrations into db/migrate/
|
|
21
|
+
* schedules the four cleanup jobs in config/recurring.yml (Solid Queue),
|
|
22
|
+
when that file exists
|
|
21
23
|
|
|
22
24
|
Use --skip-* flags to opt out of individual steps when re-running on an
|
|
23
25
|
existing install. The generator is idempotent — already-installed
|
|
@@ -30,6 +32,20 @@ module StandardId
|
|
|
30
32
|
desc: "Do not append engine mount lines to config/routes.rb"
|
|
31
33
|
class_option :skip_migrations, type: :boolean, default: false,
|
|
32
34
|
desc: "Do not copy StandardId migrations into db/migrate"
|
|
35
|
+
class_option :skip_recurring, type: :boolean, default: false,
|
|
36
|
+
desc: "Do not add the cleanup jobs to config/recurring.yml"
|
|
37
|
+
|
|
38
|
+
# Every cleanup job the engine ships, with the recommended Solid Queue
|
|
39
|
+
# schedule. Hourly, staggered off minute 0: each job is one DELETE, and
|
|
40
|
+
# running it hourly keeps that batch small on busy tables. The jobs'
|
|
41
|
+
# own grace windows (7 days expired / 1 day consumed) are what bound
|
|
42
|
+
# retention, not the cadence — daily is fine for small apps.
|
|
43
|
+
CLEANUP_JOBS = {
|
|
44
|
+
"standard_id_cleanup_expired_sessions" => ["StandardId::CleanupExpiredSessionsJob", "every hour at minute 6"],
|
|
45
|
+
"standard_id_cleanup_expired_refresh_tokens" => ["StandardId::CleanupExpiredRefreshTokensJob", "every hour at minute 3"],
|
|
46
|
+
"standard_id_cleanup_expired_authorization_codes" => ["StandardId::CleanupExpiredAuthorizationCodesJob", "every hour at minute 9"],
|
|
47
|
+
"standard_id_cleanup_expired_code_challenges" => ["StandardId::CleanupExpiredCodeChallengesJob", "every hour at minute 13"]
|
|
48
|
+
}.freeze
|
|
33
49
|
|
|
34
50
|
def create_initializer_file
|
|
35
51
|
return say_status("skip", "config/initializers/standard_id.rb (--skip-initializer)", :yellow) if options[:skip_initializer]
|
|
@@ -79,6 +95,35 @@ module StandardId
|
|
|
79
95
|
run_migration_copy_task
|
|
80
96
|
end
|
|
81
97
|
|
|
98
|
+
# Solid Queue reads a single recurring schedule (config/recurring.yml);
|
|
99
|
+
# an engine cannot contribute entries to it, so the generator writes
|
|
100
|
+
# them. Inserted under the `production:` key only — the one environment
|
|
101
|
+
# every recurring.yml has and where cleanup matters.
|
|
102
|
+
def schedule_cleanup_jobs
|
|
103
|
+
return say_status("skip", "config/recurring.yml (--skip-recurring)", :yellow) if options[:skip_recurring]
|
|
104
|
+
|
|
105
|
+
path = "config/recurring.yml"
|
|
106
|
+
full_path = File.join(destination_root, path)
|
|
107
|
+
|
|
108
|
+
unless File.exist?(full_path)
|
|
109
|
+
say_status("skip", "#{path} not found — schedule the cleanup jobs with your scheduler (see below)", :yellow)
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
content = File.read(full_path)
|
|
114
|
+
if content.include?("StandardId::CleanupExpired")
|
|
115
|
+
say_status("identical", "#{path} (StandardId cleanup jobs already scheduled)", :blue)
|
|
116
|
+
return
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
unless content.match?(/^production:[ \t]*\r?\n/)
|
|
120
|
+
say_status("warn", "#{path} has no top-level `production:` key — add the cleanup jobs manually:\n#{recurring_snippet}", :red)
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
inject_into_file path, indent(recurring_snippet, 2), after: /^production:[ \t]*\r?\n/
|
|
125
|
+
end
|
|
126
|
+
|
|
82
127
|
def print_post_install_message
|
|
83
128
|
say ""
|
|
84
129
|
say "=" * 79
|
|
@@ -116,10 +161,13 @@ module StandardId
|
|
|
116
161
|
say ""
|
|
117
162
|
say " bin/rails db:migrate"
|
|
118
163
|
say ""
|
|
119
|
-
say "5. Scheduled maintenance —
|
|
164
|
+
say "5. Scheduled maintenance — the cleanup jobs must run on a schedule"
|
|
165
|
+
say " (added to config/recurring.yml if you use Solid Queue; otherwise"
|
|
166
|
+
say " schedule them yourself, hourly or at least daily):"
|
|
167
|
+
say ""
|
|
168
|
+
CLEANUP_JOBS.each_value { |(job, _)| say " #{job}" }
|
|
120
169
|
say ""
|
|
121
|
-
say "
|
|
122
|
-
say " StandardId::CleanupExpiredRefreshTokensJob"
|
|
170
|
+
say " See the README's Scheduled Maintenance section."
|
|
123
171
|
say ""
|
|
124
172
|
say "6. Social providers — install provider plugins and register them:"
|
|
125
173
|
say ""
|
|
@@ -160,6 +208,19 @@ module StandardId
|
|
|
160
208
|
text.each_line.map { |line| line.strip.empty? ? line : prefix + line }.join
|
|
161
209
|
end
|
|
162
210
|
|
|
211
|
+
def recurring_snippet
|
|
212
|
+
entries = CLEANUP_JOBS.map do |key, (job, schedule)|
|
|
213
|
+
<<~YAML
|
|
214
|
+
#{key}:
|
|
215
|
+
class: #{job}
|
|
216
|
+
schedule: #{schedule}
|
|
217
|
+
YAML
|
|
218
|
+
end
|
|
219
|
+
"# StandardId cleanup jobs (added by standard_id:install). Retention is\n" \
|
|
220
|
+
"# bounded by each job's grace window; the cadence only sizes the DELETE.\n" +
|
|
221
|
+
entries.join
|
|
222
|
+
end
|
|
223
|
+
|
|
163
224
|
def engine_mount_snippet
|
|
164
225
|
<<~RUBY
|
|
165
226
|
# Mount the StandardId engines. The web engine serves cookie-based
|
|
@@ -253,9 +253,18 @@ StandardId.configure do |c|
|
|
|
253
253
|
# Account.create!(email: identifier.value)
|
|
254
254
|
# }
|
|
255
255
|
|
|
256
|
-
#
|
|
257
|
-
# c.passwordless_email_sender
|
|
258
|
-
#
|
|
256
|
+
# With c.passwordless.delivery = :custom, deliver codes from an event
|
|
257
|
+
# subscriber (c.passwordless_email_sender / _sms_sender were removed in
|
|
258
|
+
# 0.43). It runs synchronously in the request, so I18n.locale etc. are still
|
|
259
|
+
# available. The same subscriber delivers Otp.issue(delivery: :custom) codes.
|
|
260
|
+
#
|
|
261
|
+
# StandardId::Events.subscribe(StandardId::Events::PASSWORDLESS_CODE_GENERATED) do |event|
|
|
262
|
+
# next if event[:skip_sender] # Otp.issue(delivery: :manual)
|
|
263
|
+
# case event[:channel]
|
|
264
|
+
# when "email" then PasswordlessMailer.with(code: event[:code_challenge].code, to: event[:identifier]).deliver_later
|
|
265
|
+
# when "sms" then SmsProvider.send_code(phone: event[:identifier], code: event[:code_challenge].code)
|
|
266
|
+
# end
|
|
267
|
+
# end
|
|
259
268
|
|
|
260
269
|
# ---------------------------------------------------------------------------
|
|
261
270
|
# API engine — OAuth / JWT
|
|
@@ -430,15 +439,37 @@ StandardId.configure do |c|
|
|
|
430
439
|
# declared later from the plugin's Railtie, and apps worked around it by
|
|
431
440
|
# wrapping the writes in `Rails.application.config.after_initialize`. That
|
|
432
441
|
# wrapper is no longer needed, and it still works if you have one.
|
|
433
|
-
|
|
434
|
-
#
|
|
435
|
-
#
|
|
436
|
-
#
|
|
437
|
-
#
|
|
438
|
-
#
|
|
442
|
+
#
|
|
443
|
+
# ENV defaults (standard_id >= 0.42): every provider field you do not assign
|
|
444
|
+
# falls back to the ENV variable named after it, upper-cased — so with the
|
|
445
|
+
# canonical variables below set, you need none of these lines. Assign a
|
|
446
|
+
# field only to read it from somewhere else (a differently named variable,
|
|
447
|
+
# Rails credentials); an explicit assignment, even of nil, always wins.
|
|
448
|
+
#
|
|
449
|
+
# GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
|
|
450
|
+
# APPLE_CLIENT_ID, APPLE_MOBILE_CLIENT_ID,
|
|
451
|
+
# APPLE_PRIVATE_KEY, APPLE_KEY_ID, APPLE_TEAM_ID
|
|
452
|
+
#
|
|
453
|
+
# A provider is enabled when its client ID is present
|
|
454
|
+
# (StandardId.social_provider_enabled?(:google)). An enabled provider missing
|
|
455
|
+
# fields its plugin marks required is reported at boot — see
|
|
456
|
+
# c.social.provider_misconfiguration below.
|
|
457
|
+
|
|
458
|
+
# c.social.google_client_id = Rails.application.credentials.dig(:google, :client_id)
|
|
459
|
+
# c.social.google_client_secret = Rails.application.credentials.dig(:google, :client_secret)
|
|
460
|
+
# c.social.apple_client_id = ENV["APPLE_CLIENT_ID"] # web (Services ID) flow
|
|
461
|
+
# c.social.apple_mobile_client_id = ENV["APPLE_MOBILE_CLIENT_ID"] # native id_token flow
|
|
462
|
+
# c.social.apple_private_key = ENV["APPLE_PRIVATE_KEY"] # .p8 PEM, newlines intact
|
|
439
463
|
# c.social.apple_key_id = ENV["APPLE_KEY_ID"]
|
|
440
464
|
# c.social.apple_team_id = ENV["APPLE_TEAM_ID"]
|
|
441
465
|
|
|
466
|
+
# What to do at boot when an enabled provider is missing required fields
|
|
467
|
+
# (e.g. APPLE_CLIENT_ID set without APPLE_PRIVATE_KEY — the sign-in flow
|
|
468
|
+
# would start, then fail at the callback). :warn logs a warning; :raise
|
|
469
|
+
# raises StandardId::ConfigurationError in production and warns elsewhere.
|
|
470
|
+
# Default: :warn
|
|
471
|
+
# c.social.provider_misconfiguration = :raise
|
|
472
|
+
|
|
442
473
|
# Mobile redirect URI allow-list — custom schemes used by native apps.
|
|
443
474
|
# Default: []
|
|
444
475
|
# c.social.allowed_redirect_url_prefixes = ["example-app://"]
|
|
@@ -479,12 +510,9 @@ StandardId.configure do |c|
|
|
|
479
510
|
|
|
480
511
|
# Login limits. The login action branches password OR passwordless, so on a
|
|
481
512
|
# passwordless app these govern the OTP-SEND limit. Prefer the
|
|
482
|
-
# mechanism-agnostic names
|
|
483
|
-
# (the new name wins when both are set).
|
|
513
|
+
# mechanism-agnostic names (password_login_per_ip/_per_email were removed in 0.43).
|
|
484
514
|
# c.rate_limits.login_per_ip = 20 # per 15 minutes
|
|
485
515
|
# c.rate_limits.login_per_email = 5 # per 15 minutes
|
|
486
|
-
# c.rate_limits.password_login_per_ip = 20 # deprecated alias of login_per_ip
|
|
487
|
-
# c.rate_limits.password_login_per_email = 5 # deprecated alias of login_per_email
|
|
488
516
|
# c.rate_limits.otp_verify_per_ip = 20 # per 15 minutes
|
|
489
517
|
# c.rate_limits.verification_start_per_target = 3 # per 15 minutes
|
|
490
518
|
# c.rate_limits.verification_start_per_ip = 10 # per hour
|
|
@@ -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,12 @@ 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
|
-
|
|
12
|
+
# Removed in 0.43 (deprecated since 0.1.7): assigning either raises
|
|
13
|
+
# StandardId::ConfigurationError with this hint.
|
|
14
|
+
removed :passwordless_email_sender,
|
|
15
|
+
"deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber (it runs synchronously in the request, so I18n.locale is still available; skip it when event[:skip_sender] is true) and set c.passwordless.delivery = :custom. See docs/MIGRATION_GUIDE.md."
|
|
16
|
+
removed :passwordless_sms_sender,
|
|
17
|
+
"deliver the code from a StandardId::Events::PASSWORDLESS_CODE_GENERATED subscriber (skip it when event[:skip_sender] is true). See docs/MIGRATION_GUIDE.md."
|
|
14
18
|
field :issuer, type: :string, default: nil
|
|
15
19
|
|
|
16
20
|
# Whether `JwtService.decode` REQUIRES a matching `iss` claim.
|
|
@@ -55,12 +59,22 @@ StandardId::ConfigSchema.define do
|
|
|
55
59
|
# loading for every app that never asked for it.
|
|
56
60
|
field :association_strict_loading, type: :any, default: nil
|
|
57
61
|
|
|
62
|
+
# Boot-time check for StandardId migrations the host never copied in
|
|
63
|
+
# (StandardId::MigrationCheck). :warn, :raise or :ignore. nil (default)
|
|
64
|
+
# means :warn in development/test and :ignore elsewhere — it never raises
|
|
65
|
+
# in production unless you ask it to.
|
|
66
|
+
field :missing_migrations, type: :symbol, default: nil
|
|
67
|
+
|
|
68
|
+
# Gem migration names (e.g. "add_target_created_at_index_to_code_challenges")
|
|
69
|
+
# or original versions the check should skip — for a migration the host
|
|
70
|
+
# deliberately superseded or deferred.
|
|
71
|
+
field :ignored_migrations, type: :array, default: -> { [] }
|
|
72
|
+
|
|
58
73
|
# Scope-aware authentication: maps scope names to profile-based access config.
|
|
59
74
|
# Each scope is a hash with keys: :profile_types (Array<String>), :after_sign_in_path,
|
|
60
75
|
# :no_profile_message, :label, :allow_registration, :authorizer.
|
|
61
|
-
# The legacy :profile_type (singular
|
|
62
|
-
#
|
|
63
|
-
# warning fires on use).
|
|
76
|
+
# The legacy :profile_type (singular) key was removed in 0.43; a scope that still
|
|
77
|
+
# sets it raises StandardId::ConfigurationError at boot.
|
|
64
78
|
field :scopes, type: :any, default: {}
|
|
65
79
|
|
|
66
80
|
# Callable that resolves the active scope name for a given request/session.
|
|
@@ -139,9 +153,8 @@ StandardId::ConfigSchema.define do
|
|
|
139
153
|
end
|
|
140
154
|
|
|
141
155
|
scope :passwordless do
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
field :enabled, type: :boolean, default: false
|
|
156
|
+
# Removed in 0.43 (no effect since 0.8).
|
|
157
|
+
removed :enabled, "it had no effect since 0.8. Use web.passwordless_login (WebEngine) instead, and delete this line."
|
|
145
158
|
field :connection, type: :string, default: "email"
|
|
146
159
|
field :code_ttl, type: :integer, default: 600 # 10 minutes in seconds
|
|
147
160
|
|
|
@@ -296,8 +309,10 @@ StandardId::ConfigSchema.define do
|
|
|
296
309
|
# RefreshTokenFlow::MAX_REUSE_LEEWAY_SECONDS.
|
|
297
310
|
field :refresh_token_reuse_leeway, type: :integer, default: 0
|
|
298
311
|
field :token_lifetimes, type: :hash, default: -> { {} }
|
|
299
|
-
|
|
300
|
-
|
|
312
|
+
# Removed in 0.43 — never read by the gem. OAuth clients are
|
|
313
|
+
# StandardId::ClientApplication rows with ClientSecretCredential secrets.
|
|
314
|
+
removed :client_id, "it was never read — OAuth clients are StandardId::ClientApplication records. Delete this line."
|
|
315
|
+
removed :client_secret, "it was never read — client secrets are StandardId::ClientSecretCredential records. Delete this line."
|
|
301
316
|
field :scope_claims, type: :hash, default: -> { {} }
|
|
302
317
|
field :claim_resolvers, type: :hash, default: -> { {} }
|
|
303
318
|
# List of audience values that tokens issued and accepted by this app may
|
|
@@ -551,6 +566,11 @@ StandardId::ConfigSchema.define do
|
|
|
551
566
|
field :allowed_redirect_url_prefixes, type: :array, default: []
|
|
552
567
|
field :available_scopes, type: :array, default: -> { [] }
|
|
553
568
|
field :link_strategy, type: :symbol, default: :strict
|
|
569
|
+
# What to do at boot when an enabled social provider is missing required
|
|
570
|
+
# config (e.g. apple_client_id set without apple_private_key). :warn logs
|
|
571
|
+
# in every environment; :raise raises StandardId::ConfigurationError in
|
|
572
|
+
# production and logs elsewhere. See ProviderRegistry.validate_configuration!
|
|
573
|
+
field :provider_misconfiguration, type: :symbol, default: :warn
|
|
554
574
|
end
|
|
555
575
|
|
|
556
576
|
scope :web do
|
|
@@ -576,15 +596,9 @@ StandardId::ConfigSchema.define do
|
|
|
576
596
|
field :login_per_ip, type: :integer, default: 20 # per 15 minutes
|
|
577
597
|
field :login_per_email, type: :integer, default: 5 # per 15 minutes
|
|
578
598
|
|
|
579
|
-
#
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
# `login_per_*` alias at its default, the login controller falls back to
|
|
583
|
-
# these values (see StandardId::RateLimitHandling.login_per_ip). New name
|
|
584
|
-
# wins when explicitly set. Mirrors the max_attempts ->
|
|
585
|
-
# max_attempts_per_challenge deprecation-alias precedent.
|
|
586
|
-
field :password_login_per_ip, type: :integer, default: 20 # per 15 minutes; deprecated alias of login_per_ip
|
|
587
|
-
field :password_login_per_email, type: :integer, default: 5 # per 15 minutes; deprecated alias of login_per_email
|
|
599
|
+
# Removed in 0.43: the mechanism-specific names of login_per_ip / login_per_email.
|
|
600
|
+
removed :password_login_per_ip, "use rate_limits.login_per_ip (same meaning; it also governs passwordless OTP sends)."
|
|
601
|
+
removed :password_login_per_email, "use rate_limits.login_per_email (same meaning; it also governs passwordless OTP sends)."
|
|
588
602
|
|
|
589
603
|
# RAR-60: OTP verification
|
|
590
604
|
field :otp_verify_per_ip, type: :integer, default: 20 # per 15 minutes
|
|
@@ -603,7 +617,7 @@ StandardId::ConfigSchema.define do
|
|
|
603
617
|
field :signup_per_ip, type: :integer, default: 10 # per hour
|
|
604
618
|
|
|
605
619
|
# API OTP-initiation limits — the API counterpart of the web *login* limits
|
|
606
|
-
# (login_per_*
|
|
620
|
+
# (login_per_*): the API passwordless
|
|
607
621
|
# `start` action and the web login action both drive the passwordless
|
|
608
622
|
# `start!` strategy. NOT the counterpart of verification_start_*
|
|
609
623
|
# (email/phone verification), which bypasses the strategy via a direct
|
|
@@ -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)
|
|
@@ -24,7 +28,11 @@ module StandardId
|
|
|
24
28
|
def build = instance.apply(Config.new)
|
|
25
29
|
end
|
|
26
30
|
|
|
27
|
-
def initialize
|
|
31
|
+
def initialize
|
|
32
|
+
@scopes = Concurrent::Map.new
|
|
33
|
+
@removed = Concurrent::Map.new
|
|
34
|
+
end
|
|
35
|
+
|
|
28
36
|
def scopes = @scopes
|
|
29
37
|
def scope?(name) = @scopes.key?(name.to_sym)
|
|
30
38
|
def field?(scope_name, field_name) = !!@scopes[scope_name.to_sym]&.key?(field_name.to_sym)
|
|
@@ -35,11 +43,23 @@ module StandardId
|
|
|
35
43
|
self
|
|
36
44
|
end
|
|
37
45
|
|
|
38
|
-
def add_field(scope:, name:, type: :string, default: nil)
|
|
46
|
+
def add_field(scope:, name:, type: :string, default: nil, deprecated: nil)
|
|
39
47
|
fields = ensure_scope(scope)
|
|
40
|
-
fields.compute_if_absent(name.to_sym) { Field.new(name.to_sym, type, default) }
|
|
48
|
+
fields.compute_if_absent(name.to_sym) { Field.new(name.to_sym, type, default, deprecated) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Record a field that has been REMOVED from the schema. Assigning it raises
|
|
52
|
+
# StandardId::ConfigurationError carrying +message+ (what to do instead),
|
|
53
|
+
# rather than the generic "Unknown field" — or, for a base-scope field
|
|
54
|
+
# assigned through the top-level config, rather than being silently
|
|
55
|
+
# stored on the top-level OrderedOptions and ignored.
|
|
56
|
+
def add_removed_field(scope:, name:, message:)
|
|
57
|
+
@removed[[scope.to_sym, name.to_sym]] = message
|
|
41
58
|
end
|
|
42
59
|
|
|
60
|
+
# @return [String, nil] the removal hint for a removed field, else nil
|
|
61
|
+
def removed_field_message(scope_name, field_name) = @removed[[scope_name.to_sym, field_name.to_sym]]
|
|
62
|
+
|
|
43
63
|
# Register a scope without adding a field. Allows `define { scope :foo }` so
|
|
44
64
|
# provider gems can later `add_field(scope: :foo, ...)` against an existing scope.
|
|
45
65
|
def ensure_scope(name)
|
|
@@ -100,8 +120,12 @@ module StandardId
|
|
|
100
120
|
DSL.new(@schema, name.to_sym).instance_eval(&block) if block
|
|
101
121
|
end
|
|
102
122
|
|
|
103
|
-
def field(name, type: :string, default: nil, **)
|
|
104
|
-
@schema.add_field(scope: @scope_name, name: name, type: type, default: default)
|
|
123
|
+
def field(name, type: :string, default: nil, deprecated: nil, **)
|
|
124
|
+
@schema.add_field(scope: @scope_name, name: name, type: type, default: default, deprecated: deprecated)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def removed(name, message)
|
|
128
|
+
@schema.add_removed_field(scope: @scope_name, name: name, message: message)
|
|
105
129
|
end
|
|
106
130
|
end
|
|
107
131
|
|
|
@@ -122,9 +146,46 @@ module StandardId
|
|
|
122
146
|
|
|
123
147
|
def []=(key, value)
|
|
124
148
|
validate!(key)
|
|
149
|
+
warn_if_deprecated(key, value)
|
|
150
|
+
assigned_keys << key.to_sym
|
|
125
151
|
super(key.to_sym, value)
|
|
126
152
|
end
|
|
127
153
|
|
|
154
|
+
# Whether the host explicitly assigned +key+ (even to nil).
|
|
155
|
+
#
|
|
156
|
+
# Unlike #key?, which is also true for every field whose schema default
|
|
157
|
+
# was written when the config was built (so it is true for a provider
|
|
158
|
+
# field the host never touched), this is only true after an assignment.
|
|
159
|
+
#
|
|
160
|
+
# @param key [Symbol, String]
|
|
161
|
+
# @return [Boolean]
|
|
162
|
+
def assigned?(key) = assigned_keys.include?(key.to_sym)
|
|
163
|
+
|
|
164
|
+
# Deleting a key also forgets that it was assigned, so reads fall back
|
|
165
|
+
# to the (live) schema default again.
|
|
166
|
+
def delete(key)
|
|
167
|
+
assigned_keys.delete(key.to_sym)
|
|
168
|
+
super(key.to_sym)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Re-evaluate the schema default of every field the host never assigned.
|
|
172
|
+
#
|
|
173
|
+
# Defaults are resolved once, when the config is built — including the
|
|
174
|
+
# ENV fallback of provider fields (`GOOGLE_CLIENT_ID` etc.). Call this
|
|
175
|
+
# after changing ENV to make unassigned fields pick the new value up;
|
|
176
|
+
# explicitly assigned fields are left alone. Meant for tests — see
|
|
177
|
+
# StandardId::Testing.with_provider_env.
|
|
178
|
+
#
|
|
179
|
+
# @return [self]
|
|
180
|
+
def refresh_defaults!
|
|
181
|
+
@schema.scopes[@scope_name]&.each_value do |field|
|
|
182
|
+
next if assigned?(field.name)
|
|
183
|
+
|
|
184
|
+
RAW_SET.bind_call(self, field.name, field.default_value)
|
|
185
|
+
end
|
|
186
|
+
self
|
|
187
|
+
end
|
|
188
|
+
|
|
128
189
|
def [](key)
|
|
129
190
|
sym = key.to_sym
|
|
130
191
|
validate!(sym) unless key?(sym) || resolver
|
|
@@ -148,12 +209,36 @@ module StandardId
|
|
|
148
209
|
|
|
149
210
|
private
|
|
150
211
|
|
|
212
|
+
def assigned_keys = (@assigned_keys ||= Set.new)
|
|
213
|
+
|
|
151
214
|
def validate!(key)
|
|
152
215
|
return if @schema.field?(@scope_name, key)
|
|
216
|
+
if (message = @schema.removed_field_message(@scope_name, key))
|
|
217
|
+
raise StandardId::ConfigurationError,
|
|
218
|
+
"StandardId.config.#{config_path(key)} was removed in StandardId 0.43: #{message}"
|
|
219
|
+
end
|
|
153
220
|
raise StandardId::ConfigurationError,
|
|
154
221
|
"Unknown field '#{key}' for scope '#{@scope_name}'. Valid fields: #{@schema.scopes[@scope_name]&.keys}"
|
|
155
222
|
end
|
|
156
223
|
|
|
224
|
+
def warn_if_deprecated(key, value)
|
|
225
|
+
return if value.nil?
|
|
226
|
+
|
|
227
|
+
message = @schema.field_for(@scope_name, key)&.deprecation
|
|
228
|
+
return if message.nil?
|
|
229
|
+
|
|
230
|
+
# Point the warning at the host's assignment, not at this file.
|
|
231
|
+
# (OrderedOptions' method_missing forwards `c.foo = x` to #[]=.)
|
|
232
|
+
callstack = caller_locations(1).reject do |location|
|
|
233
|
+
location.path == __FILE__ || location.path.end_with?("active_support/ordered_options.rb")
|
|
234
|
+
end
|
|
235
|
+
StandardId.deprecator.warn("StandardId.config.#{config_path(key)} is deprecated: #{message}", callstack)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def config_path(key)
|
|
239
|
+
@scope_name == :base ? key.to_s : "#{@scope_name}.#{key}"
|
|
240
|
+
end
|
|
241
|
+
|
|
157
242
|
def cast_read(key, value)
|
|
158
243
|
field = @schema.field_for(@scope_name, key)
|
|
159
244
|
return value unless field
|
|
@@ -192,6 +277,9 @@ module StandardId
|
|
|
192
277
|
sym = key.to_sym
|
|
193
278
|
if __schema__ && !__schema__.scope?(sym) && !key?(sym) && (target = unique_scope_for(sym))
|
|
194
279
|
self[target][sym] = value
|
|
280
|
+
elsif __schema__ && (message = __schema__.removed_field_message(:base, sym))
|
|
281
|
+
raise StandardId::ConfigurationError,
|
|
282
|
+
"StandardId.config.#{sym} was removed in StandardId 0.43: #{message}"
|
|
195
283
|
else
|
|
196
284
|
super(sym, value)
|
|
197
285
|
end
|
|
@@ -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
|
|
@@ -77,8 +97,10 @@ module StandardId
|
|
|
77
97
|
# surfaces typos at boot instead of at callback time in production.
|
|
78
98
|
StandardId::Config::CallableValidator.validate!
|
|
79
99
|
StandardId::Config::ScopeClaimsValidator.validate!
|
|
100
|
+
StandardId::ScopeConfig.validate_all!
|
|
80
101
|
|
|
81
102
|
StandardId::Engine.verify_host_cookie_encryption!(app)
|
|
103
|
+
StandardId::MigrationCheck.verify_at_boot!
|
|
82
104
|
StandardId::Engine.warn_if_allowed_audiences_empty_in_production!
|
|
83
105
|
end
|
|
84
106
|
|
|
@@ -11,6 +11,9 @@ module StandardId
|
|
|
11
11
|
# challenges, etc.) and would otherwise receive a duplicate email
|
|
12
12
|
# from this subscriber when c.passwordless.delivery == :built_in.
|
|
13
13
|
return if event[:skip_sender]
|
|
14
|
+
# Otp.issue(delivery: :custom): the host's own subscriber delivers
|
|
15
|
+
# this code, even where the engine mailer is the global default.
|
|
16
|
+
return if event[:delivery]&.to_sym == :custom
|
|
14
17
|
return unless built_in_delivery?
|
|
15
18
|
return unless event[:channel] == "email"
|
|
16
19
|
|
|
@@ -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
|