standard_id 0.41.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +251 -13
  4. data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
  5. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +1 -0
  6. data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
  7. data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
  8. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
  9. data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
  10. data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
  11. data/app/models/concerns/standard_id/credentiable.rb +8 -1
  12. data/app/models/standard_id/application_record.rb +26 -0
  13. data/app/models/standard_id/authorization_code.rb +3 -1
  14. data/app/models/standard_id/identifier.rb +1 -0
  15. data/app/models/standard_id/session.rb +2 -1
  16. data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
  17. data/app/views/standard_id/web/login/show.html.erb +5 -5
  18. data/app/views/standard_id/web/signup/show.html.erb +3 -3
  19. data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
  20. data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
  21. data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
  22. data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
  23. data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
  24. data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
  25. data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
  26. data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
  27. data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
  28. data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
  29. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
  30. data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
  31. data/lib/generators/standard_id/install/install_generator.rb +64 -3
  32. data/lib/generators/standard_id/install/templates/standard_id.rb +42 -13
  33. data/lib/standard_id/checks/migrations.rb +61 -0
  34. data/lib/standard_id/config/schema.rb +36 -9
  35. data/lib/standard_id/config_schema.rb +28 -5
  36. data/lib/standard_id/deprecator.rb +17 -0
  37. data/lib/standard_id/engine.rb +21 -0
  38. data/lib/standard_id/instrumentation.rb +49 -0
  39. data/lib/standard_id/migration_check.rb +183 -0
  40. data/lib/standard_id/migration_helpers.rb +65 -0
  41. data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
  42. data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
  43. data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
  44. data/lib/standard_id/provider_registry.rb +113 -2
  45. data/lib/standard_id/providers/base.rb +222 -8
  46. data/lib/standard_id/providers/plugin_railtie.rb +59 -0
  47. data/lib/standard_id/scope_config.rb +24 -7
  48. data/lib/standard_id/testing/provider_examples.rb +117 -0
  49. data/lib/standard_id/testing.rb +1 -0
  50. data/lib/standard_id/version.rb +1 -1
  51. data/lib/standard_id.rb +26 -0
  52. metadata +23 -17
  53. data/config/initializers/migration_helpers.rb +0 -32
@@ -181,7 +181,7 @@ module StandardId
181
181
  # loop and leave later sessions without their SESSION_REVOKED event, which
182
182
  # would permanently desync audit-trail consumers from the DB.
183
183
  def self.publish_session_revocations(sessions, account:, reason:, now:)
184
- shared_account = account || sessions.first.account
184
+ shared_account = account || preload_associations(sessions.first, :account).first&.account
185
185
 
186
186
  sessions.each do |session|
187
187
  session.revoked_at = now
@@ -277,6 +277,7 @@ module StandardId
277
277
  end
278
278
 
279
279
  def emit_session_revoked_event
280
+ preload_associations(:account)
280
281
  StandardId::Events.publish(
281
282
  StandardId::Events::SESSION_REVOKED,
282
283
  session: self,
@@ -6,14 +6,14 @@
6
6
  %>
7
7
  <section aria-label="Other sign-in options">
8
8
  <p>Or continue with</p>
9
- <% if StandardId.config.google_client_id.present? %>
9
+ <% if StandardId.social_provider_enabled?(:google) %>
10
10
  <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
11
11
  <%= form.hidden_field :connection, value: "google" %>
12
12
  <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
13
13
  <button type="submit">Google</button>
14
14
  <% end %>
15
15
  <% end %>
16
- <% if StandardId.config.apple_client_id.present? %>
16
+ <% if StandardId.social_provider_enabled?(:apple) %>
17
17
  <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
18
18
  <%= form.hidden_field :connection, value: "apple" %>
19
19
  <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
@@ -53,7 +53,7 @@
53
53
  })();
54
54
  </script>
55
55
 
56
- <% if StandardId.config.google_client_id.present? || StandardId.config.apple_client_id.present? %>
56
+ <% if StandardId.social_provider_enabled?(:google) || StandardId.social_provider_enabled?(:apple) %>
57
57
  <%= render "standard_id/web/login/social_buttons" %>
58
58
  <% end %>
59
59
 
@@ -128,7 +128,7 @@
128
128
  </div>
129
129
  <% end %>
130
130
 
131
- <% if StandardId.config.google_client_id.present? || StandardId.config.apple_client_id.present? %>
131
+ <% if StandardId.social_provider_enabled?(:google) || StandardId.social_provider_enabled?(:apple) %>
132
132
  <div>
133
133
  <div class="mt-10 flex items-center gap-x-6">
134
134
  <div class="w-full flex-1 border-t border-gray-200 dark:border-white/10"></div>
@@ -137,7 +137,7 @@
137
137
  </div>
138
138
 
139
139
  <div class="mt-6 grid grid-cols-2 gap-4">
140
- <% if StandardId.config.google_client_id.present? %>
140
+ <% if StandardId.social_provider_enabled?(:google) %>
141
141
  <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
