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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/standard_id/social_authentication.rb +42 -1
  3. data/app/controllers/concerns/standard_id/web_authentication.rb +7 -1
  4. data/app/forms/standard_id/web/reset_password_confirm_form.rb +1 -1
  5. data/app/forms/standard_id/web/signup_form.rb +2 -1
  6. data/app/jobs/standard_id/cleanup_expired_refresh_tokens_job.rb +16 -0
  7. data/app/models/concerns/standard_id/account_associations.rb +1 -0
  8. data/app/models/concerns/standard_id/password_strength.rb +31 -0
  9. data/app/models/standard_id/authorization_code.rb +29 -10
  10. data/app/models/standard_id/password_credential.rb +2 -1
  11. data/app/models/standard_id/refresh_token.rb +81 -0
  12. data/app/models/standard_id/session.rb +15 -1
  13. data/db/migrate/20260311000000_add_provider_to_standard_id_identifiers.rb +15 -0
  14. data/db/migrate/20260311100000_create_standard_id_refresh_tokens.rb +20 -0
  15. data/db/migrate/20260311100001_add_nullify_to_refresh_token_previous_token_fk.rb +7 -0
  16. data/lib/generators/standard_id/install/templates/standard_id.rb +1 -0
  17. data/lib/standard_id/config/schema.rb +4 -3
  18. data/lib/standard_id/engine.rb +14 -0
  19. data/lib/standard_id/errors.rb +18 -0
  20. data/lib/standard_id/events/definitions.rb +8 -2
  21. data/lib/standard_id/http_client.rb +54 -8
  22. data/lib/standard_id/jwt_service.rb +6 -2
  23. data/lib/standard_id/oauth/refresh_token_flow.rb +96 -0
  24. data/lib/standard_id/oauth/token_grant_flow.rb +31 -2
  25. data/lib/standard_id/testing/factories/credentials.rb +2 -2
  26. data/lib/standard_id/version.rb +1 -1
  27. data/lib/standard_id/web/session_manager.rb +13 -1
  28. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 112cdb26b4fc96d4be1830bde78fada616b87a79f6d7adff9657df72d8942a57
4
- data.tar.gz: 4efeaf6a5f3f5bfdaf3747bebc0f5f9c3ccf22b02642c69e7b59c8a3d4aec74a
3
+ metadata.gz: 3c621a201633467c0738625eacb1f0c59c0b8ee5b670903cb09b1a1a4f6bfd01
4
+ data.tar.gz: 2b4b97e69b5b8dba07f1df919fd3d3b5ef4dda4fa6b72ae54c317cfb96bb1320
5
5
  SHA512:
6
- metadata.gz: eea26565c62749d409acbdf38a282f63edf16ed907872d9673fea58057ede62ea679adb1765cd1e55855a48ca67cdabc5baeb7fa52277529828366c560462e7e
7
- data.tar.gz: c250360c48f34dc9d52b6f1391e7816735dd1d3e9be63d7d80a0e8ae9b87d7ee178f8c78867eead4b7c792ac62b6a753ea90be73caf9e1f60e4c94415c57f1fd
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(token_manager, request: request, session: session, cookies: cookies)
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, length: { minimum: 8 }, confirmation: 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: 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
- case (code_challenge_method || "plain").downcase
62
- when "s256"
63
- expected = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier)).delete("=")
64
- ActiveSupport::SecurityUtils.secure_compare(expected, code_challenge)
65
- when "plain"
66
- ActiveSupport::SecurityUtils.secure_compare(code_verifier, code_challenge)
67
- else
68
- false
69
- end
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, length: { minimum: 8 }, confirmation: true, if: :validate_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
- update!(revoked_at: Time.current)
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: false
58
- field :require_uppercase, type: :boolean, default: false
59
- field :require_numbers, type: :boolean, default: false
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
@@ -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
@@ -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 = URI(endpoint)
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 = URI(endpoint)
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 start_connection(uri, &block)
27
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
28
- open_timeout: OPEN_TIMEOUT,
29
- read_timeout: READ_TIMEOUT, &block)
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: 1.hour)
126
- payload[:exp] = expires_in.from_now.to_i
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
- StandardId::JwtService.encode(payload, expires_in: refresh_token_expiry)
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 { "password123" }
5
- password_confirmation { "password123" }
4
+ password { "Password1!" }
5
+ password_confirmation { "Password1!" }
6
6
  end
7
7
 
8
8
  factory :standard_id_credential, class: "StandardId::Credential" do
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.8.0"
2
+ VERSION = "0.9.0"
3
3
  end
@@ -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.8.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