standard_id 0.14.4 → 0.16.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/README.md +114 -1
- data/app/controllers/concerns/standard_id/audience_verification.rb +121 -12
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +117 -36
- data/app/controllers/concerns/standard_id/social_authentication.rb +19 -0
- data/app/controllers/standard_id/api/oauth/revocations_controller.rb +55 -6
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +6 -0
- data/app/controllers/standard_id/web/reset_password/start_controller.rb +27 -1
- data/app/controllers/standard_id/web/verify_email/start_controller.rb +1 -7
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +1 -7
- data/app/forms/standard_id/web/reset_password_start_form.rb +20 -19
- data/app/jobs/standard_id/cleanup_expired_authorization_codes_job.rb +51 -0
- data/app/jobs/standard_id/cleanup_expired_code_challenges_job.rb +45 -0
- data/app/jobs/standard_id/password_reset_delivery_job.rb +42 -0
- data/app/mailers/standard_id/password_reset_mailer.rb +16 -0
- data/app/models/concerns/standard_id/account_associations.rb +43 -0
- data/app/models/standard_id/authorization_code.rb +15 -2
- data/app/models/standard_id/client_application.rb +78 -3
- data/app/models/standard_id/code_challenge.rb +6 -1
- data/app/views/standard_id/password_reset_mailer/reset_email.html.erb +27 -0
- data/app/views/standard_id/password_reset_mailer/reset_email.text.erb +12 -0
- data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +117 -0
- data/lib/generators/standard_id/install/install_generator.rb +174 -1
- data/lib/generators/standard_id/install/templates/standard_id.rb +322 -76
- data/lib/standard_id/account_locking.rb +23 -10
- data/lib/standard_id/account_status.rb +22 -8
- data/lib/standard_id/api/token_manager.rb +78 -11
- data/lib/standard_id/api_engine.rb +2 -0
- data/lib/standard_id/config/callable_validator.rb +163 -0
- data/lib/standard_id/config/schema.rb +124 -5
- data/lib/standard_id/config/scope_claims_validator.rb +47 -0
- data/lib/standard_id/engine.rb +60 -1
- data/lib/standard_id/errors.rb +41 -0
- data/lib/standard_id/events/definitions.rb +9 -3
- data/lib/standard_id/events/subscribers/password_reset_delivery_subscriber.rb +36 -0
- data/lib/standard_id/jwt_service.rb +141 -3
- data/lib/standard_id/oauth/audience_profile_resolver.rb +93 -0
- data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +10 -0
- data/lib/standard_id/oauth/authorization_flow.rb +8 -0
- data/lib/standard_id/oauth/oauth_session_persistence.rb +81 -0
- data/lib/standard_id/oauth/token_grant_flow.rb +57 -1
- data/lib/standard_id/otp.rb +250 -0
- data/lib/standard_id/passwordless/base_strategy.rb +42 -15
- data/lib/standard_id/passwordless/verification_service.rb +145 -72
- data/lib/standard_id/passwordless.rb +34 -2
- data/lib/standard_id/scope_config.rb +79 -4
- data/lib/standard_id/session_type_resolver.rb +102 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/token_manager.rb +42 -4
- data/lib/standard_id/web_engine.rb +2 -0
- data/lib/standard_id.rb +6 -1
- data/lib/tasks/standard_id_tasks.rake +56 -4
- metadata +15 -1
|
@@ -14,7 +14,10 @@ module StandardId
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def create
|
|
17
|
-
form = StandardId::Web::ResetPasswordStartForm.new(
|
|
17
|
+
form = StandardId::Web::ResetPasswordStartForm.new(
|
|
18
|
+
email: params[:email],
|
|
19
|
+
reset_url_template: build_reset_url_template
|
|
20
|
+
)
|
|
18
21
|
|
|
19
22
|
if form.submit
|
|
20
23
|
flash[:notice] = "If an account with that email exists, we've sent password reset instructions."
|
|
@@ -24,6 +27,29 @@ module StandardId
|
|
|
24
27
|
render :show, status: :unprocessable_content
|
|
25
28
|
end
|
|
26
29
|
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Build a password-reset URL template containing a literal "{token}"
|
|
34
|
+
# placeholder. The async delivery job substitutes the placeholder with
|
|
35
|
+
# the generated token once the account lookup completes. We build this
|
|
36
|
+
# here (rather than in the job) so the URL reflects the request's
|
|
37
|
+
# scheme/host/port — the job has no access to the HTTP request.
|
|
38
|
+
#
|
|
39
|
+
# The route helper may be absent if the host app mounts the engine
|
|
40
|
+
# without the `:password_reset` mechanism, or raise UrlGenerationError
|
|
41
|
+
# if required params are missing; fall back to a request-derived URL
|
|
42
|
+
# in those cases. Any other exception should surface normally.
|
|
43
|
+
def build_reset_url_template
|
|
44
|
+
base = begin
|
|
45
|
+
reset_password_confirm_url
|
|
46
|
+
rescue NameError, NoMethodError, ActionController::UrlGenerationError
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
base ||= "#{request.base_url}/reset_password/confirm"
|
|
50
|
+
separator = base.include?("?") ? "&" : "?"
|
|
51
|
+
"#{base}#{separator}token={token}"
|
|
52
|
+
end
|
|
27
53
|
end
|
|
28
54
|
end
|
|
29
55
|
end
|
|
@@ -32,7 +32,7 @@ module StandardId
|
|
|
32
32
|
realm: "verification",
|
|
33
33
|
channel: "email",
|
|
34
34
|
target: email,
|
|
35
|
-
code: generate_otp_code,
|
|
35
|
+
code: StandardId::Passwordless.generate_otp_code,
|
|
36
36
|
expires_at: 10.minutes.from_now,
|
|
37
37
|
ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
|
|
38
38
|
user_agent: request.user_agent
|
|
@@ -42,12 +42,6 @@ module StandardId
|
|
|
42
42
|
|
|
43
43
|
redirect_to standard_id_web.login_path, notice: "Verification code sent to your email", status: :see_other
|
|
44
44
|
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def generate_otp_code
|
|
49
|
-
(SecureRandom.random_number(900_000) + 100_000).to_s
|
|
50
|
-
end
|
|
51
45
|
end
|
|
52
46
|
end
|
|
53
47
|
end
|
|
@@ -32,7 +32,7 @@ module StandardId
|
|
|
32
32
|
realm: "verification",
|
|
33
33
|
channel: "sms",
|
|
34
34
|
target: phone,
|
|
35
|
-
code: generate_otp_code,
|
|
35
|
+
code: StandardId::Passwordless.generate_otp_code,
|
|
36
36
|
expires_at: 10.minutes.from_now,
|
|
37
37
|
ip_address: StandardId::Utils::IpNormalizer.normalize(request.remote_ip),
|
|
38
38
|
user_agent: request.user_agent
|
|
@@ -42,12 +42,6 @@ module StandardId
|
|
|
42
42
|
|
|
43
43
|
redirect_to standard_id_web.login_path, notice: "Verification code sent via SMS", status: :see_other
|
|
44
44
|
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def generate_otp_code
|
|
49
|
-
(SecureRandom.random_number(900_000) + 100_000).to_s
|
|
50
|
-
end
|
|
51
45
|
end
|
|
52
46
|
end
|
|
53
47
|
end
|
|
@@ -6,32 +6,33 @@ module StandardId
|
|
|
6
6
|
|
|
7
7
|
attribute :email, :string
|
|
8
8
|
|
|
9
|
-
attr_reader :password_credential, :token
|
|
10
|
-
|
|
11
9
|
validates :email, presence: { message: "Please enter your email address" }, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
# Constructor accepts the reset URL template so the form is decoupled
|
|
12
|
+
# from routing. The controller builds a URL from `reset_password_confirm_url`
|
|
13
|
+
# (or a request-derived fallback) and appends a literal `?token={token}` (or
|
|
14
|
+
# `&token={token}`) marker via string concatenation. The delivery job
|
|
15
|
+
# substitutes that placeholder with the actual token after account lookup.
|
|
16
|
+
def initialize(attributes = {})
|
|
17
|
+
@reset_url_template = attributes.delete(:reset_url_template) if attributes.is_a?(Hash)
|
|
18
|
+
super
|
|
21
19
|
end
|
|
22
20
|
|
|
23
|
-
|
|
24
|
-
@password_credential ||= identifier&.account&.credentials&.where(credentialable_type: "StandardId::PasswordCredential")&.sole&.credentialable
|
|
25
|
-
end
|
|
21
|
+
attr_reader :reset_url_template
|
|
26
22
|
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
-
end
|
|
23
|
+
def submit
|
|
24
|
+
return false unless valid?
|
|
30
25
|
|
|
31
|
-
|
|
26
|
+
# Enqueue the full lookup + token generation + mailer delivery pipeline
|
|
27
|
+
# so the controller response time does not depend on whether an account
|
|
28
|
+
# exists for the submitted email. This closes the user-enumeration
|
|
29
|
+
# timing side channel.
|
|
30
|
+
StandardId::PasswordResetDeliveryJob.perform_later(
|
|
31
|
+
email: email.to_s,
|
|
32
|
+
reset_url_template: reset_url_template.to_s
|
|
33
|
+
)
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
@identifier ||= StandardId::EmailIdentifier.find_by(value: email.to_s.strip.downcase)
|
|
35
|
+
true
|
|
35
36
|
end
|
|
36
37
|
end
|
|
37
38
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
class CleanupExpiredAuthorizationCodesJob < ApplicationJob
|
|
3
|
+
queue_as :default
|
|
4
|
+
|
|
5
|
+
# Delete OAuth authorization codes that are either expired or consumed
|
|
6
|
+
# beyond the respective grace periods.
|
|
7
|
+
#
|
|
8
|
+
# Authorization codes are single-use and short-lived (OAuth 2.1 recommends
|
|
9
|
+
# a lifetime under 10 minutes). Two grace windows apply:
|
|
10
|
+
#
|
|
11
|
+
# - `grace_period_seconds` (default 7 days): how long expired-but-unused
|
|
12
|
+
# codes are retained after `expires_at`. Matches the sessions/refresh-
|
|
13
|
+
# token cleanup windows so operators only have to reason about one
|
|
14
|
+
# default.
|
|
15
|
+
# - `consumed_grace_period_seconds` (default 1 day): how long consumed
|
|
16
|
+
# codes are retained after `consumed_at`. Used codes are useless after
|
|
17
|
+
# redemption, so they're pruned faster — keeping them briefly only
|
|
18
|
+
# helps with replay-attack forensics in the immediate aftermath of a
|
|
19
|
+
# redemption.
|
|
20
|
+
#
|
|
21
|
+
# Accepts integer seconds for reliable ActiveJob serialization across all
|
|
22
|
+
# queue adapters.
|
|
23
|
+
def perform(grace_period_seconds: 7.days.to_i, consumed_grace_period_seconds: 1.day.to_i)
|
|
24
|
+
expired_cutoff = grace_period_seconds.seconds.ago
|
|
25
|
+
consumed_cutoff = consumed_grace_period_seconds.seconds.ago
|
|
26
|
+
|
|
27
|
+
# The two windows govern disjoint sets of rows:
|
|
28
|
+
# - Unconsumed codes: ruled by `expires_at < expired_cutoff`.
|
|
29
|
+
# - Consumed codes: ruled by `consumed_at < consumed_cutoff`.
|
|
30
|
+
#
|
|
31
|
+
# A naive `expires_at < :expired_cutoff OR consumed_at < :consumed_cutoff`
|
|
32
|
+
# would let the expired arm delete a consumed-12-hours-ago row whose
|
|
33
|
+
# `expires_at` sits 8 days in the past — defeating the consumed grace
|
|
34
|
+
# window's whole purpose. Scoping the expired clause to `consumed_at IS
|
|
35
|
+
# NULL` keeps consumed rows under the consumed window exclusively.
|
|
36
|
+
deleted = StandardId::AuthorizationCode
|
|
37
|
+
.where(
|
|
38
|
+
"(expires_at < :expired_cutoff AND consumed_at IS NULL) " \
|
|
39
|
+
"OR consumed_at < :consumed_cutoff",
|
|
40
|
+
expired_cutoff: expired_cutoff,
|
|
41
|
+
consumed_cutoff: consumed_cutoff
|
|
42
|
+
)
|
|
43
|
+
.delete_all
|
|
44
|
+
|
|
45
|
+
Rails.logger.info(
|
|
46
|
+
"[StandardId] Cleaned up #{deleted} authorization codes " \
|
|
47
|
+
"(expired before #{expired_cutoff}, consumed before #{consumed_cutoff})"
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
class CleanupExpiredCodeChallengesJob < ApplicationJob
|
|
3
|
+
queue_as :default
|
|
4
|
+
|
|
5
|
+
# Delete code challenges (OTP records) that are either expired or used
|
|
6
|
+
# beyond the respective grace periods.
|
|
7
|
+
#
|
|
8
|
+
# Code challenges back passwordless/OTP flows: rows are short-lived and
|
|
9
|
+
# single-use. Two grace windows apply:
|
|
10
|
+
#
|
|
11
|
+
# - `grace_period_seconds` (default 7 days): how long expired-but-unused
|
|
12
|
+
# challenges are retained after `expires_at`. Matches the sessions/
|
|
13
|
+
# refresh-token cleanup windows for operational consistency.
|
|
14
|
+
# - `used_grace_period_seconds` (default 1 day): how long used challenges
|
|
15
|
+
# are retained after `used_at`. A used OTP is useless after redemption,
|
|
16
|
+
# so prune faster — the short tail only helps with replay-attack
|
|
17
|
+
# forensics immediately after use.
|
|
18
|
+
#
|
|
19
|
+
# Accepts integer seconds for reliable ActiveJob serialization across all
|
|
20
|
+
# queue adapters.
|
|
21
|
+
def perform(grace_period_seconds: 7.days.to_i, used_grace_period_seconds: 1.day.to_i)
|
|
22
|
+
expired_cutoff = grace_period_seconds.seconds.ago
|
|
23
|
+
used_cutoff = used_grace_period_seconds.seconds.ago
|
|
24
|
+
|
|
25
|
+
# See CleanupExpiredAuthorizationCodesJob for the rationale — the two
|
|
26
|
+
# windows govern disjoint sets of rows and a naive OR lets the expired
|
|
27
|
+
# arm prune recently-used challenges out from under the used grace
|
|
28
|
+
# window. Unused challenges follow `expires_at`; used challenges follow
|
|
29
|
+
# `used_at`.
|
|
30
|
+
deleted = StandardId::CodeChallenge
|
|
31
|
+
.where(
|
|
32
|
+
"(expires_at < :expired_cutoff AND used_at IS NULL) " \
|
|
33
|
+
"OR used_at < :used_cutoff",
|
|
34
|
+
expired_cutoff: expired_cutoff,
|
|
35
|
+
used_cutoff: used_cutoff
|
|
36
|
+
)
|
|
37
|
+
.delete_all
|
|
38
|
+
|
|
39
|
+
Rails.logger.info(
|
|
40
|
+
"[StandardId] Cleaned up #{deleted} code challenges " \
|
|
41
|
+
"(expired before #{expired_cutoff}, used before #{used_cutoff})"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
# Handles the full password-reset email delivery pipeline asynchronously.
|
|
3
|
+
#
|
|
4
|
+
# Running this work in a job (rather than inline in the request) eliminates
|
|
5
|
+
# the timing side-channel that would otherwise leak whether an account
|
|
6
|
+
# exists for a given email: every request enqueues the same job, so the
|
|
7
|
+
# synchronous request path is constant-time regardless of account state.
|
|
8
|
+
class PasswordResetDeliveryJob < ApplicationJob
|
|
9
|
+
queue_as :default
|
|
10
|
+
|
|
11
|
+
# @param email [String] the raw email submitted by the user
|
|
12
|
+
# @param reset_url_template [String] URL with a literal "{token}" placeholder
|
|
13
|
+
# that the job will substitute with the generated reset token
|
|
14
|
+
def perform(email:, reset_url_template:)
|
|
15
|
+
normalized = email.to_s.strip.downcase
|
|
16
|
+
return if normalized.blank?
|
|
17
|
+
|
|
18
|
+
identifier = StandardId::EmailIdentifier.find_by(value: normalized)
|
|
19
|
+
return if identifier.nil?
|
|
20
|
+
|
|
21
|
+
password_credential = identifier.account
|
|
22
|
+
&.credentials
|
|
23
|
+
&.where(credentialable_type: "StandardId::PasswordCredential")
|
|
24
|
+
&.first
|
|
25
|
+
&.credentialable
|
|
26
|
+
return if password_credential.nil?
|
|
27
|
+
|
|
28
|
+
token = password_credential.generate_token_for(:password_reset)
|
|
29
|
+
return if token.blank?
|
|
30
|
+
|
|
31
|
+
reset_url = reset_url_template.to_s.sub("{token}", token)
|
|
32
|
+
|
|
33
|
+
StandardId::Events.publish(
|
|
34
|
+
StandardId::Events::CREDENTIAL_PASSWORD_RESET_INITIATED,
|
|
35
|
+
account: identifier.account,
|
|
36
|
+
identifier: normalized,
|
|
37
|
+
token: token,
|
|
38
|
+
reset_url: reset_url
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
class PasswordResetMailer < ApplicationMailer
|
|
3
|
+
layout false
|
|
4
|
+
|
|
5
|
+
def reset_email
|
|
6
|
+
@reset_url = params[:reset_url]
|
|
7
|
+
@email = params[:email]
|
|
8
|
+
|
|
9
|
+
mail(
|
|
10
|
+
to: @email,
|
|
11
|
+
from: StandardId.config.reset_password.mailer_from,
|
|
12
|
+
subject: StandardId.config.reset_password.mailer_subject
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -12,6 +12,31 @@ module StandardId
|
|
|
12
12
|
accepts_nested_attributes_for :identifiers
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# Returns the account's StandardId::EmailIdentifier, if any.
|
|
16
|
+
#
|
|
17
|
+
# Uses the in-memory collection when identifiers is already loaded to
|
|
18
|
+
# avoid issuing an extra query (N+1 safety). Falls back to a scoped
|
|
19
|
+
# query otherwise.
|
|
20
|
+
#
|
|
21
|
+
# @return [StandardId::EmailIdentifier, nil]
|
|
22
|
+
def email_identifier
|
|
23
|
+
typed_identifier(StandardId::EmailIdentifier)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns the account's StandardId::PhoneNumberIdentifier, if any.
|
|
27
|
+
#
|
|
28
|
+
# @return [StandardId::PhoneNumberIdentifier, nil]
|
|
29
|
+
def phone_number_identifier
|
|
30
|
+
typed_identifier(StandardId::PhoneNumberIdentifier)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the account's StandardId::UsernameIdentifier, if any.
|
|
34
|
+
#
|
|
35
|
+
# @return [StandardId::UsernameIdentifier, nil]
|
|
36
|
+
def username_identifier
|
|
37
|
+
typed_identifier(StandardId::UsernameIdentifier)
|
|
38
|
+
end
|
|
39
|
+
|
|
15
40
|
class_methods do
|
|
16
41
|
def find_or_create_by_verified_email!(email, **account_attributes)
|
|
17
42
|
raise ArgumentError, "email is required" if email.blank?
|
|
@@ -54,5 +79,23 @@ module StandardId
|
|
|
54
79
|
identifier.account
|
|
55
80
|
end
|
|
56
81
|
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Fetch the first identifier of the given STI subclass.
|
|
86
|
+
#
|
|
87
|
+
# When the identifiers association is already loaded, filters in memory
|
|
88
|
+
# to avoid triggering an additional query. Otherwise issues a scoped
|
|
89
|
+
# query that returns at most one row.
|
|
90
|
+
#
|
|
91
|
+
# @param klass [Class] subclass of StandardId::Identifier
|
|
92
|
+
# @return [StandardId::Identifier, nil]
|
|
93
|
+
def typed_identifier(klass)
|
|
94
|
+
if association(:identifiers).loaded?
|
|
95
|
+
identifiers.detect { |i| i.is_a?(klass) }
|
|
96
|
+
else
|
|
97
|
+
identifiers.where(type: klass.sti_name).first
|
|
98
|
+
end
|
|
99
|
+
end
|
|
57
100
|
end
|
|
58
101
|
end
|
|
@@ -18,9 +18,22 @@ module StandardId
|
|
|
18
18
|
|
|
19
19
|
def self.issue!(plaintext_code:, client_id:, redirect_uri:, scope: nil, audience: nil, account: nil, code_challenge: nil, code_challenge_method: nil, nonce: nil, metadata: {})
|
|
20
20
|
# Fail fast: reject unsupported PKCE methods at issuance rather than
|
|
21
|
-
# storing a code that will always fail at redemption time.
|
|
21
|
+
# storing a code that will always fail at redemption time. When the
|
|
22
|
+
# client record has PKCE required, defer to the client's configured
|
|
23
|
+
# `code_challenge_methods`. In all other cases (client lookup missing,
|
|
24
|
+
# or client opted out of PKCE but still sent a challenge) fall back
|
|
25
|
+
# to an S256-only belt-and-suspenders check so we never silently
|
|
26
|
+
# accept a weaker method.
|
|
22
27
|
if code_challenge.present?
|
|
23
|
-
|
|
28
|
+
client = StandardId::ClientApplication.find_by(client_id: client_id)
|
|
29
|
+
method_supported =
|
|
30
|
+
if client&.require_pkce?
|
|
31
|
+
client.supports_pkce_method?(code_challenge_method)
|
|
32
|
+
else
|
|
33
|
+
code_challenge_method.to_s.downcase == "s256"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
unless method_supported
|
|
24
37
|
raise StandardId::InvalidRequestError, "Unsupported code_challenge_method: only S256 is allowed"
|
|
25
38
|
end
|
|
26
39
|
end
|
|
@@ -12,6 +12,7 @@ module StandardId
|
|
|
12
12
|
validates :name, presence: true, length: { maximum: 255 }
|
|
13
13
|
validates :description, length: { maximum: 1000 }
|
|
14
14
|
validates :redirect_uris, presence: true
|
|
15
|
+
validate :redirect_uris_must_be_absolute_without_query_or_fragment
|
|
15
16
|
validates :client_type, inclusion: { in: %w[confidential public] }
|
|
16
17
|
validates :grant_types, presence: true
|
|
17
18
|
validates :response_types, presence: true
|
|
@@ -22,6 +23,11 @@ module StandardId
|
|
|
22
23
|
validates :access_token_lifetime, :refresh_token_lifetime, :authorization_code_lifetime,
|
|
23
24
|
presence: true, numericality: { greater_than: 0 }
|
|
24
25
|
|
|
26
|
+
# Security: public clients cannot opt out of PKCE. Public clients run in
|
|
27
|
+
# environments where a client secret cannot be kept confidential, so PKCE
|
|
28
|
+
# is the only protection against authorization code interception.
|
|
29
|
+
validate :public_clients_must_require_pkce
|
|
30
|
+
|
|
25
31
|
# Scopes
|
|
26
32
|
scope :active, -> { where(active: true) }
|
|
27
33
|
scope :confidential, -> { where(client_type: "confidential") }
|
|
@@ -75,11 +81,50 @@ module StandardId
|
|
|
75
81
|
|
|
76
82
|
def supports_pkce_method?(method)
|
|
77
83
|
return false unless require_pkce?
|
|
78
|
-
|
|
84
|
+
normalized = method.to_s.downcase
|
|
85
|
+
code_challenge_methods_array.any? { |m| m.downcase == normalized }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Validates a redirect_uri presented in an OAuth request against this
|
|
89
|
+
# client's registered URIs.
|
|
90
|
+
#
|
|
91
|
+
# OAuth 2.0 (RFC 6749 §3.1.2) requires the authorization server to compare
|
|
92
|
+
# the registered redirect URI and the request redirect URI using simple
|
|
93
|
+
# string comparison, with the exception that the authorization server may
|
|
94
|
+
# redirect with additional query parameters. We implement a stricter
|
|
95
|
+
# scheme+host+port+path match: the *request* URI may add query or fragment
|
|
96
|
+
# segments, but the scheme, host, port, and path must exactly match a
|
|
97
|
+
# registered URI. This prevents a class of "query-string piggyback" attacks
|
|
98
|
+
# where a registered callback at /cb is abused with a crafted query string
|
|
99
|
+
# (or, worse, a different path segment like /cb/evil).
|
|
100
|
+
#
|
|
101
|
+
# Subdomain wildcards are NOT supported — host must match exactly.
|
|
102
|
+
def valid_redirect_uri?(uri)
|
|
103
|
+
requested = self.class.parse_redirect_uri(uri)
|
|
104
|
+
return false unless requested
|
|
105
|
+
|
|
106
|
+
redirect_uris_array.any? do |registered_uri|
|
|
107
|
+
registered = self.class.parse_redirect_uri(registered_uri)
|
|
108
|
+
next false unless registered
|
|
109
|
+
|
|
110
|
+
registered.scheme == requested.scheme &&
|
|
111
|
+
registered.host == requested.host &&
|
|
112
|
+
registered.port == requested.port &&
|
|
113
|
+
registered.path == requested.path
|
|
114
|
+
end
|
|
79
115
|
end
|
|
80
116
|
|
|
81
|
-
|
|
82
|
-
|
|
117
|
+
# Parse a redirect URI string into a URI object suitable for comparison.
|
|
118
|
+
# Returns nil for unparseable, relative, or scheme-less URIs.
|
|
119
|
+
def self.parse_redirect_uri(value)
|
|
120
|
+
return nil if value.to_s.strip.empty?
|
|
121
|
+
|
|
122
|
+
parsed = URI.parse(value.to_s.strip)
|
|
123
|
+
return nil if parsed.scheme.blank? || parsed.host.blank?
|
|
124
|
+
|
|
125
|
+
parsed
|
|
126
|
+
rescue URI::InvalidURIError
|
|
127
|
+
nil
|
|
83
128
|
end
|
|
84
129
|
|
|
85
130
|
def confidential?
|
|
@@ -127,6 +172,36 @@ module StandardId
|
|
|
127
172
|
|
|
128
173
|
private
|
|
129
174
|
|
|
175
|
+
# Registered redirect URIs must be absolute (include scheme + host) and
|
|
176
|
+
# must NOT carry a query string or fragment. Allowing either would turn
|
|
177
|
+
# the whitelist into a prefix match and enable "query-param piggyback"
|
|
178
|
+
# attacks where a registered callback is reused with attacker-controlled
|
|
179
|
+
# parameters.
|
|
180
|
+
def redirect_uris_must_be_absolute_without_query_or_fragment
|
|
181
|
+
redirect_uris_array.each do |value|
|
|
182
|
+
parsed = self.class.parse_redirect_uri(value)
|
|
183
|
+
if parsed.nil?
|
|
184
|
+
errors.add(:redirect_uris, "contains an invalid URI (#{value.inspect}). Redirect URIs must be absolute (scheme + host)")
|
|
185
|
+
next
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
if parsed.query.present?
|
|
189
|
+
errors.add(:redirect_uris, "must not contain a query string (#{value.inspect}). Register the base URI only; OAuth adds query params at runtime")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
if parsed.fragment.present?
|
|
193
|
+
errors.add(:redirect_uris, "must not contain a fragment (#{value.inspect})")
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def public_clients_must_require_pkce
|
|
199
|
+
return unless client_type == "public"
|
|
200
|
+
return if require_pkce?
|
|
201
|
+
|
|
202
|
+
errors.add(:require_pkce, "public clients must have require_pkce enabled")
|
|
203
|
+
end
|
|
204
|
+
|
|
130
205
|
def generate_client_id
|
|
131
206
|
self.client_id ||= SecureRandom.hex(16)
|
|
132
207
|
end
|
|
@@ -2,10 +2,15 @@ module StandardId
|
|
|
2
2
|
class CodeChallenge < ApplicationRecord
|
|
3
3
|
self.table_name = "standard_id_code_challenges"
|
|
4
4
|
|
|
5
|
+
# Well-known realms used by the engine itself. Host apps may create
|
|
6
|
+
# challenges in any realm (see StandardId::Otp) — realm is a free-form
|
|
7
|
+
# string that partitions challenges by purpose (e.g. "authentication",
|
|
8
|
+
# "verification", "custom_widget"). Only presence is validated so
|
|
9
|
+
# consumers can define their own realms without the engine knowing.
|
|
5
10
|
REALMS = %w[authentication verification].freeze
|
|
6
11
|
CHANNELS = %w[email sms].freeze
|
|
7
12
|
|
|
8
|
-
validates :realm, presence: true
|
|
13
|
+
validates :realm, presence: true
|
|
9
14
|
validates :channel, presence: true, inclusion: { in: CHANNELS }
|
|
10
15
|
validates :target, presence: true
|
|
11
16
|
validates :code, presence: true
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<style>
|
|
6
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5; margin: 0; padding: 0; }
|
|
7
|
+
.container { max-width: 480px; margin: 40px auto; background-color: #ffffff; border-radius: 8px; padding: 40px; }
|
|
8
|
+
.button { display: inline-block; padding: 12px 24px; background-color: #111111; color: #ffffff !important; text-decoration: none; border-radius: 6px; font-weight: 600; }
|
|
9
|
+
.footer { margin-top: 32px; font-size: 13px; color: #888888; text-align: center; }
|
|
10
|
+
.fallback { margin-top: 16px; font-size: 13px; color: #555555; word-break: break-all; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div class="container">
|
|
15
|
+
<p>Hi,</p>
|
|
16
|
+
<p>We received a request to reset the password for the account associated with this email address. Click the button below to choose a new password:</p>
|
|
17
|
+
<p style="text-align: center; margin: 24px 0;">
|
|
18
|
+
<a class="button" href="<%= @reset_url %>">Reset your password</a>
|
|
19
|
+
</p>
|
|
20
|
+
<p class="fallback">If the button doesn't work, paste this link into your browser: <%= @reset_url %></p>
|
|
21
|
+
<p>If you did not request a password reset, you can safely ignore this email.</p>
|
|
22
|
+
<div class="footer">
|
|
23
|
+
<p>This is an automated message. Please do not reply.</p>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Hi,
|
|
2
|
+
|
|
3
|
+
We received a request to reset the password for the account associated with this email address.
|
|
4
|
+
|
|
5
|
+
Use the link below to choose a new password:
|
|
6
|
+
|
|
7
|
+
<%= @reset_url %>
|
|
8
|
+
|
|
9
|
+
If you did not request a password reset, you can safely ignore this email.
|
|
10
|
+
|
|
11
|
+
--
|
|
12
|
+
This is an automated message. Please do not reply.
|
data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
class AddPartialIndexesForActiveSessionAndChallengeLookups < ActiveRecord::Migration[8.0]
|
|
2
|
+
# Run indexes CONCURRENTLY on Postgres so we don't take a long write lock on
|
|
3
|
+
# busy tables. SQLite (dummy app, some host setups) ignores algorithm:; we
|
|
4
|
+
# only pass :concurrently when the adapter is Postgres.
|
|
5
|
+
#
|
|
6
|
+
# StrongMigrations note: every change here is additive and non-destructive
|
|
7
|
+
# except for the GIN drop in step 3. We guard that drop with an `if_exists`
|
|
8
|
+
# check so StrongMigrations/host apps that never ran the creating migration
|
|
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.
|
|
12
|
+
#
|
|
13
|
+
# Split into def up / def down because `remove_index :table, name: "..."`
|
|
14
|
+
# (name-only, no column list) is not auto-reversible via def change — Rails
|
|
15
|
+
# raises ActiveRecord::IrreversibleMigration on rollback. The explicit down
|
|
16
|
+
# path re-adds the GIN index using the same column + opclass the creating
|
|
17
|
+
# migration used (t.index :metadata, using: :gin).
|
|
18
|
+
disable_ddl_transaction!
|
|
19
|
+
|
|
20
|
+
def up
|
|
21
|
+
pg = connection.adapter_name.downcase.include?("postgres")
|
|
22
|
+
concurrent = pg ? { algorithm: :concurrently } : {}
|
|
23
|
+
|
|
24
|
+
# ── D3: Partial indexes on hot "active session" lookups ────────────────
|
|
25
|
+
# The existing [:expires_at, :revoked_at] indexes include revoked rows.
|
|
26
|
+
# Most of our hot paths (SessionManager#find_active, cleanup jobs, the
|
|
27
|
+
# revocation controller) only care about revoked_at IS NULL rows, and a
|
|
28
|
+
# partial index on expires_at WHERE revoked_at IS NULL is both smaller
|
|
29
|
+
# and lets Postgres short-circuit the revoked_at check in the plan.
|
|
30
|
+
add_index :standard_id_sessions,
|
|
31
|
+
:expires_at,
|
|
32
|
+
where: "revoked_at IS NULL",
|
|
33
|
+
name: "index_standard_id_sessions_on_expires_at_where_active",
|
|
34
|
+
if_not_exists: true,
|
|
35
|
+
**concurrent
|
|
36
|
+
|
|
37
|
+
add_index :standard_id_refresh_tokens,
|
|
38
|
+
:expires_at,
|
|
39
|
+
where: "revoked_at IS NULL",
|
|
40
|
+
name: "index_standard_id_refresh_tokens_on_expires_at_where_active",
|
|
41
|
+
if_not_exists: true,
|
|
42
|
+
**concurrent
|
|
43
|
+
|
|
44
|
+
# ── D4: code_challenges index rework ───────────────────────────────────
|
|
45
|
+
# The hot-path consumer is Passwordless::VerificationService#find_active_challenge:
|
|
46
|
+
#
|
|
47
|
+
# CodeChallenge.active
|
|
48
|
+
# .where(realm:, channel:, target:)
|
|
49
|
+
# .order(created_at: :desc).first
|
|
50
|
+
#
|
|
51
|
+
# where `active` = used_at IS NULL AND expires_at > NOW(). We can't put
|
|
52
|
+
# `expires_at > NOW()` into a partial index predicate (NOW() isn't
|
|
53
|
+
# immutable), but `used_at IS NULL` is safe and eliminates consumed OTPs.
|
|
54
|
+
#
|
|
55
|
+
# The existing [:realm, :channel, :target, :created_at] index works but
|
|
56
|
+
# covers every row, including long-since-consumed ones. A partial variant
|
|
57
|
+
# 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
|
|
64
|
+
|
|
65
|
+
# Drop the GIN metadata index on Postgres: metadata is only written to
|
|
66
|
+
# (record_failed_attempt bumps `attempts`), never queried with containment
|
|
67
|
+
# (@>, ?, ?|, ?&) operators, so the GIN index is pure write overhead.
|
|
68
|
+
# SQLite never had a GIN index to drop.
|
|
69
|
+
if pg
|
|
70
|
+
remove_index :standard_id_code_challenges,
|
|
71
|
+
name: "index_standard_id_code_challenges_on_metadata",
|
|
72
|
+
if_exists: true,
|
|
73
|
+
**concurrent
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# ── D5: skipped intentionally ──────────────────────────────────────────
|
|
77
|
+
# standard_id_credentials already has a composite
|
|
78
|
+
# (credentialable_type, credentialable_id) index via `t.references
|
|
79
|
+
# :credentialable, polymorphic: true, index: true`. A search of the
|
|
80
|
+
# codebase shows no queries filtering by credentialable_id alone (callers
|
|
81
|
+
# always know the credentialable_type because Credential uses
|
|
82
|
+
# delegated_type). Adding a single-column credentialable_id index would
|
|
83
|
+
# only add write overhead with no matching read pattern, so we skip it.
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def down
|
|
87
|
+
pg = connection.adapter_name.downcase.include?("postgres")
|
|
88
|
+
concurrent = pg ? { algorithm: :concurrently } : {}
|
|
89
|
+
|
|
90
|
+
# Re-create the GIN metadata index first (mirrors the creating migration:
|
|
91
|
+
# `t.index :metadata, using: :gin` on standard_id_code_challenges).
|
|
92
|
+
# SQLite never had a GIN index, so this is Postgres-only.
|
|
93
|
+
if pg
|
|
94
|
+
add_index :standard_id_code_challenges,
|
|
95
|
+
:metadata,
|
|
96
|
+
using: :gin,
|
|
97
|
+
name: "index_standard_id_code_challenges_on_metadata",
|
|
98
|
+
if_not_exists: true,
|
|
99
|
+
**concurrent
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
remove_index :standard_id_code_challenges,
|
|
103
|
+
name: "index_code_challenges_on_active_target_created_at",
|
|
104
|
+
if_exists: true,
|
|
105
|
+
**concurrent
|
|
106
|
+
|
|
107
|
+
remove_index :standard_id_refresh_tokens,
|
|
108
|
+
name: "index_standard_id_refresh_tokens_on_expires_at_where_active",
|
|
109
|
+
if_exists: true,
|
|
110
|
+
**concurrent
|
|
111
|
+
|
|
112
|
+
remove_index :standard_id_sessions,
|
|
113
|
+
name: "index_standard_id_sessions_on_expires_at_where_active",
|
|
114
|
+
if_exists: true,
|
|
115
|
+
**concurrent
|
|
116
|
+
end
|
|
117
|
+
end
|