142
142
  <%= form.hidden_field :connection, value: "google" %>
143
143
  <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
@@ -153,7 +153,7 @@
153
153
  <% end %>
154
154
  <% end %>
155
155
 
156
- <% if StandardId.config.apple_client_id.present? %>
156
+ <% if StandardId.social_provider_enabled?(:apple) %>
157
157
  <%= form_with url: login_path, method: :post, local: true, data: { turbo: false } do |form| %>
158
158
  <%= form.hidden_field :connection, value: "apple" %>
159
159
  <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
@@ -185,7 +185,7 @@
185
185
  <main>
186
186
  <h1>Sign in</h1>
187
187
  <p>No login method is enabled.</p>
188
- <% if StandardId.config.google_client_id.present? || StandardId.config.apple_client_id.present? %>
188
+ <% if StandardId.social_provider_enabled?(:google) || StandardId.social_provider_enabled?(:apple) %>
189
189
  <%= render "standard_id/web/login/social_buttons" %>
190
190
  <% end %>
191
191
  </main>
@@ -46,7 +46,7 @@
46
46
  </div>
47
47
  <% end %>
48
48
 
49
- <% if StandardId.config.google_client_id.present? || StandardId.config.apple_client_id.present? %>
49
+ <% if StandardId.social_provider_enabled?(:google) || StandardId.social_provider_enabled?(:apple) %>
50
50
  <div>
51
51
  <div class="mt-10 flex items-center gap-x-6">
52
52
  <div class="w-full flex-1 border-t border-gray-200 dark:border-white/10"></div>
@@ -55,7 +55,7 @@
55
55
  </div>
56
56
 
57
57
  <div class="mt-6 grid grid-cols-2 gap-4">
58
- <% if StandardId.config.google_client_id.present? %>
58
+ <% if StandardId.social_provider_enabled?(:google) %>
59
59
  <%= form_with url: signup_path, method: :post, local: true do |form| %>
60
60
  <%= form.hidden_field :connection, value: "google" %>
61
61
  <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
@@ -71,7 +71,7 @@
71
71
  <% end %>
72
72
  <% end %>
73
73
 
74
- <% if StandardId.config.apple_client_id.present? %>
74
+ <% if StandardId.social_provider_enabled?(:apple) %>
75
75
  <%= form_with url: signup_path, method: :post, local: true do |form| %>
76
76
  <%= form.hidden_field :connection, value: "apple" %>
77
77
  <%= form.hidden_field :redirect_uri, value: @redirect_uri %>
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdClientApplications < ActiveRecord::Migration[7.1]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_client_applications, id: primary_key_type do |t|
4
6
  # Polymorphic owner association (Account, Organization, etc.)
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdPasswordCredentials < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_password_credentials, id: primary_key_type do |t|
4
6
  t.string :login, null: false, index: { unique: true }
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdIdentifiers < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_identifiers, id: primary_key_type do |t|
4
6
  t.references :account, type: primary_key_type, null: false, foreign_key: { to_table: StandardId.account_class.table_name }, index: true
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdCredentials < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_credentials, id: primary_key_type do |t|
4
6
  t.references :identifier, type: primary_key_type, null: false, foreign_key: { to_table: :standard_id_identifiers }, index: true
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdSessions < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_sessions, id: primary_key_type do |t|
4
6
  t.references :account, type: primary_key_type, null: false, foreign_key: true, index: true
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdClientSecretCredentials < ActiveRecord::Migration[7.1]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_client_secret_credentials, id: primary_key_type do |t|
4
6
  t.string :name, null: false
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdAuthorizationCodes < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_authorization_codes, id: primary_key_type do |t|
4
6
  # Link to account when available (can be nil for pre-auth flows)
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdCodeChallenges < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_code_challenges, id: primary_key_type do |t|
4
6
  t.string :realm, null: false # e.g., authentication, verification
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdRefreshTokens < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_refresh_tokens, id: primary_key_type do |t|
4
6
  t.references :account, type: primary_key_type, null: false, foreign_key: true, index: true
@@ -1,3 +1,4 @@
1
+ # Superseded by 20260416180511's partial index_code_challenges_on_active_target_created_at; hosts that installed that may skip this one (StandardId::MigrationCheck::SUPERSEDED_BY).
1
2
  class AddTargetCreatedAtIndexToCodeChallenges < ActiveRecord::Migration[8.0]
2
3
  def change
3
4
  add_index :standard_id_code_challenges,
@@ -7,8 +7,10 @@ class AddPartialIndexesForActiveSessionAndChallengeLookups < ActiveRecord::Migra
7
7
  # except for the GIN drop in step 3. We guard that drop with an `if_exists`
8
8
  # check so StrongMigrations/host apps that never ran the creating migration
9
9
  # (e.g. SQLite dummies) don't error. StrongMigrations considers partial-
10
- # index-add and remove_index safe when concurrently + if_exists are used, so
11
- # no ignore comment is needed.
10
+ # index-add and remove_index safe when concurrently + if_exists are used —
11
+ # except its "non-unique index with more than three columns" heuristic, which
12
+ # rejects the 4-column code_challenges index below; that one add_index is
13
+ # asserted safe (see #assert_safe and the comment at the call site).
12
14
  #
