standard_id 0.41.1 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -0
- data/README.md +310 -17
- data/app/controllers/concerns/standard_id/inertia_rendering.rb +23 -5
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +1 -0
- data/app/controllers/concerns/standard_id/passwordless_flow.rb +11 -2
- data/app/controllers/concerns/standard_id/rate_limit_handling.rb +5 -19
- data/app/controllers/concerns/standard_id/social_authentication.rb +1 -1
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +1 -8
- data/app/controllers/standard_id/web/login_controller.rb +1 -2
- data/app/controllers/standard_id/web/login_verify_controller.rb +2 -0
- data/app/controllers/standard_id/web/verify_email/start_controller.rb +12 -8
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +12 -8
- data/app/jobs/standard_id/cleanup_all_job.rb +46 -0
- data/app/jobs/standard_id/password_reset_delivery_job.rb +1 -1
- data/app/models/concerns/standard_id/credentiable.rb +8 -1
- data/app/models/standard_id/application_record.rb +26 -0
- data/app/models/standard_id/authorization_code.rb +3 -1
- data/app/models/standard_id/identifier.rb +1 -0
- data/app/models/standard_id/session.rb +2 -1
- data/app/views/standard_id/web/login/_social_buttons.html.erb +2 -2
- data/app/views/standard_id/web/login/show.html.erb +5 -5
- data/app/views/standard_id/web/signup/show.html.erb +3 -3
- data/db/migrate/20250830000000_create_standard_id_client_applications.rb +2 -0
- data/db/migrate/20250830171553_create_standard_id_password_credentials.rb +2 -0
- data/db/migrate/20250830232800_create_standard_id_identifiers.rb +2 -0
- data/db/migrate/20250831075703_create_standard_id_credentials.rb +2 -0
- data/db/migrate/20250831154635_create_standard_id_sessions.rb +2 -0
- data/db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb +2 -0
- data/db/migrate/20250903063000_create_standard_id_authorization_codes.rb +2 -0
- data/db/migrate/20250907090000_create_standard_id_code_challenges.rb +2 -0
- data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +2 -0
- data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +1 -0
- data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +25 -8
- data/db/migrate/20260611000000_create_standard_id_client_grants.rb +2 -0
- data/lib/generators/standard_id/install/install_generator.rb +64 -3
- data/lib/generators/standard_id/install/templates/standard_id.rb +41 -13
- data/lib/standard_id/checks/migrations.rb +61 -0
- data/lib/standard_id/config/schema.rb +34 -20
- data/lib/standard_id/config_schema.rb +94 -6
- data/lib/standard_id/deprecator.rb +17 -0
- data/lib/standard_id/engine.rb +22 -0
- data/lib/standard_id/events/subscribers/passwordless_delivery_subscriber.rb +3 -0
- data/lib/standard_id/instrumentation.rb +49 -0
- data/lib/standard_id/migration_check.rb +183 -0
- data/lib/standard_id/migration_helpers.rb +65 -0
- data/lib/standard_id/oauth/audience_profile_resolver.rb +8 -2
- data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
- data/lib/standard_id/oauth/token_grant_flow.rb +25 -3
- data/lib/standard_id/otp.rb +20 -28
- data/lib/standard_id/passwordless/base_strategy.rb +18 -14
- data/lib/standard_id/passwordless/email_strategy.rb +0 -5
- data/lib/standard_id/passwordless/sms_strategy.rb +0 -4
- data/lib/standard_id/provider_registry.rb +95 -5
- data/lib/standard_id/providers/base.rb +222 -8
- data/lib/standard_id/providers/plugin_railtie.rb +59 -0
- data/lib/standard_id/scope_config.rb +43 -26
- data/lib/standard_id/testing/config_helpers.rb +55 -0
- data/lib/standard_id/testing/provider_examples.rb +117 -0
- data/lib/standard_id/testing.rb +2 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +26 -0
- metadata +25 -17
- data/config/initializers/migration_helpers.rb +0 -32
|
@@ -61,7 +61,10 @@ module StandardId
|
|
|
61
61
|
# @param username [String] the identifier value (email or phone number)
|
|
62
62
|
# @param code [String] the OTP code to verify
|
|
63
63
|
# @param connection [String] the delivery channel ("email" or "sms"), defaults to "email"
|
|
64
|
-
# @param allow_registration [Boolean] whether to create a new account if none exists (default: true)
|
|
64
|
+
# @param allow_registration [Boolean] whether to create a new account if none exists (default: true).
|
|
65
|
+
# When the including controller resolves a scope (StandardId::LifecycleHooks#current_scope_config)
|
|
66
|
+
# and that scope sets `allow_registration: false`, registration is refused regardless —
|
|
67
|
+
# a scope can only restrict.
|
|
65
68
|
# @return [StandardId::Passwordless::VerificationService::Result] a result with:
|
|
66
69
|
# - success? -- true when verification succeeded
|
|
67
70
|
# - account -- the authenticated/created account (nil on failure)
|
|
@@ -77,8 +80,14 @@ module StandardId
|
|
|
77
80
|
code: code,
|
|
78
81
|
connection: connection,
|
|
79
82
|
request: request,
|
|
80
|
-
allow_registration: allow_registration
|
|
83
|
+
allow_registration: StandardId::ScopeConfig.registration_allowed?(allow_registration, passwordless_scope_config)
|
|
81
84
|
)
|
|
82
85
|
end
|
|
86
|
+
|
|
87
|
+
# The active ScopeConfig when the controller also includes
|
|
88
|
+
# StandardId::LifecycleHooks; nil otherwise (no scope → no restriction).
|
|
89
|
+
def passwordless_scope_config
|
|
90
|
+
respond_to?(:current_scope_config, true) ? current_scope_config : nil
|
|
91
|
+
end
|
|
83
92
|
end
|
|
84
93
|
end
|
|
@@ -41,29 +41,15 @@ module StandardId
|
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
# `password_login_per_ip` when the host left the alias at its default (i.e.
|
|
47
|
-
# only configured the old name). The new alias wins whenever explicitly set.
|
|
48
|
-
# Mirrors the max_attempts -> max_attempts_per_challenge deprecation-alias
|
|
49
|
-
# precedent (prefer-new, fall-back-to-old).
|
|
44
|
+
# Effective per-IP login rate limit (`rate_limits.login_per_ip`). The
|
|
45
|
+
# deprecated `password_login_per_ip` fallback was removed in 0.43.
|
|
50
46
|
def self.login_per_ip
|
|
51
|
-
|
|
47
|
+
StandardId.config.rate_limits.login_per_ip
|
|
52
48
|
end
|
|
53
49
|
|
|
54
|
-
# Effective per-email login rate limit
|
|
50
|
+
# Effective per-email login rate limit (`rate_limits.login_per_email`).
|
|
55
51
|
def self.login_per_email
|
|
56
|
-
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Return the alias value unless it still equals its schema default, in which
|
|
60
|
-
# case fall back to the deprecated field. Both fields share the same default,
|
|
61
|
-
# so the effective default is unchanged when neither is set.
|
|
62
|
-
def self.resolve_login_alias(new_field, old_field)
|
|
63
|
-
rate_limits = StandardId.config.rate_limits
|
|
64
|
-
default = StandardId.config.__schema__.field_for(:rate_limits, new_field).default_value
|
|
65
|
-
new_value = rate_limits[new_field]
|
|
66
|
-
new_value == default ? rate_limits[old_field] : new_value
|
|
52
|
+
StandardId.config.rate_limits.login_per_email
|
|
67
53
|
end
|
|
68
54
|
|
|
69
55
|
private
|
|
@@ -38,7 +38,7 @@ module StandardId
|
|
|
38
38
|
|
|
39
39
|
emit_social_user_info_fetched(provider, social_info, email)
|
|
40
40
|
|
|
41
|
-
identifier = StandardId::EmailIdentifier.find_by(value: email)
|
|
41
|
+
identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: email)
|
|
42
42
|
|
|
43
43
|
if identifier.present?
|
|
44
44
|
validate_social_link!(identifier, provider)
|
|
@@ -55,19 +55,12 @@ module StandardId
|
|
|
55
55
|
# must not emit. The error re-raises into the standard
|
|
56
56
|
# handle_oauth_error JSON response.
|
|
57
57
|
def fetch_provider_user_info
|
|
58
|
-
get_user_info_from_provider(flow:
|
|
58
|
+
get_user_info_from_provider(flow: provider.flow_for(params))
|
|
59
59
|
rescue StandardId::OAuthError => e
|
|
60
60
|
emit_social_auth_failed(e)
|
|
61
61
|
raise
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
def resolve_flow_for(connection)
|
|
65
|
-
return :mobile unless connection == "apple"
|
|
66
|
-
|
|
67
|
-
flow_param = params[:flow].to_s.downcase
|
|
68
|
-
flow_param == "web" ? :web : :mobile
|
|
69
|
-
end
|
|
70
|
-
|
|
71
64
|
# The `except` list is the trust boundary — non-reserved values are
|
|
72
65
|
# host-supplied opaque attribution data, never interpreted by the gem.
|
|
73
66
|
def forwarded_request_params
|
|
@@ -11,8 +11,7 @@ module StandardId
|
|
|
11
11
|
layout "public"
|
|
12
12
|
|
|
13
13
|
# RAR-51: Rate limit login attempts by IP (20 per 15 minutes). Reads the
|
|
14
|
-
# mechanism-agnostic `login_per_ip`
|
|
15
|
-
# `password_login_per_ip` — this action branches password OR passwordless,
|
|
14
|
+
# mechanism-agnostic `login_per_ip` — this action branches password OR passwordless,
|
|
16
15
|
# so on a passwordless app this governs the OTP-send limit, not a password
|
|
17
16
|
# login. See StandardId::RateLimitHandling.login_per_ip.
|
|
18
17
|
rate_limit to: StandardId::RateLimitHandling.login_per_ip,
|
|
@@ -106,6 +106,8 @@ module StandardId
|
|
|
106
106
|
end
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
+
# Global web.passwordless_registration AND the active scope's
|
|
110
|
+
# allow_registration (verify_passwordless_otp applies the scope half).
|
|
109
111
|
def passwordless_registration_enabled?
|
|
110
112
|
StandardId.config.web.passwordless_registration
|
|
111
113
|
end
|
|
@@ -35,17 +35,21 @@ module StandardId
|
|
|
35
35
|
render plain: "missing email", status: :unprocessable_content and return
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
# Issued through the passwordless strategy (validation, retry delay,
|
|
39
|
+
# previous-code invalidation) so delivery goes through the
|
|
40
|
+
# PASSWORDLESS_CODE_GENERATED event like every other OTP. Before 0.43
|
|
41
|
+
# this called the since-removed passwordless_email_sender directly.
|
|
42
|
+
result = StandardId::Otp.issue(
|
|
39
43
|
realm: "verification",
|
|
40
|
-
channel: "email",
|
|
41
44
|
target: email,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
user_agent: request.user_agent
|
|
45
|
+
channel: :email,
|
|
46
|
+
request: request,
|
|
47
|
+
expires_in: 10.minutes
|
|
46
48
|
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
unless result.success?
|
|
50
|
+
flash[:alert] = result.error_message
|
|
51
|
+
render plain: "invalid email", status: :unprocessable_content and return
|
|
52
|
+
end
|
|
49
53
|
|
|
50
54
|
redirect_to standard_id_web.login_path, notice: "Verification code sent to your email", status: :see_other
|
|
51
55
|
end
|
|
@@ -35,17 +35,21 @@ module StandardId
|
|
|
35
35
|
render plain: "invalid phone", status: :unprocessable_content and return
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
# Issued through the passwordless strategy (validation, retry delay,
|
|
39
|
+
# previous-code invalidation) so delivery goes through the
|
|
40
|
+
# PASSWORDLESS_CODE_GENERATED event like every other OTP. Before 0.43
|
|
41
|
+
# this called the since-removed passwordless_sms_sender directly.
|
|
42
|
+
result = StandardId::Otp.issue(
|
|
39
43
|
realm: "verification",
|
|
40
|
-
channel: "sms",
|
|
41
44
|
target: phone,
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
user_agent: request.user_agent
|
|
45
|
+
channel: :sms,
|
|
46
|
+
request: request,
|
|
47
|
+
expires_in: 10.minutes
|
|
46
48
|
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
unless result.success?
|
|
50
|
+
flash[:alert] = result.error_message
|
|
51
|
+
render plain: "invalid phone", status: :unprocessable_content and return
|
|
52
|
+
end
|
|
49
53
|
|
|
50
54
|
redirect_to standard_id_web.login_path, notice: "Verification code sent via SMS", status: :see_other
|
|
51
55
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
# Runs all four expired-row cleanup jobs inline, in one job.
|
|
3
|
+
#
|
|
4
|
+
# Schedule this ONE job instead of the four individual ones when you want a
|
|
5
|
+
# single recurring entry — typically so the schedule carries one cron
|
|
6
|
+
# monitor (Sentry, Honeybadger, …) rather than four. Subclass it in the host
|
|
7
|
+
# to attach the monitor:
|
|
8
|
+
#
|
|
9
|
+
# # app/jobs/standard_id_cleanup_job.rb
|
|
10
|
+
# class StandardIdCleanupJob < StandardId::CleanupAllJob
|
|
11
|
+
# include Sentry::Cron::MonitorCheckIns
|
|
12
|
+
# sentry_monitor_check_ins slug: "standard-id-cleanup",
|
|
13
|
+
# monitor_config: Sentry::Cron::MonitorConfig.from_crontab("7 * * * *")
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# One table's DELETE failing (a lock timeout, say) does not skip the others;
|
|
17
|
+
# the first error is re-raised once all four have run, so the run still
|
|
18
|
+
# fails its cron check-in and lands in the queue's failed executions.
|
|
19
|
+
#
|
|
20
|
+
# Each job keeps its default grace windows (see README "Scheduled
|
|
21
|
+
# Maintenance"); retention is bounded by those, not by the cadence.
|
|
22
|
+
class CleanupAllJob < ApplicationJob
|
|
23
|
+
queue_as :default
|
|
24
|
+
|
|
25
|
+
# @return [Array<Class>] the cleanup jobs run, in order
|
|
26
|
+
def self.jobs
|
|
27
|
+
[
|
|
28
|
+
StandardId::CleanupExpiredSessionsJob,
|
|
29
|
+
StandardId::CleanupExpiredRefreshTokensJob,
|
|
30
|
+
StandardId::CleanupExpiredAuthorizationCodesJob,
|
|
31
|
+
StandardId::CleanupExpiredCodeChallengesJob
|
|
32
|
+
]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def perform
|
|
36
|
+
errors = self.class.jobs.filter_map do |job|
|
|
37
|
+
job.perform_now
|
|
38
|
+
nil
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
Rails.logger.warn("[StandardId] #{job.name} failed: #{e.class}: #{e.message}")
|
|
41
|
+
e
|
|
42
|
+
end
|
|
43
|
+
raise errors.first if errors.any?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -15,7 +15,7 @@ module StandardId
|
|
|
15
15
|
normalized = email.to_s.strip.downcase
|
|
16
16
|
return if normalized.blank?
|
|
17
17
|
|
|
18
|
-
identifier = StandardId::EmailIdentifier.find_by(value: normalized)
|
|
18
|
+
identifier = StandardId::EmailIdentifier.includes(:account).find_by(value: normalized)
|
|
19
19
|
return if identifier.nil?
|
|
20
20
|
|
|
21
21
|
password_credential = identifier.account
|
|
@@ -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)
|
|
@@ -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
|