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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +111 -0
  3. data/README.md +310 -17
  4. data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
  5. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +1 -0
  6. data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
  7. data/app/controllers/concerns/standard_id/rate_limit_handling.rb +5 -19
  8. data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
  9. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
  10. data/app/controllers/standard_id/web/login_controller.rb +1 -2
  11. data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
  12. data/app/controllers/standard_id/web/verify_email/start_controller.rb +12 -8
  13. data/app/controllers/standard_id/web/verify_phone/start_controller.rb +12 -8
  14. data/app/jobs/standard_id/cleanup_all_job.rb +46 -0
  15. data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
  16. data/app/models/concerns/standard_id/credentiable.rb +8 -1
  17. data/app/models/standard_id/application_record.rb +26 -0
  18. data/app/models/standard_id/authorization_code.rb +3 -1
  19. data/app/models/standard_id/identifier.rb +1 -0
  20. data/app/models/standard_id/session.rb +2 -1
  21. data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
  22. data/app/views/standard_id/web/login/show.html.erb +5 -5
  23. data/app/views/standard_id/web/signup/show.html.erb +3 -3
  24. data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
  25. data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
  26. data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
  27. data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
  28. data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
  29. data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
  30. data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
  31. data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
  32. data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
  33. data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
  34. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
  35. data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
  36. data/lib/generators/standard_id/install/install_generator.rb +64 -3
  37. data/lib/generators/standard_id/install/templates/standard_id.rb +41 -13
  38. data/lib/standard_id/checks/migrations.rb +61 -0
  39. data/lib/standard_id/config/schema.rb +34 -20
  40. data/lib/standard_id/config_schema.rb +94 -6
  41. data/lib/standard_id/deprecator.rb +17 -0
  42. data/lib/standard_id/engine.rb +22 -0
  43. data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +3 -0
  44. data/lib/standard_id/instrumentation.rb +49 -0
  45. data/lib/standard_id/migration_check.rb +183 -0
  46. data/lib/standard_id/migration_helpers.rb +65 -0
  47. data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
  48. data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
  49. data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
  50. data/lib/standard_id/otp.rb +20 -28
  51. data/lib/standard_id/passwordless/base_strategy.rb +18 -14
  52. data/lib/standard_id/passwordless/email_strategy.rb +0 -5
  53. data/lib/standard_id/passwordless/sms_strategy.rb +0 -4
  54. data/lib/standard_id/provider_registry.rb +95 -5
  55. data/lib/standard_id/providers/base.rb +222 -8
  56. data/lib/standard_id/providers/plugin_railtie.rb +59 -0
  57. data/lib/standard_id/scope_config.rb +43 -26
  58. data/lib/standard_id/testing/config_helpers.rb +55 -0
  59. data/lib/standard_id/testing/provider_examples.rb +117 -0
  60. data/lib/standard_id/testing.rb +2 -0
  61. data/lib/standard_id/version.rb +1 -1
  62. data/lib/standard_id.rb +26 -0
  63. metadata +25 -17
  64. 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 — schedule the cleanup jobs (e.g. daily):"
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 " StandardId::CleanupExpiredSessionsJob"
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
- # Deprecated senders (prefer event subscriptions + built_in delivery):
257
- # c.passwordless_email_sender = ->(email, code) { PasswordlessMailer.with(code: code, to: email).deliver_later }
258
- # c.passwordless_sms_sender = ->(phone, code) { SmsProvider.send_code(phone: phone, code: code) }
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
- # c.social.google_client_id = ENV["GOOGLE_CLIENT_ID"]
435
- # c.social.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
436
- # c.social.apple_mobile_client_id = ENV["APPLE_MOBILE_CLIENT_ID"]
437
- # c.social.apple_client_id = ENV["APPLE_CLIENT_ID"]
438
- # c.social.apple_private_key = ENV["APPLE_PRIVATE_KEY"]
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; the deprecated password_login_* names still work
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
- field :passwordless_email_sender, type: :any, default: nil
13
- field :passwordless_sms_sender, type: :any, default: nil
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 String) key is still accepted for backward
62
- # compatibility and coerced into a single-element :profile_types array (deprecation
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
- # Deprecated: use web.passwordless_login to control WebEngine passwordless login.
143
- # Retained for backwards compatibility with consuming apps that set this field.
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
- field :client_id, type: :string, default: nil
300
- field :client_secret, type: :string, default: nil
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
- # Deprecated mechanism-specific names, retained for backwards compatibility.
580
- # The schema raises ConfigurationError on unknown fields at boot, so these
581
- # must NOT be removed while hosts still set them. When a host leaves the
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_* / the deprecated password_login_*): the API passwordless
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
- Field = Struct.new(:name, :type, :default) do
13
+ # +deprecation+ is a message (String) for a field kept only so existing
14
+ # host initializers still boot. Assigning a non-nil value warns through
15
+ # StandardId.deprecator; reads and schema defaults never warn.
16
+ Field = Struct.new(:name, :type, :default, :deprecation) do
13
17
  def default_value
14
18
  return default.call if default.respond_to?(:call)
15
19
  return default.dup if default.is_a?(Array) || default.is_a?(Hash)
@@ -24,7 +28,11 @@ module StandardId
24
28
  def build = instance.apply(Config.new)
25
29
  end
26
30
 
27
- def initialize = @scopes = Concurrent::Map.new
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
@@ -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