standard_id 0.41.0 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -1
  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 +13 -3
  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/client_application.rb +11 -0
  15. data/app/models/standard_id/identifier.rb +1 -0
  16. data/app/models/standard_id/session.rb +2 -1
  17. data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
  18. data/app/views/standard_id/web/login/show.html.erb +5 -5
  19. data/app/views/standard_id/web/signup/show.html.erb +3 -3
  20. data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
  21. data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
  22. data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
  23. data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
  24. data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
  25. data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
  26. data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
  27. data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
  28. data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
  29. data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
  30. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
  31. data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
  32. data/db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb +115 -0
  33. data/lib/generators/standard_id/install/install_generator.rb +64 -3
  34. data/lib/generators/standard_id/install/templates/standard_id.rb +45 -15
  35. data/lib/standard_id/checks/migrations.rb +61 -0
  36. data/lib/standard_id/config/schema.rb +40 -10
  37. data/lib/standard_id/config_schema.rb +28 -5
  38. data/lib/standard_id/deprecator.rb +17 -0
  39. data/lib/standard_id/engine.rb +21 -0
  40. data/lib/standard_id/instrumentation.rb +49 -0
  41. data/lib/standard_id/migration_check.rb +183 -0
  42. data/lib/standard_id/migration_helpers.rb +65 -0
  43. data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
  44. data/lib/standard_id/oauth/oauth_session_persistence.rb +66 -24
  45. data/lib/standard_id/oauth/refresh_token_flow.rb +23 -2
  46. data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
  47. data/lib/standard_id/passwordless.rb +13 -1
  48. data/lib/standard_id/provider_registry.rb +113 -2
  49. data/lib/standard_id/providers/base.rb +222 -8
  50. data/lib/standard_id/providers/plugin_railtie.rb +59 -0
  51. data/lib/standard_id/scope_config.rb +24 -7
  52. data/lib/standard_id/testing/provider_examples.rb +117 -0
  53. data/lib/standard_id/testing.rb +1 -0
  54. data/lib/standard_id/version.rb +1 -1
  55. data/lib/standard_id.rb +26 -0
  56. metadata +24 -17
  57. data/config/initializers/migration_helpers.rb +0 -32
@@ -3,7 +3,14 @@ module StandardId
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
- has_one :credential, as: :credentialable, touch: true
6
+ # strict_loading: false — `touch: true` makes Rails read this association
7
+ # from inside its own after_create/after_update/after_destroy callbacks
8
+ # (ActiveRecord::Associations::Builder::HasOne.touch_record), which no
9
+ # `includes` at a call site can reach. Without the exemption every save of
10
+ # a PasswordCredential / ClientSecretCredential raises
11
+ # StrictLoadingViolationError in hosts running strict_loading_by_default.
12
+ # It is a one-row has_one read, never an N+1.
13
+ has_one :credential, as: :credentialable, touch: true, strict_loading: false
7
14
  accepts_nested_attributes_for :credential
8
15
 
9
16
  delegate :account, to: :credential
@@ -1,5 +1,31 @@
1
1
  module StandardId
2
2
  class ApplicationRecord < ActiveRecord::Base
3
3
  self.abstract_class = true
4
+
5
+ # Load +associations+ onto already-instantiated +records+ without tripping
6
+ # strict loading.
7
+ #
8
+ # Gem models run under hosts that set `strict_loading_by_default = true`.
9
+ # A lazy `record.account` then raises ActiveRecord::StrictLoadingViolationError
10
+ # even for a single-row belongs_to read. Where the gem did not load the
11
+ # record itself (model callbacks, records handed in by host code) it cannot
12
+ # add `includes` at the query, so it preloads here instead — the supported
13
+ # Rails API, which also skips associations that are already loaded.
14
+ #
15
+ # @return [Array<ActiveRecord::Base>] the records
16
+ def self.preload_associations(records, *associations)
17
+ records = Array(records).compact
18
+ return records if records.empty?
19
+
20
+ ActiveRecord::Associations::Preloader.new(records: records, associations: associations).call
21
+ records
22
+ end
23
+
24
+ private
25
+
26
+ def preload_associations(*associations)
27
+ StandardId::ApplicationRecord.preload_associations(self, *associations)
28
+ self
29
+ end
4
30
  end
5
31
  end
@@ -60,8 +60,10 @@ module StandardId
60
60
  )
61
61
  end
62
62
 
63
+ # Preloads :account — every caller (the token exchange) reads it, and hosts
64
+ # running strict_loading_by_default would otherwise raise on the lazy read.
63
65
  def self.lookup(plaintext_code)
64
- find_by(code_hash: hash_for(plaintext_code))
66
+ includes(:account).find_by(code_hash: hash_for(plaintext_code))
65
67
  end
66
68
 
67
69
  def self.hash_for(plaintext_code)
@@ -1,6 +1,17 @@
1
1
  module StandardId
2
2
  class ClientApplication < ApplicationRecord
3
3
  self.table_name = "standard_id_client_applications"