13
15
  # Split into def up / def down because `remove_index :table, name: "..."`
14
16
  # (name-only, no column list) is not auto-reversible via def change — Rails
@@ -55,12 +57,20 @@ class AddPartialIndexesForActiveSessionAndChallengeLookups < ActiveRecord::Migra
55
57
  # The existing [:realm, :channel, :target, :created_at] index works but
56
58
  # covers every row, including long-since-consumed ones. A partial variant
57
59
  # stays tiny (only live challenges) and matches the exact query shape.
58
- add_index :standard_id_code_challenges,
59
- [:realm, :channel, :target, :created_at],
60
- where: "used_at IS NULL",
61
- name: "index_code_challenges_on_active_target_created_at",
62
- if_not_exists: true,
63
- **concurrent
60
+ #
61
+ # StrongMigrations flags any non-unique index over more than three columns.
62
+ # The shape here is deliberate: three equality predicates plus the ORDER BY
63
+ # column, partial on live challenges, built CONCURRENTLY — so assert it safe
64
+ # rather than make every host wrap it by hand (fundbright, luminality and
65
+ # nutripod all had to).
66
+ assert_safe do
67
+ add_index :standard_id_code_challenges,
68
+ [:realm, :channel, :target, :created_at],
69
+ where: "used_at IS NULL",
70
+ name: "index_code_challenges_on_active_target_created_at",
71
+ if_not_exists: true,
72
+ **concurrent
73
+ end
64
74
 
65
75
  # Drop the GIN metadata index on Postgres: metadata is only written to
66
76
  # (record_failed_attempt bumps `attempts`), never queried with containment
@@ -114,4 +124,11 @@ class AddPartialIndexesForActiveSessionAndChallengeLookups < ActiveRecord::Migra
114
124
  if_exists: true,
115
125
  **concurrent
116
126
  end
127
+
128
+ private
129
+
130
+ # StrongMigrations is optional for hosts (the gem does not depend on it).
131
+ def assert_safe(&block)
132
+ respond_to?(:safety_assured) ? safety_assured(&block) : yield
133
+ end
117
134
  end
@@ -1,4 +1,6 @@
1
1
  class CreateStandardIdClientGrants < ActiveRecord::Migration[8.0]
2
+ include StandardId::MigrationHelpers
3
+
2
4
  def change
3
5
  create_table :standard_id_client_grants, id: primary_key_type do |t|
4
6
  # The account that granted consent. Required — consent is per-user.
@@ -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
+ # c.passwordless_email_sender / c.passwordless_sms_sender are DEPRECATED
257
+ # (assigning them emits a StandardId deprecation warning). Deliver codes from
258
+ # an event subscriber instead — it runs synchronously in the request, so
259
+ # I18n.locale etc. are still available:
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,10 @@ 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; the old password_login_per_ip/_per_email names
514
+ # still work but emit a deprecation warning (the new name wins when both are set).
484
515
  # c.rate_limits.login_per_ip = 20 # per 15 minutes
485
516
  # 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
517
  # c.rate_limits.otp_verify_per_ip = 20 # per 15 minutes
489
518
  # c.rate_limits.verification_start_per_target = 3 # per 15 minutes
490
519
  # 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,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
- field :passwordless_email_sender, type: :any, default: nil
13
- field :passwordless_sms_sender, type: :any, default: nil
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 passwordless login.
143
- # Retained for backwards compatibility with consuming apps that set this field.
144
- field :enabled, type: :boolean, default: false
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
 
@@ -296,8 +312,12 @@ StandardId::ConfigSchema.define do
296
312
  # RefreshTokenFlow::MAX_REUSE_LEEWAY_SECONDS.
297
313
  field :refresh_token_reuse_leeway, type: :integer, default: 0
298
314
  field :token_lifetimes, type: :hash, default: -> { {} }
299
- field :client_id, type: :string, default: nil
300
- field :client_secret, type: :string, default: nil
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."
301
321
  field :scope_claims, type: :hash, default: -> { {} }
302
322
  field :claim_resolvers, type: :hash, default: -> { {} }
303
323
  # List of audience values that tokens issued and accepted by this app may
@@ -551,6 +571,11 @@ StandardId::ConfigSchema.define do
551
571
  field :allowed_redirect_url_prefixes, type: :array, default: []
552
572
  field :available_scopes, type: :array, default: -> { [] }
553
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
554
579
  end
555
580
 
556
581
  scope :web do
@@ -583,8 +608,10 @@ StandardId::ConfigSchema.define do
583
608
  # these values (see StandardId::RateLimitHandling.login_per_ip). New name
584
609
  # wins when explicitly set. Mirrors the max_attempts ->
585
610
  # 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
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."
588
615
 
589
616
  # RAR-60: OTP verification
590
617
  field :otp_verify_per_ip, type: :integer, default: 20 # per 15 minutes