standard_id 0.41.0 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +102 -1
- data/README.md +251 -13
- data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +13 -3
- data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
- data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
- data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
- data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
- data/app/models/concerns/standard_id/credentiable.rb +8 -1
- data/app/models/standard_id/application_record.rb +26 -0
- data/app/models/standard_id/authorization_code.rb +3 -1
- data/app/models/standard_id/client_application.rb +11 -0
- data/app/models/standard_id/identifier.rb +1 -0
- data/app/models/standard_id/session.rb +2 -1
- data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
- data/app/views/standard_id/web/login/show.html.erb +5 -5
- data/app/views/standard_id/web/signup/show.html.erb +3 -3
- data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
- data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
- data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
- data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
- data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
- data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
- data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
- data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
- data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
- data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
- data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
- data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
- data/db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb +115 -0
- data/lib/generators/standard_id/install/install_generator.rb +64 -3
- data/lib/generators/standard_id/install/templates/standard_id.rb +45 -15
- data/lib/standard_id/checks/migrations.rb +61 -0
- data/lib/standard_id/config/schema.rb +40 -10
- data/lib/standard_id/config_schema.rb +28 -5
- data/lib/standard_id/deprecator.rb +17 -0
- data/lib/standard_id/engine.rb +21 -0
- data/lib/standard_id/instrumentation.rb +49 -0
- data/lib/standard_id/migration_check.rb +183 -0
- data/lib/standard_id/migration_helpers.rb +65 -0
- data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
- data/lib/standard_id/oauth/oauth_session_persistence.rb +66 -24
- data/lib/standard_id/oauth/refresh_token_flow.rb +23 -2
- data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
- data/lib/standard_id/passwordless.rb +13 -1
- data/lib/standard_id/provider_registry.rb +113 -2
- data/lib/standard_id/providers/base.rb +222 -8
- data/lib/standard_id/providers/plugin_railtie.rb +59 -0
- data/lib/standard_id/scope_config.rb +24 -7
- data/lib/standard_id/testing/provider_examples.rb +117 -0
- data/lib/standard_id/testing.rb +1 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +26 -0
- metadata +24 -17
- data/config/initializers/migration_helpers.rb +0 -32
|
@@ -3,7 +3,14 @@ module StandardId
|
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
5
|
included do
|
|
6
|
-
|
|
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
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 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 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,
|
data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb
CHANGED
|
@@ -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
|
|
11
|
-
#
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
@@ -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 —
|
|
164
|
+
say "5. Scheduled maintenance — the cleanup jobs must run on a schedule"
|
|
165
|
+
say " (added to config/recurring.yml if you use Solid Queue; otherwise"
|
|
166
|
+
say " schedule them yourself, hourly or at least daily):"
|
|
167
|
+
say ""
|
|
168
|
+
CLEANUP_JOBS.each_value { |(job, _)| say " #{job}" }
|
|
120
169
|
say ""
|
|
121
|
-
say "
|
|
122
|
-
say " StandardId::CleanupExpiredRefreshTokensJob"
|
|
170
|
+
say " See the README's Scheduled Maintenance section."
|
|
123
171
|
say ""
|
|
124
172
|
say "6. Social providers — install provider plugins and register them:"
|
|
125
173
|
say ""
|
|
@@ -160,6 +208,19 @@ module StandardId
|
|
|
160
208
|
text.each_line.map { |line| line.strip.empty? ? line : prefix + line }.join
|
|
161
209
|
end
|
|
162
210
|
|
|
211
|
+
def recurring_snippet
|
|
212
|
+
entries = CLEANUP_JOBS.map do |key, (job, schedule)|
|
|
213
|
+
<<~YAML
|
|
214
|
+
#{key}:
|
|
215
|
+
class: #{job}
|
|
216
|
+
schedule: #{schedule}
|
|
217
|
+
YAML
|
|
218
|
+
end
|
|
219
|
+
"# StandardId cleanup jobs (added by standard_id:install). Retention is\n" \
|
|
220
|
+
"# bounded by each job's grace window; the cadence only sizes the DELETE.\n" +
|
|
221
|
+
entries.join
|
|
222
|
+
end
|
|
223
|
+
|
|
163
224
|
def engine_mount_snippet
|
|
164
225
|
<<~RUBY
|
|
165
226
|
# Mount the StandardId engines. The web engine serves cookie-based
|
|
@@ -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
|
|
229
|
-
#
|
|
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
|
-
#
|
|
256
|
-
#
|
|
257
|
-
#
|
|
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
|
-
#
|
|
434
|
-
#
|
|
435
|
-
#
|
|
436
|
-
#
|
|
437
|
-
#
|
|
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
|
|
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
|