standard_id 0.8.0 → 0.9.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/app/controllers/concerns/standard_id/social_authentication.rb +42 -1
- data/app/controllers/concerns/standard_id/web_authentication.rb +7 -1
- data/app/forms/standard_id/web/reset_password_confirm_form.rb +1 -1
- data/app/forms/standard_id/web/signup_form.rb +2 -1
- data/app/jobs/standard_id/cleanup_expired_refresh_tokens_job.rb +16 -0
- data/app/models/concerns/standard_id/account_associations.rb +1 -0
- data/app/models/concerns/standard_id/password_strength.rb +31 -0
- data/app/models/standard_id/authorization_code.rb +29 -10
- data/app/models/standard_id/password_credential.rb +2 -1
- data/app/models/standard_id/refresh_token.rb +81 -0
- data/app/models/standard_id/session.rb +15 -1
- data/db/migrate/20260311000000_add_provider_to_standard_id_identifiers.rb +15 -0
- data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +20 -0
- data/db/migrate/20260311100001_add_nullify_to_refresh_token_previous_token_fk.rb +7 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +1 -0
- data/lib/standard_id/config/schema.rb +4 -3
- data/lib/standard_id/engine.rb +14 -0
- data/lib/standard_id/errors.rb +18 -0
- data/lib/standard_id/events/definitions.rb +8 -2
- data/lib/standard_id/http_client.rb +54 -8
- data/lib/standard_id/jwt_service.rb +6 -2
- data/lib/standard_id/oauth/refresh_token_flow.rb +96 -0
- data/lib/standard_id/oauth/token_grant_flow.rb +31 -2
- data/lib/standard_id/testing/factories/credentials.rb +2 -2
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/session_manager.rb +13 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c621a201633467c0738625eacb1f0c59c0b8ee5b670903cb09b1a1a4f6bfd01
|
|
4
|
+
data.tar.gz: 2b4b97e69b5b8dba07f1df919fd3d3b5ef4dda4fa6b72ae54c317cfb96bb1320
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 22613e053bfa27512b39c38af4be699686ed8ef83b296efbbd17b131e60f12923fc6efdb8b572833849807c3928fb8cecbaee4d72e1e6da60e0b37b606555a3b
|
|
7
|
+
data.tar.gz: 36e22ae1dca29a4350350c079eb7f1b4166c341f3d435bcdd666d7603b8f479de0dbee957b75fd807a42f7deeaf272d69badf6fe61d978151dcebb1f48c92401
|
|
@@ -6,6 +6,8 @@ module StandardId
|
|
|
6
6
|
prepend_before_action :prepare_provider
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
+
VALID_LINK_STRATEGIES = %i[strict trust_provider].freeze
|
|
10
|
+
|
|
9
11
|
private
|
|
10
12
|
|
|
11
13
|
attr_reader :provider
|
|
@@ -39,13 +41,16 @@ module StandardId
|
|
|
39
41
|
identifier = StandardId::EmailIdentifier.find_by(value: email)
|
|
40
42
|
|
|
41
43
|
if identifier.present?
|
|
44
|
+
validate_social_link!(identifier, provider)
|
|
45
|
+
identifier.update!(provider: provider.provider_name) if identifier.provider.nil?
|
|
42
46
|
emit_social_account_linked(identifier.account, provider, identifier)
|
|
43
47
|
identifier.account
|
|
44
48
|
else
|
|
45
49
|
account = build_account_from_social(social_info)
|
|
46
50
|
identifier = StandardId::EmailIdentifier.create!(
|
|
47
51
|
account: account,
|
|
48
|
-
value: email
|
|
52
|
+
value: email,
|
|
53
|
+
provider: provider.provider_name
|
|
49
54
|
)
|
|
50
55
|
identifier.verify! if identifier.respond_to?(:verify!) && [true, "true"].include?(social_info[:email_verified])
|
|
51
56
|
emit_social_account_created(account, provider, social_info)
|
|
@@ -53,6 +58,32 @@ module StandardId
|
|
|
53
58
|
end
|
|
54
59
|
end
|
|
55
60
|
|
|
61
|
+
def validate_social_link!(identifier, provider)
|
|
62
|
+
strategy = StandardId.config.social.link_strategy
|
|
63
|
+
|
|
64
|
+
unless VALID_LINK_STRATEGIES.include?(strategy)
|
|
65
|
+
raise ArgumentError, "Invalid social.link_strategy: #{strategy.inspect}. " \
|
|
66
|
+
"Must be one of: #{VALID_LINK_STRATEGIES.map(&:inspect).join(', ')}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return if strategy == :trust_provider
|
|
70
|
+
# nil provider means the identifier predates provider tracking — allow
|
|
71
|
+
# through since we can't retroactively determine its origin.
|
|
72
|
+
return if identifier.provider.nil?
|
|
73
|
+
return if identifier.provider == provider.provider_name
|
|
74
|
+
return if account_has_social_identifier_from?(identifier.account, provider)
|
|
75
|
+
|
|
76
|
+
emit_social_link_blocked(identifier, provider)
|
|
77
|
+
raise StandardId::SocialLinkError.new(
|
|
78
|
+
email: identifier.value,
|
|
79
|
+
provider_name: provider.provider_name
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def account_has_social_identifier_from?(account, provider)
|
|
84
|
+
account.identifiers.where(type: StandardId::EmailIdentifier.sti_name, provider: provider.provider_name).exists?
|
|
85
|
+
end
|
|
86
|
+
|
|
56
87
|
def build_account_from_social(social_info)
|
|
57
88
|
emit_account_creating_from_social(social_info)
|
|
58
89
|
attrs = resolve_account_attributes(social_info)
|
|
@@ -123,6 +154,16 @@ module StandardId
|
|
|
123
154
|
)
|
|
124
155
|
end
|
|
125
156
|
|
|
157
|
+
def emit_social_link_blocked(identifier, provider)
|
|
158
|
+
StandardId::Events.publish(
|
|
159
|
+
StandardId::Events::SOCIAL_LINK_BLOCKED,
|
|
160
|
+
email: identifier.value,
|
|
161
|
+
provider: provider,
|
|
162
|
+
identifier: identifier,
|
|
163
|
+
account: identifier.account
|
|
164
|
+
)
|
|
165
|
+
end
|
|
166
|
+
|
|
126
167
|
def emit_social_account_linked(account, provider, identifier)
|
|
127
168
|
StandardId::Events.publish(
|
|
128
169
|
StandardId::Events::SOCIAL_ACCOUNT_LINKED,
|
|
@@ -107,7 +107,13 @@ module StandardId
|
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
def session_manager
|
|
110
|
-
@session_manager ||= StandardId::Web::SessionManager.new(
|
|
110
|
+
@session_manager ||= StandardId::Web::SessionManager.new(
|
|
111
|
+
token_manager,
|
|
112
|
+
request: request,
|
|
113
|
+
session: session,
|
|
114
|
+
cookies: cookies,
|
|
115
|
+
reset_session: -> { reset_session }
|
|
116
|
+
)
|
|
111
117
|
end
|
|
112
118
|
|
|
113
119
|
def token_manager
|
|
@@ -3,6 +3,7 @@ module StandardId
|
|
|
3
3
|
class ResetPasswordConfirmForm
|
|
4
4
|
include ActiveModel::Model
|
|
5
5
|
include ActiveModel::Attributes
|
|
6
|
+
include StandardId::PasswordStrength
|
|
6
7
|
|
|
7
8
|
attribute :password, :string
|
|
8
9
|
attribute :password_confirmation, :string
|
|
@@ -11,7 +12,6 @@ module StandardId
|
|
|
11
12
|
|
|
12
13
|
validates :password,
|
|
13
14
|
presence: { message: "cannot be blank" },
|
|
14
|
-
length: { minimum: 8, too_short: "must be at least 8 characters long" },
|
|
15
15
|
confirmation: { message: "confirmation doesn't match" }
|
|
16
16
|
|
|
17
17
|
def initialize(password_credential, params = {})
|
|
@@ -3,6 +3,7 @@ module StandardId
|
|
|
3
3
|
class SignupForm
|
|
4
4
|
include ActiveModel::Model
|
|
5
5
|
include ActiveModel::Attributes
|
|
6
|
+
include StandardId::PasswordStrength
|
|
6
7
|
|
|
7
8
|
attribute :email, :string
|
|
8
9
|
attribute :password, :string
|
|
@@ -11,7 +12,7 @@ module StandardId
|
|
|
11
12
|
attr_reader :account
|
|
12
13
|
|
|
13
14
|
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
14
|
-
validates :password, presence: true,
|
|
15
|
+
validates :password, presence: true, confirmation: true
|
|
15
16
|
|
|
16
17
|
def submit
|
|
17
18
|
return false unless valid?
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
class CleanupExpiredRefreshTokensJob < ApplicationJob
|
|
3
|
+
queue_as :default
|
|
4
|
+
|
|
5
|
+
# Delete refresh tokens that expired or were revoked more than
|
|
6
|
+
# `grace_period_seconds` ago.
|
|
7
|
+
# Accepts integer seconds for reliable ActiveJob serialization across all queue adapters.
|
|
8
|
+
def perform(grace_period_seconds: 7.days.to_i)
|
|
9
|
+
cutoff = grace_period_seconds.seconds.ago
|
|
10
|
+
deleted = StandardId::RefreshToken
|
|
11
|
+
.where("expires_at < :cutoff OR revoked_at < :cutoff", cutoff: cutoff)
|
|
12
|
+
.delete_all
|
|
13
|
+
Rails.logger.info("[StandardId] Cleaned up #{deleted} expired/revoked refresh tokens older than #{cutoff}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -6,6 +6,7 @@ module StandardId
|
|
|
6
6
|
has_many :identifiers, class_name: "StandardId::Identifier", dependent: :restrict_with_exception
|
|
7
7
|
has_many :credentials, class_name: "StandardId::Credential", through: :identifiers, source: :credentials, dependent: :restrict_with_exception
|
|
8
8
|
has_many :sessions, class_name: "StandardId::Session", dependent: :restrict_with_exception
|
|
9
|
+
has_many :refresh_tokens, class_name: "StandardId::RefreshToken", dependent: :restrict_with_exception
|
|
9
10
|
has_many :client_applications, class_name: "StandardId::ClientApplication", as: :owner, dependent: :restrict_with_exception
|
|
10
11
|
|
|
11
12
|
accepts_nested_attributes_for :identifiers
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
module PasswordStrength
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
validate :password_meets_strength_requirements, if: -> { password.present? }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def password_meets_strength_requirements
|
|
12
|
+
config = StandardId.config.password
|
|
13
|
+
|
|
14
|
+
if password.length < config.minimum_length
|
|
15
|
+
errors.add(:password, "must be at least #{config.minimum_length} characters long")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if config.require_uppercase && password !~ /[A-Z]/
|
|
19
|
+
errors.add(:password, "must include at least one uppercase letter")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if config.require_numbers && password !~ /\d/
|
|
23
|
+
errors.add(:password, "must include at least one number")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if config.require_special_chars && password !~ /[^a-zA-Z0-9]/
|
|
27
|
+
errors.add(:password, "must include at least one special character")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -17,6 +17,20 @@ module StandardId
|
|
|
17
17
|
before_validation :set_issued_and_expiry, on: :create
|
|
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
|
+
# Fail fast: reject unsupported PKCE methods at issuance rather than
|
|
21
|
+
# storing a code that will always fail at redemption time.
|
|
22
|
+
if code_challenge.present?
|
|
23
|
+
unless code_challenge_method.to_s.downcase == "s256"
|
|
24
|
+
raise StandardId::InvalidRequestError, "Unsupported code_challenge_method: only S256 is allowed"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Hash the code_challenge for defense-in-depth (RAR-58).
|
|
29
|
+
# The stored value is SHA256(S256_challenge), where S256_challenge is
|
|
30
|
+
# base64url(SHA256(verifier)). This is intentionally a double-hash:
|
|
31
|
+
# S256 derives the challenge from the verifier, and we hash again for storage.
|
|
32
|
+
hashed_challenge = code_challenge.present? ? Digest::SHA256.hexdigest(code_challenge) : nil
|
|
33
|
+
|
|
20
34
|
create!(
|
|
21
35
|
account: account,
|
|
22
36
|
code_hash: hash_for(plaintext_code),
|
|
@@ -24,7 +38,7 @@ module StandardId
|
|
|
24
38
|
redirect_uri: redirect_uri,
|
|
25
39
|
scope: scope,
|
|
26
40
|
audience: audience,
|
|
27
|
-
code_challenge:
|
|
41
|
+
code_challenge: hashed_challenge,
|
|
28
42
|
code_challenge_method: code_challenge_method,
|
|
29
43
|
nonce: nonce,
|
|
30
44
|
issued_at: Time.current,
|
|
@@ -58,15 +72,20 @@ module StandardId
|
|
|
58
72
|
|
|
59
73
|
return false if code_verifier.blank?
|
|
60
74
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
# Only S256 is supported (OAuth 2.1). The "plain" method is rejected
|
|
76
|
+
# because it transmits the verifier in cleartext, defeating PKCE's purpose.
|
|
77
|
+
return false unless (code_challenge_method || "").downcase == "s256"
|
|
78
|
+
|
|
79
|
+
s256_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier)).delete("=")
|
|
80
|
+
|
|
81
|
+
# New format: stored value is SHA256(S256_challenge)
|
|
82
|
+
hashed_expected = Digest::SHA256.hexdigest(s256_challenge)
|
|
83
|
+
return true if ActiveSupport::SecurityUtils.secure_compare(hashed_expected, code_challenge)
|
|
84
|
+
|
|
85
|
+
# Legacy fallback: codes issued before RAR-58 store the raw S256 challenge.
|
|
86
|
+
# This handles in-flight codes during deployment (max 10-minute TTL).
|
|
87
|
+
# Safe to remove after one deployment cycle.
|
|
88
|
+
ActiveSupport::SecurityUtils.secure_compare(s256_challenge, code_challenge)
|
|
70
89
|
end
|
|
71
90
|
|
|
72
91
|
def mark_as_used!
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
module StandardId
|
|
2
2
|
class PasswordCredential < ApplicationRecord
|
|
3
3
|
include StandardId::Credentiable
|
|
4
|
+
include StandardId::PasswordStrength
|
|
4
5
|
|
|
5
6
|
has_secure_password
|
|
6
7
|
|
|
@@ -13,7 +14,7 @@ module StandardId
|
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
validates :login, presence: true, uniqueness: true
|
|
16
|
-
validates :password,
|
|
17
|
+
validates :password, confirmation: true, if: :validate_password?
|
|
17
18
|
|
|
18
19
|
private
|
|
19
20
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
class RefreshToken < ApplicationRecord
|
|
3
|
+
self.table_name = "standard_id_refresh_tokens"
|
|
4
|
+
|
|
5
|
+
belongs_to :account, class_name: StandardId.config.account_class_name
|
|
6
|
+
belongs_to :session, class_name: "StandardId::Session", optional: true
|
|
7
|
+
belongs_to :previous_token, class_name: "StandardId::RefreshToken", optional: true
|
|
8
|
+
|
|
9
|
+
scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) }
|
|
10
|
+
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
|
11
|
+
scope :revoked, -> { where.not(revoked_at: nil) }
|
|
12
|
+
|
|
13
|
+
validates :token_digest, presence: true, uniqueness: true
|
|
14
|
+
validates :expires_at, presence: true
|
|
15
|
+
|
|
16
|
+
def self.digest_for(jti)
|
|
17
|
+
Digest::SHA256.hexdigest(jti)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.find_by_jti(jti)
|
|
21
|
+
find_by(token_digest: digest_for(jti))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def active?
|
|
25
|
+
!revoked? && !expired?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def expired?
|
|
29
|
+
expires_at <= Time.current
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def revoked?
|
|
33
|
+
revoked_at.present?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def revoke!
|
|
37
|
+
rows = self.class.where(id: id, revoked_at: nil).update_all(revoked_at: Time.current)
|
|
38
|
+
reload if rows > 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Revoke this token and all tokens in the same family chain.
|
|
42
|
+
# A "family" is all tokens linked via previous_token_id.
|
|
43
|
+
# Only revokes tokens that aren't already revoked, preserving historical
|
|
44
|
+
# revoked_at timestamps for audit purposes.
|
|
45
|
+
def revoke_family!
|
|
46
|
+
family_tokens.where(revoked_at: nil).update_all(revoked_at: Time.current)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Find the root of this token's family and return all descendants.
|
|
52
|
+
# Backward traversal uses a visited set for cycle detection in case
|
|
53
|
+
# of corrupted data. Forward traversal collects all descendants.
|
|
54
|
+
def family_tokens
|
|
55
|
+
root = self
|
|
56
|
+
visited = Set.new([root.id])
|
|
57
|
+
while root.previous_token.present?
|
|
58
|
+
break if visited.include?(root.previous_token_id)
|
|
59
|
+
visited.add(root.previous_token_id)
|
|
60
|
+
root = root.previous_token
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
self.class.where(id: collect_family_ids(root.id))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def collect_family_ids(root_id)
|
|
67
|
+
ids = [root_id]
|
|
68
|
+
current_ids = [root_id]
|
|
69
|
+
|
|
70
|
+
loop do
|
|
71
|
+
next_ids = self.class.where(previous_token_id: current_ids).pluck(:id)
|
|
72
|
+
break if next_ids.empty?
|
|
73
|
+
|
|
74
|
+
ids.concat(next_ids)
|
|
75
|
+
current_ids = next_ids
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
ids
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -5,6 +5,9 @@ module StandardId
|
|
|
5
5
|
self.table_name = "standard_id_sessions"
|
|
6
6
|
|
|
7
7
|
belongs_to :account, class_name: StandardId.config.account_class_name
|
|
8
|
+
has_many :refresh_tokens, class_name: "StandardId::RefreshToken", dependent: :nullify
|
|
9
|
+
|
|
10
|
+
before_destroy :revoke_active_refresh_tokens, prepend: true
|
|
8
11
|
|
|
9
12
|
scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) }
|
|
10
13
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
|
@@ -35,7 +38,12 @@ module StandardId
|
|
|
35
38
|
|
|
36
39
|
def revoke!(reason: nil)
|
|
37
40
|
@reason = reason
|
|
38
|
-
|
|
41
|
+
transaction do
|
|
42
|
+
update!(revoked_at: Time.current)
|
|
43
|
+
# Cascade revocation to refresh tokens. Uses update_all for efficiency;
|
|
44
|
+
# intentionally skips updated_at since revocation is tracked via revoked_at.
|
|
45
|
+
refresh_tokens.active.update_all(revoked_at: Time.current)
|
|
46
|
+
end
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
private
|
|
@@ -52,6 +60,12 @@ module StandardId
|
|
|
52
60
|
self.lookup_hash = Digest::SHA256.hexdigest("#{token}:#{Rails.configuration.secret_key_base}")
|
|
53
61
|
end
|
|
54
62
|
|
|
63
|
+
# Revoke any still-active refresh tokens before the session row is deleted,
|
|
64
|
+
# so tokens don't become orphaned but usable.
|
|
65
|
+
def revoke_active_refresh_tokens
|
|
66
|
+
refresh_tokens.active.update_all(revoked_at: Time.current)
|
|
67
|
+
end
|
|
68
|
+
|
|
55
69
|
def just_revoked?
|
|
56
70
|
saved_change_to_revoked_at? && revoked?
|
|
57
71
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Adds provider tracking to identifiers for social login link validation.
|
|
2
|
+
#
|
|
3
|
+
# After running this migration, existing identifiers will have provider=NULL.
|
|
4
|
+
# The strict link strategy treats NULL provider as "pre-migration" and allows
|
|
5
|
+
# linking, so existing users are not blocked. However, this means pre-migration
|
|
6
|
+
# accounts are not fully protected by the strict strategy until their provider
|
|
7
|
+
# is populated — either by logging in again via social, or by running a
|
|
8
|
+
# backfill (e.g. UPDATE standard_id_identifiers SET provider = 'email'
|
|
9
|
+
# WHERE provider IS NULL AND type = 'StandardId::EmailIdentifier').
|
|
10
|
+
class AddProviderToStandardIdIdentifiers < ActiveRecord::Migration[8.0]
|
|
11
|
+
def change
|
|
12
|
+
add_column :standard_id_identifiers, :provider, :string
|
|
13
|
+
add_index :standard_id_identifiers, [:account_id, :provider]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class CreateStandardIdRefreshTokens < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :standard_id_refresh_tokens, id: primary_key_type do |t|
|
|
4
|
+
t.references :account, type: primary_key_type, null: false, foreign_key: true, index: true
|
|
5
|
+
t.references :session, type: primary_key_type, null: true, foreign_key: { to_table: :standard_id_sessions }, index: true
|
|
6
|
+
|
|
7
|
+
t.string :token_digest, null: false, index: { unique: true }
|
|
8
|
+
t.datetime :expires_at, null: false
|
|
9
|
+
t.datetime :revoked_at
|
|
10
|
+
|
|
11
|
+
t.references :previous_token, type: primary_key_type, null: true, foreign_key: { to_table: :standard_id_refresh_tokens }, index: true
|
|
12
|
+
|
|
13
|
+
t.timestamps
|
|
14
|
+
|
|
15
|
+
t.index [:account_id, :revoked_at], name: "idx_on_account_id_revoked_at_refresh_tokens"
|
|
16
|
+
t.index [:session_id, :revoked_at], name: "idx_on_session_id_revoked_at_refresh_tokens"
|
|
17
|
+
t.index [:expires_at, :revoked_at], name: "idx_on_expires_at_revoked_at_refresh_tokens"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddNullifyToRefreshTokenPreviousTokenFk < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
remove_foreign_key :standard_id_refresh_tokens, column: :previous_token_id
|
|
4
|
+
add_foreign_key :standard_id_refresh_tokens, :standard_id_refresh_tokens,
|
|
5
|
+
column: :previous_token_id, on_delete: :nullify
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -114,6 +114,7 @@ StandardId.configure do |c|
|
|
|
114
114
|
# c.social.apple_team_id = ENV["APPLE_TEAM_ID"]
|
|
115
115
|
# c.social.allowed_redirect_url_prefixes = ["sidekicklabs://"]
|
|
116
116
|
# c.social.available_scopes = ["profile", "email", "offline_access"]
|
|
117
|
+
# c.social.link_strategy = :strict # :strict (default) or :trust_provider
|
|
117
118
|
# c.social.social_account_attributes = ->(social_info:, provider:) {
|
|
118
119
|
# {
|
|
119
120
|
# email: social_info[:email],
|
|
@@ -54,9 +54,9 @@ StandardConfig.schema.draw do
|
|
|
54
54
|
|
|
55
55
|
scope :password do
|
|
56
56
|
field :minimum_length, type: :integer, default: 8
|
|
57
|
-
field :require_special_chars, type: :boolean, default:
|
|
58
|
-
field :require_uppercase, type: :boolean, default:
|
|
59
|
-
field :require_numbers, type: :boolean, default:
|
|
57
|
+
field :require_special_chars, type: :boolean, default: true
|
|
58
|
+
field :require_uppercase, type: :boolean, default: true
|
|
59
|
+
field :require_numbers, type: :boolean, default: true
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
scope :session do
|
|
@@ -96,6 +96,7 @@ StandardConfig.schema.draw do
|
|
|
96
96
|
field :social_account_attributes, type: :any, default: nil
|
|
97
97
|
field :allowed_redirect_url_prefixes, type: :array, default: []
|
|
98
98
|
field :available_scopes, type: :array, default: -> { [] }
|
|
99
|
+
field :link_strategy, type: :symbol, default: :strict
|
|
99
100
|
end
|
|
100
101
|
|
|
101
102
|
scope :web do
|
data/lib/standard_id/engine.rb
CHANGED
|
@@ -2,6 +2,20 @@ module StandardId
|
|
|
2
2
|
class Engine < ::Rails::Engine
|
|
3
3
|
isolate_namespace StandardId
|
|
4
4
|
|
|
5
|
+
initializer "standard_id.filter_parameters" do |app|
|
|
6
|
+
app.config.filter_parameters += %i[
|
|
7
|
+
code_verifier
|
|
8
|
+
code_challenge
|
|
9
|
+
client_secret
|
|
10
|
+
id_token
|
|
11
|
+
refresh_token
|
|
12
|
+
access_token
|
|
13
|
+
state
|
|
14
|
+
nonce
|
|
15
|
+
authorization_code
|
|
16
|
+
]
|
|
17
|
+
end
|
|
18
|
+
|
|
5
19
|
config.after_initialize do
|
|
6
20
|
if StandardId.config.events.enable_logging
|
|
7
21
|
StandardId::Events::Subscribers::LoggingSubscriber.attach
|
data/lib/standard_id/errors.rb
CHANGED
|
@@ -73,6 +73,24 @@ module StandardId
|
|
|
73
73
|
# Lifecycle hook errors
|
|
74
74
|
class AuthenticationDenied < StandardError; end
|
|
75
75
|
|
|
76
|
+
# Social login errors
|
|
77
|
+
# NOTE: email and provider_name are exposed as reader attributes for host
|
|
78
|
+
# apps to build custom error responses. If you report exceptions to an
|
|
79
|
+
# error tracker (Sentry, etc.), be aware these attributes contain PII.
|
|
80
|
+
class SocialLinkError < OAuthError
|
|
81
|
+
attr_reader :email, :provider_name
|
|
82
|
+
|
|
83
|
+
def initialize(email:, provider_name:)
|
|
84
|
+
@email = email
|
|
85
|
+
@provider_name = provider_name
|
|
86
|
+
super("This email is already associated with an account. Please sign in first to link this provider.")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Uses standard OAuth :access_denied code since account_link_required is non-standard
|
|
90
|
+
def oauth_error_code = :access_denied
|
|
91
|
+
def http_status = :forbidden
|
|
92
|
+
end
|
|
93
|
+
|
|
76
94
|
# Audience verification errors
|
|
77
95
|
class InvalidAudienceError < StandardError
|
|
78
96
|
attr_reader :required, :actual
|
|
@@ -40,6 +40,7 @@ module StandardId
|
|
|
40
40
|
OAUTH_TOKEN_REFRESHED = "oauth.token.refreshed"
|
|
41
41
|
OAUTH_CODE_CONSUMED = "oauth.code.consumed"
|
|
42
42
|
OAUTH_TOKEN_REVOKED = "oauth.token.revoked"
|
|
43
|
+
OAUTH_REFRESH_TOKEN_REUSE_DETECTED = "oauth.refresh_token.reuse_detected"
|
|
43
44
|
|
|
44
45
|
PASSWORDLESS_CODE_REQUESTED = "passwordless.code.requested"
|
|
45
46
|
PASSWORDLESS_CODE_GENERATED = "passwordless.code.generated"
|
|
@@ -53,6 +54,7 @@ module StandardId
|
|
|
53
54
|
SOCIAL_USER_INFO_FETCHED = "social.user_info.fetched"
|
|
54
55
|
SOCIAL_ACCOUNT_CREATED = "social.account.created"
|
|
55
56
|
SOCIAL_ACCOUNT_LINKED = "social.account.linked"
|
|
57
|
+
SOCIAL_LINK_BLOCKED = "social.link.blocked"
|
|
56
58
|
SOCIAL_AUTH_COMPLETED = "social.auth.completed"
|
|
57
59
|
|
|
58
60
|
CREDENTIAL_PASSWORD_CREATED = "credential.password.created"
|
|
@@ -110,7 +112,8 @@ module StandardId
|
|
|
110
112
|
OAUTH_TOKEN_ISSUED,
|
|
111
113
|
OAUTH_TOKEN_REFRESHED,
|
|
112
114
|
OAUTH_CODE_CONSUMED,
|
|
113
|
-
OAUTH_TOKEN_REVOKED
|
|
115
|
+
OAUTH_TOKEN_REVOKED,
|
|
116
|
+
OAUTH_REFRESH_TOKEN_REUSE_DETECTED
|
|
114
117
|
].freeze
|
|
115
118
|
|
|
116
119
|
PASSWORDLESS_EVENTS = [
|
|
@@ -128,6 +131,7 @@ module StandardId
|
|
|
128
131
|
SOCIAL_USER_INFO_FETCHED,
|
|
129
132
|
SOCIAL_ACCOUNT_CREATED,
|
|
130
133
|
SOCIAL_ACCOUNT_LINKED,
|
|
134
|
+
SOCIAL_LINK_BLOCKED,
|
|
131
135
|
SOCIAL_AUTH_COMPLETED
|
|
132
136
|
].freeze
|
|
133
137
|
|
|
@@ -167,6 +171,7 @@ module StandardId
|
|
|
167
171
|
OAUTH_TOKEN_ISSUED,
|
|
168
172
|
OAUTH_TOKEN_REFRESHED,
|
|
169
173
|
OAUTH_TOKEN_REVOKED,
|
|
174
|
+
OAUTH_REFRESH_TOKEN_REUSE_DETECTED,
|
|
170
175
|
# Passwordless
|
|
171
176
|
PASSWORDLESS_CODE_FAILED,
|
|
172
177
|
PASSWORDLESS_ACCOUNT_CREATED,
|
|
@@ -180,7 +185,8 @@ module StandardId
|
|
|
180
185
|
CREDENTIAL_CLIENT_SECRET_REVOKED,
|
|
181
186
|
# Social
|
|
182
187
|
SOCIAL_ACCOUNT_CREATED,
|
|
183
|
-
SOCIAL_ACCOUNT_LINKED
|
|
188
|
+
SOCIAL_ACCOUNT_LINKED,
|
|
189
|
+
SOCIAL_LINK_BLOCKED
|
|
184
190
|
].freeze
|
|
185
191
|
|
|
186
192
|
ALL_EVENTS = (
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
require "ipaddr"
|
|
1
2
|
require "net/http"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "resolv"
|
|
2
5
|
require "uri"
|
|
3
6
|
|
|
4
7
|
module StandardId
|
|
@@ -6,27 +9,70 @@ module StandardId
|
|
|
6
9
|
OPEN_TIMEOUT = 5
|
|
7
10
|
READ_TIMEOUT = 10
|
|
8
11
|
|
|
12
|
+
class SsrfError < StandardError; end
|
|
13
|
+
|
|
14
|
+
BLOCKED_IP_RANGES = [
|
|
15
|
+
IPAddr.new("10.0.0.0/8"),
|
|
16
|
+
IPAddr.new("172.16.0.0/12"),
|
|
17
|
+
IPAddr.new("192.168.0.0/16"),
|
|
18
|
+
IPAddr.new("127.0.0.0/8"),
|
|
19
|
+
IPAddr.new("169.254.0.0/16"),
|
|
20
|
+
IPAddr.new("0.0.0.0/8"),
|
|
21
|
+
IPAddr.new("::1/128"),
|
|
22
|
+
IPAddr.new("fc00::/7"),
|
|
23
|
+
IPAddr.new("fe80::/10")
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
9
26
|
class << self
|
|
10
27
|
def post_form(endpoint, params)
|
|
11
|
-
uri =
|
|
28
|
+
uri, resolved_ip = validate_url!(endpoint)
|
|
12
29
|
request = Net::HTTP::Post.new(uri)
|
|
13
30
|
request.set_form_data(params)
|
|
14
|
-
start_connection(uri) { |http| http.request(request) }
|
|
31
|
+
start_connection(uri, resolved_ip:) { |http| http.request(request) }
|
|
15
32
|
end
|
|
16
33
|
|
|
17
34
|
def get_with_bearer(endpoint, access_token)
|
|
18
|
-
uri =
|
|
35
|
+
uri, resolved_ip = validate_url!(endpoint)
|
|
19
36
|
request = Net::HTTP::Get.new(uri)
|
|
20
37
|
request["Authorization"] = "Bearer #{access_token}"
|
|
21
|
-
start_connection(uri) { |http| http.request(request) }
|
|
38
|
+
start_connection(uri, resolved_ip:) { |http| http.request(request) }
|
|
22
39
|
end
|
|
23
40
|
|
|
24
41
|
private
|
|
25
42
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
def validate_url!(url)
|
|
44
|
+
uri = URI.parse(url.to_s)
|
|
45
|
+
raise SsrfError, "Only http and https schemes are allowed" unless %w[http https].include?(uri.scheme)
|
|
46
|
+
raise SsrfError, "Invalid URL: missing host" if uri.host.nil? || uri.host.empty?
|
|
47
|
+
|
|
48
|
+
addresses = Resolv.getaddresses(uri.host)
|
|
49
|
+
raise SsrfError, "Could not resolve host" if addresses.empty?
|
|
50
|
+
|
|
51
|
+
addresses.each do |addr|
|
|
52
|
+
ip = IPAddr.new(addr)
|
|
53
|
+
if BLOCKED_IP_RANGES.any? { |range| range.include?(ip) }
|
|
54
|
+
raise SsrfError, "Requests to private/internal addresses are not allowed"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Return resolved IP to pin connection and prevent DNS rebinding
|
|
59
|
+
[uri, addresses.first]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def start_connection(uri, resolved_ip: nil, &block)
|
|
63
|
+
host = resolved_ip || uri.host
|
|
64
|
+
options = {
|
|
65
|
+
use_ssl: uri.scheme == "https",
|
|
66
|
+
open_timeout: OPEN_TIMEOUT,
|
|
67
|
+
read_timeout: READ_TIMEOUT
|
|
68
|
+
}
|
|
69
|
+
options[:verify_mode] = OpenSSL::SSL::VERIFY_PEER if options[:use_ssl]
|
|
70
|
+
|
|
71
|
+
Net::HTTP.start(host, uri.port, **options) do |http|
|
|
72
|
+
# Set Host header for virtual hosting when connecting to resolved IP
|
|
73
|
+
http.instance_variable_set(:@address, uri.host) if resolved_ip
|
|
74
|
+
yield http
|
|
75
|
+
end
|
|
30
76
|
end
|
|
31
77
|
end
|
|
32
78
|
end
|
|
@@ -122,8 +122,12 @@ module StandardId
|
|
|
122
122
|
@jwks_ref.set(nil)
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
-
def self.encode(payload, expires_in:
|
|
126
|
-
payload[:exp] =
|
|
125
|
+
def self.encode(payload, expires_in: nil, expires_at: nil)
|
|
126
|
+
payload[:exp] = if expires_at
|
|
127
|
+
expires_at.to_i
|
|
128
|
+
else
|
|
129
|
+
(expires_in || 1.hour).from_now.to_i
|
|
130
|
+
end
|
|
127
131
|
payload[:iat] = Time.current.to_i
|
|
128
132
|
payload[:iss] ||= StandardId.config.issuer if StandardId.config.issuer.present?
|
|
129
133
|
|
|
@@ -4,6 +4,26 @@ module StandardId
|
|
|
4
4
|
expect_params :refresh_token, :client_id
|
|
5
5
|
permit_params :client_secret, :scope, :audience
|
|
6
6
|
|
|
7
|
+
# authenticate! runs outside the transaction so reuse-detection
|
|
8
|
+
# revocations (revoke_family!) persist even when the error propagates.
|
|
9
|
+
# Only the normal rotation path (revoke old + create new) is wrapped
|
|
10
|
+
# in a transaction for atomicity.
|
|
11
|
+
def execute
|
|
12
|
+
authenticate!
|
|
13
|
+
response = nil
|
|
14
|
+
StandardId::RefreshToken.transaction do
|
|
15
|
+
rotate_current_refresh_token!
|
|
16
|
+
response = generate_token_response
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# If rotate detected a concurrent reuse (rows==0), the transaction
|
|
20
|
+
# was rolled back via ActiveRecord::Rollback and response is nil.
|
|
21
|
+
# Handle family revocation outside the transaction so it persists.
|
|
22
|
+
handle_concurrent_reuse! unless response
|
|
23
|
+
|
|
24
|
+
response
|
|
25
|
+
end
|
|
26
|
+
|
|
7
27
|
def authenticate!
|
|
8
28
|
validate_client_secret!(params[:client_id], params[:client_secret]) if params[:client_secret].present?
|
|
9
29
|
|
|
@@ -14,11 +34,76 @@ module StandardId
|
|
|
14
34
|
raise StandardId::InvalidGrantError, "Refresh token was not issued to this client"
|
|
15
35
|
end
|
|
16
36
|
|
|
37
|
+
validate_refresh_token_record!
|
|
17
38
|
validate_scope_narrowing!
|
|
18
39
|
end
|
|
19
40
|
|
|
20
41
|
private
|
|
21
42
|
|
|
43
|
+
def validate_refresh_token_record!
|
|
44
|
+
jti = @refresh_payload[:jti]
|
|
45
|
+
# Legacy tokens minted before jti tracking was added cannot be looked
|
|
46
|
+
# up or revoked through the RefreshToken model. This shim can be removed
|
|
47
|
+
# once all pre-jti tokens have expired (refresh_token_lifetime after deploy).
|
|
48
|
+
return if jti.blank?
|
|
49
|
+
|
|
50
|
+
@current_refresh_token_record = StandardId::RefreshToken.find_by_jti(jti)
|
|
51
|
+
|
|
52
|
+
unless @current_refresh_token_record
|
|
53
|
+
raise StandardId::InvalidGrantError, "Refresh token not found"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if @current_refresh_token_record.revoked?
|
|
57
|
+
# Reuse detected: this token was already rotated. Revoke entire family.
|
|
58
|
+
@current_refresh_token_record.revoke_family!
|
|
59
|
+
emit_reuse_detected_event
|
|
60
|
+
raise StandardId::InvalidGrantError, "Refresh token reuse detected"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
unless @current_refresh_token_record.active?
|
|
64
|
+
raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Atomically revoke the current token as part of rotation.
|
|
69
|
+
# Uses a conditional UPDATE to prevent TOCTOU race conditions — only one
|
|
70
|
+
# concurrent request can successfully revoke and proceed.
|
|
71
|
+
# Called inside a transaction with new-token creation so both succeed or
|
|
72
|
+
# both roll back.
|
|
73
|
+
def rotate_current_refresh_token!
|
|
74
|
+
return unless @current_refresh_token_record
|
|
75
|
+
|
|
76
|
+
rows = StandardId::RefreshToken
|
|
77
|
+
.where(id: @current_refresh_token_record.id, revoked_at: nil)
|
|
78
|
+
.update_all(revoked_at: Time.current)
|
|
79
|
+
|
|
80
|
+
return if rows > 0
|
|
81
|
+
|
|
82
|
+
# A concurrent request won the race. Roll back this transaction
|
|
83
|
+
# (no new token should be issued). Reuse handling happens outside
|
|
84
|
+
# the transaction in handle_concurrent_reuse! so revocations persist.
|
|
85
|
+
raise ActiveRecord::Rollback
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def handle_concurrent_reuse!
|
|
89
|
+
@current_refresh_token_record&.reload
|
|
90
|
+
if @current_refresh_token_record&.revoked?
|
|
91
|
+
@current_refresh_token_record.revoke_family!
|
|
92
|
+
emit_reuse_detected_event
|
|
93
|
+
raise StandardId::InvalidGrantError, "Refresh token reuse detected"
|
|
94
|
+
end
|
|
95
|
+
raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def emit_reuse_detected_event
|
|
99
|
+
StandardId::Events.publish(
|
|
100
|
+
StandardId::Events::OAUTH_REFRESH_TOKEN_REUSE_DETECTED,
|
|
101
|
+
account_id: @refresh_payload[:sub],
|
|
102
|
+
client_id: @refresh_payload[:client_id],
|
|
103
|
+
refresh_token_id: @current_refresh_token_record.id
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
22
107
|
def subject_id
|
|
23
108
|
@refresh_payload[:sub]
|
|
24
109
|
end
|
|
@@ -46,6 +131,17 @@ module StandardId
|
|
|
46
131
|
@refresh_payload[:aud]
|
|
47
132
|
end
|
|
48
133
|
|
|
134
|
+
def refresh_token_session_id
|
|
135
|
+
@current_refresh_token_record&.session_id
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Returns the (now-revoked) token record so it can be linked as
|
|
139
|
+
# previous_token on the newly minted refresh token, maintaining the
|
|
140
|
+
# family chain for reuse detection.
|
|
141
|
+
def previous_refresh_token_record
|
|
142
|
+
@current_refresh_token_record
|
|
143
|
+
end
|
|
144
|
+
|
|
49
145
|
def validate_scope_narrowing!
|
|
50
146
|
return unless params[:scope].present?
|
|
51
147
|
|
|
@@ -74,14 +74,43 @@ module StandardId
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def generate_refresh_token
|
|
77
|
+
jti = SecureRandom.uuid
|
|
77
78
|
payload = {
|
|
78
79
|
sub: subject_id,
|
|
79
80
|
client_id: client_id,
|
|
80
81
|
scope: token_scope,
|
|
81
82
|
aud: audience,
|
|
82
|
-
grant_type: "refresh_token"
|
|
83
|
+
grant_type: "refresh_token",
|
|
84
|
+
jti: jti
|
|
83
85
|
}.compact
|
|
84
|
-
|
|
86
|
+
|
|
87
|
+
expiry = refresh_token_expiry
|
|
88
|
+
# Capture expires_at once so the JWT exp and DB record are consistent
|
|
89
|
+
expires_at = expiry.from_now
|
|
90
|
+
|
|
91
|
+
# Persist the DB record first so we never hand out a signed JWT
|
|
92
|
+
# that has no backing record (e.g. if the INSERT were to fail).
|
|
93
|
+
persist_refresh_token!(jti: jti, expires_at: expires_at)
|
|
94
|
+
|
|
95
|
+
StandardId::JwtService.encode(payload, expires_at: expires_at)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def persist_refresh_token!(jti:, expires_at:)
|
|
99
|
+
StandardId::RefreshToken.create!(
|
|
100
|
+
account_id: subject_id,
|
|
101
|
+
session_id: refresh_token_session_id,
|
|
102
|
+
token_digest: StandardId::RefreshToken.digest_for(jti),
|
|
103
|
+
expires_at: expires_at,
|
|
104
|
+
previous_token: previous_refresh_token_record
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def refresh_token_session_id
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def previous_refresh_token_record
|
|
113
|
+
nil
|
|
85
114
|
end
|
|
86
115
|
|
|
87
116
|
def refresh_token_expiry
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
FactoryBot.define do
|
|
2
2
|
factory :standard_id_password_credential, class: "StandardId::PasswordCredential" do
|
|
3
3
|
sequence(:login) { |n| "user#{n}@example.com" }
|
|
4
|
-
password { "
|
|
5
|
-
password_confirmation { "
|
|
4
|
+
password { "Password1!" }
|
|
5
|
+
password_confirmation { "Password1!" }
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
factory :standard_id_credential, class: "StandardId::Credential" do
|
data/lib/standard_id/version.rb
CHANGED
|
@@ -3,11 +3,12 @@ module StandardId
|
|
|
3
3
|
class SessionManager
|
|
4
4
|
attr_reader :token_manager, :request, :session, :cookies
|
|
5
5
|
|
|
6
|
-
def initialize(token_manager, request:, session:, cookies:)
|
|
6
|
+
def initialize(token_manager, request:, session:, cookies:, reset_session: nil)
|
|
7
7
|
@token_manager = token_manager
|
|
8
8
|
@request = request
|
|
9
9
|
@session = session
|
|
10
10
|
@cookies = cookies
|
|
11
|
+
@reset_session = reset_session
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def current_session
|
|
@@ -20,6 +21,14 @@ module StandardId
|
|
|
20
21
|
|
|
21
22
|
def sign_in_account(account)
|
|
22
23
|
emit_session_creating(account, "browser")
|
|
24
|
+
|
|
25
|
+
# Prevent session fixation by resetting the Rails session before
|
|
26
|
+
# creating an authenticated session (Rails Security Guide §2.5).
|
|
27
|
+
# Preserve return_to URL across the reset so post-login redirect works.
|
|
28
|
+
return_to = session[:return_to_after_authenticating]
|
|
29
|
+
@reset_session&.call
|
|
30
|
+
session[:return_to_after_authenticating] = return_to if return_to
|
|
31
|
+
|
|
23
32
|
token_manager.create_browser_session(account).tap do |browser_session|
|
|
24
33
|
# Store in both session and encrypted cookie for backward compatibility
|
|
25
34
|
# Action Cable will use the encrypted cookie
|
|
@@ -91,6 +100,9 @@ module StandardId
|
|
|
91
100
|
password_credential = StandardId::PasswordCredential.find_by_token_for(:remember_me, cookies[:remember_token])
|
|
92
101
|
return if password_credential.blank?
|
|
93
102
|
|
|
103
|
+
# Prevent session fixation on returning-user remember-me flow
|
|
104
|
+
@reset_session&.call
|
|
105
|
+
|
|
94
106
|
token_manager.create_browser_session(password_credential.account, remember_me: true).tap do |browser_session|
|
|
95
107
|
# Store in both session and encrypted cookie for backward compatibility
|
|
96
108
|
session[:session_token] = browser_session.token
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_id
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -153,10 +153,12 @@ files:
|
|
|
153
153
|
- app/forms/standard_id/web/signup_form.rb
|
|
154
154
|
- app/helpers/standard_id/application_helper.rb
|
|
155
155
|
- app/jobs/standard_id/application_job.rb
|
|
156
|
+
- app/jobs/standard_id/cleanup_expired_refresh_tokens_job.rb
|
|
156
157
|
- app/jobs/standard_id/cleanup_expired_sessions_job.rb
|
|
157
158
|
- app/mailers/standard_id/application_mailer.rb
|
|
158
159
|
- app/models/concerns/standard_id/account_associations.rb
|
|
159
160
|
- app/models/concerns/standard_id/credentiable.rb
|
|
161
|
+
- app/models/concerns/standard_id/password_strength.rb
|
|
160
162
|
- app/models/standard_id/application_record.rb
|
|
161
163
|
- app/models/standard_id/authorization_code.rb
|
|
162
164
|
- app/models/standard_id/browser_session.rb
|
|
@@ -169,6 +171,7 @@ files:
|
|
|
169
171
|
- app/models/standard_id/identifier.rb
|
|
170
172
|
- app/models/standard_id/password_credential.rb
|
|
171
173
|
- app/models/standard_id/phone_number_identifier.rb
|
|
174
|
+
- app/models/standard_id/refresh_token.rb
|
|
172
175
|
- app/models/standard_id/service_session.rb
|
|
173
176
|
- app/models/standard_id/session.rb
|
|
174
177
|
- app/models/standard_id/username_identifier.rb
|
|
@@ -194,6 +197,9 @@ files:
|
|
|
194
197
|
- db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb
|
|
195
198
|
- db/migrate/20250903063000_create_standard_id_authorization_codes.rb
|
|
196
199
|
- db/migrate/20250907090000_create_standard_id_code_challenges.rb
|
|
200
|
+
- db/migrate/20260311000000_add_provider_to_standard_id_identifiers.rb
|
|
201
|
+
- db/migrate/20260311100000_create_standard_id_refresh_tokens.rb
|
|
202
|
+
- db/migrate/20260311100001_add_nullify_to_refresh_token_previous_token_fk.rb
|
|
197
203
|
- lib/generators/standard_id/install/install_generator.rb
|
|
198
204
|
- lib/generators/standard_id/install/templates/standard_id.rb
|
|
199
205
|
- lib/standard_config.rb
|