4
+
5
+ # 0.41.0 dropped `refresh_token_lifetime` (migration 20260915000000) without
6
+ # ignoring it first, so a rolling deploy broke: processes still running the
7
+ # old code had the column in their cached schema and wrote it on every
8
+ # INSERT/UPDATE once the migration had removed it. Ignoring it here lets
9
+ # hosts ship this release first and drop the column in a LATER deploy —
10
+ # the order strong_migrations requires for remove_column.
11
+ #
12
+ # Remove this line in a future minor, once every host has run 20260915000000.
13
+ self.ignored_columns += %w[refresh_token_lifetime]
14
+
4
15
  belongs_to :owner, polymorphic: true
5
16
 
6
17
  has_many :client_secret_credentials, dependent: :destroy
@@ -36,6 +36,7 @@ module StandardId
36
36
  end
37
37
 
38
38
  def mark_account_verified!
39
+ preload_associations(:account)
39
40
  return if account.nil?
40
41
 
41
42
  return unless account.has_attribute?(:verified)
@@ -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.
@@ -0,0 +1,115 @@
1
+ class AddUniqueActiveDeviceIndexToStandardIdSessions < ActiveRecord::Migration[8.0]
2
+ # One ACTIVE DeviceSession per (account, device).
3
+ #
4
+ # `OauthSessionPersistence.upsert_device_session!` keys OAuth-issued device
5
+ # sessions on a stable device_id and reuses the active row for repeat
6
+ # sign-ins. Until now nothing in the schema backed that: the upsert
7
+ # serialised on a row lock, a race or an older gem could still leave two
8
+ # active rows for one device, and the lookup then picked one arbitrarily.
9
+ # This partial unique index makes "one active row per device" a database
10
+ # invariant; the upsert inserts inside a savepoint and, on
11
+ # RecordNotUnique, reuses the row that won.
12
+ #
13
+ # Partial on purpose:
14
+ # - `revoked_at IS NULL` — revoked rows are history (audit trail, admin
15
+ # session list) and a device legitimately accumulates one per sign-out.
16
+ # - `device_id IS NOT NULL` — BrowserSession / ServiceSession rows share
17
+ # the table and carry no device_id.
18
+ #
19
+ # Existing duplicates are resolved BEFORE the index is built, without
20
+ # revoking anything: in each duplicated (account_id, device_id) group the
21
+ # newest active row keeps its device_id and every other row has
22
+ # ":detached:<id>" appended to its own. Detached rows stay active — their
23
+ # refresh tokens keep working until they expire or are revoked — they are
24
+ # simply no longer the row a new sign-in on that device reuses. Not undone
25
+ # by `down`: the original device_id is recoverable by stripping the suffix.
26
+ #
27
+ # Idempotent (if_not_exists / if_exists), CONCURRENTLY on Postgres, same
28
+ # conventions as 20260416180511. StrongMigrations treats a concurrent
29
+ # add_index as safe, but it cannot inspect the raw-SQL detach UPDATE and
30
+ # stops the migration there whenever a host actually has duplicates — so
31
+ # that data fix is asserted safe (see #assert_safe) when the gem is loaded.
32
+ disable_ddl_transaction!
33
+
34
+ INDEX_NAME = "index_standard_id_sessions_on_active_account_device".freeze
35
+ WHERE = "revoked_at IS NULL AND device_id IS NOT NULL".freeze
36
+ DETACHED_MARKER = ":detached:".freeze
37
+
38
+ def up
39
+ pg = connection.adapter_name.downcase.include?("postgres")
40
+ concurrent = pg ? { algorithm: :concurrently } : {}
41
+
42
+ # A CONCURRENTLY build that failed (e.g. a duplicate inserted mid-build)
43
+ # leaves an INVALID index behind, which if_not_exists would then skip.
44
+ # Drop it so a re-run actually rebuilds.
45
+ if pg && invalid_postgres_index?(INDEX_NAME)
46
+ remove_index :standard_id_sessions, name: INDEX_NAME, if_exists: true, **concurrent
47
+ end
48
+
49
+ detach_duplicate_active_device_sessions!
50
+
51
+ add_index :standard_id_sessions,
52
+ [:account_id, :device_id],
53
+ unique: true,
54
+ where: WHERE,
55
+ name: INDEX_NAME,
56
+ if_not_exists: true,
57
+ **concurrent
58
+ end
59
+
60
+ def down
61
+ pg = connection.adapter_name.downcase.include?("postgres")
62
+ concurrent = pg ? { algorithm: :concurrently } : {}
63
+
64
+ remove_index :standard_id_sessions, name: INDEX_NAME, if_exists: true, **concurrent
65
+ end
66
+
67
+ private
68
+
69
+ def detach_duplicate_active_device_sessions!
70
+ rows = select_all(<<~SQL.squish).to_a
71
+ SELECT s.id, s.account_id, s.device_id
72
+ FROM standard_id_sessions s
73
+ INNER JOIN (
74
+ SELECT account_id, device_id
75
+ FROM standard_id_sessions
76
+ WHERE #{WHERE}
77
+ GROUP BY account_id, device_id
78
+ HAVING COUNT(*) > 1
79
+ ) dup ON dup.account_id = s.account_id AND dup.device_id = s.device_id
80
+ WHERE s.revoked_at IS NULL AND s.device_id IS NOT NULL
81
+ ORDER BY s.account_id, s.device_id, s.created_at DESC, s.id DESC
82
+ SQL
83
+
84
+ rows.group_by { |row| [row["account_id"], row["device_id"]] }.each_value do |group|
85
+ # group.first is the newest active row: it keeps the device_id.
86
+ group.drop(1).each do |row|
87
+ detached = "#{row["device_id"]}#{DETACHED_MARKER}#{row["id"]}"
88
+ assert_safe do
89
+ execute(<<~SQL.squish)
90
+ UPDATE standard_id_sessions
91
+ SET device_id = #{connection.quote(detached)}
92
+ WHERE id = #{connection.quote(row["id"])}
93
+ SQL
94
+ end
95
+ end
96
+ end
97
+ end
98
+
99
+ # A single-row UPDATE by primary key, run only for duplicated device rows:
100
+ # a deliberate, bounded data fix. Every known host runs StrongMigrations,
101
+ # which cannot inspect raw SQL; assert safety when it is loaded, and run the
102
+ # block as-is when it is not (the gem does not depend on it).
103
+ def assert_safe(&block)
104
+ respond_to?(:safety_assured) ? safety_assured(&block) : yield
105
+ end
106
+
107
+ def invalid_postgres_index?(name)
108
+ select_value(<<~SQL.squish).present?
109
+ SELECT 1
110
+ FROM pg_index i
111
+ INNER JOIN pg_class c ON c.oid = i.indexrelid
112
+ WHERE c.relname = #{connection.quote(name)} AND NOT i.indisvalid
113
+ SQL
114
+ end
115
+ end
@@ -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
@@ -225,8 +225,9 @@ StandardId.configure do |c|
225
225
  # challenge is burned so further submissions fail fast. Distinct from the
226
226
  # per-IP rate limit (c.rate_limits.otp_verify_per_ip) — this defends
227
227
  # against distributed brute-force against a single challenge.
228
- # Default: nil — falls back to :max_attempts for backwards compatibility.
229
- # c.passwordless.max_attempts_per_challenge = 5
228
+ # Default: nil — falls back to :max_attempts (default 3) for backwards
229
+ # compatibility, so the effective default ceiling is 3.
230
+ # c.passwordless.max_attempts_per_challenge = 3
230
231
 
231
232
  # Default: 3 — deprecated alias for :max_attempts_per_challenge. Retained
232
233
  # for backwards compatibility; new installs should set the newer key.
@@ -252,9 +253,18 @@ StandardId.configure do |c|
252
253
  # Account.create!(email: identifier.value)
253
254
  # }
254
255
 
255
- # Deprecated senders (prefer event subscriptions + built_in delivery):
256
- # c.passwordless_email_sender = ->(email, code) { PasswordlessMailer.with(code: code, to: email).deliver_later }
257
- # 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
258
268
 
259
269
  # ---------------------------------------------------------------------------
260
270
  # API engine — OAuth / JWT
@@ -429,15 +439,37 @@ StandardId.configure do |c|
429
439
  # declared later from the plugin's Railtie, and apps worked around it by
430
440
  # wrapping the writes in `Rails.application.config.after_initialize`. That
431
441
  # wrapper is no longer needed, and it still works if you have one.
432
-
433
- # c.social.google_client_id = ENV["GOOGLE_CLIENT_ID"]
434
- # c.social.google_client_secret = ENV["GOOGLE_CLIENT_SECRET"]
435
- # c.social.apple_mobile_client_id = ENV["APPLE_MOBILE_CLIENT_ID"]
436
- # c.social.apple_client_id = ENV["APPLE_CLIENT_ID"]
437
- # 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
438
463
  # c.social.apple_key_id = ENV["APPLE_KEY_ID"]
439
464
  # c.social.apple_team_id = ENV["APPLE_TEAM_ID"]
440
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
+
441
473
  # Mobile redirect URI allow-list — custom schemes used by native apps.
442
474
  # Default: []
443
475
  # c.social.allowed_redirect_url_prefixes = ["example-app://"]
@@ -478,12 +510,10 @@ StandardId.configure do |c|
478
510
 
479
511
  # Login limits. The login action branches password OR passwordless, so on a
480
512
  # passwordless app these govern the OTP-SEND limit. Prefer the
481
- # mechanism-agnostic names; the deprecated password_login_* names still work
482
- # (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).
483
515
  # c.rate_limits.login_per_ip = 20 # per 15 minutes
484
516
  # c.rate_limits.login_per_email = 5 # per 15 minutes
485
- # c.rate_limits.password_login_per_ip = 20 # deprecated alias of login_per_ip
486
- # c.rate_limits.password_login_per_email = 5 # deprecated alias of login_per_email
487
517
  # c.rate_limits.otp_verify_per_ip = 20 # per 15 minutes
488
518
  # c.rate_limits.verification_start_per_target = 3 # per 15 minutes
489
519
  # c.rate_limits.verification_start_per_ip = 10 # per hour