standard_id 0.8.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a6e84ca4e7d4d01aa3957ed1371ef774fc1203b9006da7591b99e20fadb343b4
4
- data.tar.gz: cc74bfcf5f471d4bf7b76d621497598fac95fa0f833b256a74c3a4374456850b
3
+ metadata.gz: 3c621a201633467c0738625eacb1f0c59c0b8ee5b670903cb09b1a1a4f6bfd01
4
+ data.tar.gz: 2b4b97e69b5b8dba07f1df919fd3d3b5ef4dda4fa6b72ae54c317cfb96bb1320
5
5
  SHA512:
6
- metadata.gz: 21932b0dd06f986340284587ccfae60cfea9b0e5075cdc670ff297e23412c0440d0e434e3974f98f65b9071fb15a118c3c36920e08b8887e9857fa612318e857
7
- data.tar.gz: 7acf89c15d302da9e25da25de4c786441df085f65950a02e8b6fec71b4a90cf3f72c3167929371018869cc4787e6476d5df78036e3b2dde4833525d5c260045f
6
+ metadata.gz: 22613e053bfa27512b39c38af4be699686ed8ef83b296efbbd17b131e60f12923fc6efdb8b572833849807c3928fb8cecbaee4d72e1e6da60e0b37b606555a3b
7
+ data.tar.gz: 36e22ae1dca29a4350350c079eb7f1b4166c341f3d435bcdd666d7603b8f479de0dbee957b75fd807a42f7deeaf272d69badf6fe61d978151dcebb1f48c92401
@@ -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,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
 
@@ -34,7 +34,8 @@ module StandardId
34
34
  end
35
35
 
36
36
  def revoke!
37
- update!(revoked_at: Time.current) unless revoked?
37
+ rows = self.class.where(id: id, revoked_at: nil).update_all(revoked_at: Time.current)
38
+ reload if rows > 0
38
39
  end
39
40
 
40
41
  # Revoke this token and all tokens in the same family chain.
@@ -45,22 +46,18 @@ module StandardId
45
46
  family_tokens.where(revoked_at: nil).update_all(revoked_at: Time.current)
46
47
  end
47
48
 
48
- # Max depth guard to prevent unbounded traversal in case of
49
- # corrupted data or extremely long-lived token chains.
50
- MAX_FAMILY_DEPTH = 50
51
-
52
49
  private
53
50
 
54
51
  # Find the root of this token's family and return all descendants.
55
- # Uses iterative queries (one per generation) bounded by MAX_FAMILY_DEPTH.
56
- # For typical token chain lengths (<10) this is fine; a recursive CTE
57
- # would collapse to a single query if performance becomes a concern.
52
+ # Backward traversal uses a visited set for cycle detection in case
53
+ # of corrupted data. Forward traversal collects all descendants.
58
54
  def family_tokens
59
55
  root = self
60
- depth = 0
61
- while root.previous_token.present? && depth < MAX_FAMILY_DEPTH
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)
62
60
  root = root.previous_token
63
- depth += 1
64
61
  end
65
62
 
66
63
  self.class.where(id: collect_family_ids(root.id))
@@ -69,15 +66,13 @@ module StandardId
69
66
  def collect_family_ids(root_id)
70
67
  ids = [root_id]
71
68
  current_ids = [root_id]
72
- depth = 0
73
69
 
74
- while depth < MAX_FAMILY_DEPTH
70
+ loop do
75
71
  next_ids = self.class.where(previous_token_id: current_ids).pluck(:id)
76
72
  break if next_ids.empty?
77
73
 
78
74
  ids.concat(next_ids)
79
75
  current_ids = next_ids
80
- depth += 1
81
76
  end
82
77
 
83
78
  ids
@@ -7,7 +7,7 @@ module StandardId
7
7
  belongs_to :account, class_name: StandardId.config.account_class_name
8
8
  has_many :refresh_tokens, class_name: "StandardId::RefreshToken", dependent: :nullify
9
9
 
10
- before_destroy :revoke_active_refresh_tokens
10
+ before_destroy :revoke_active_refresh_tokens, prepend: true
11
11
 
12
12
  scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) }
13
13
  scope :expired, -> { where("expires_at <= ?", Time.current) }
@@ -38,10 +38,12 @@ module StandardId
38
38
 
39
39
  def revoke!(reason: nil)
40
40
  @reason = reason
41
- update!(revoked_at: Time.current)
42
- # Cascade revocation to refresh tokens. Uses update_all for efficiency;
43
- # intentionally skips updated_at since revocation is tracked via revoked_at.
44
- refresh_tokens.active.update_all(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
45
47
  end
46
48
 
47
49
  private
@@ -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
@@ -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
@@ -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
 
@@ -36,42 +56,52 @@ module StandardId
36
56
  if @current_refresh_token_record.revoked?
37
57
  # Reuse detected: this token was already rotated. Revoke entire family.
38
58
  @current_refresh_token_record.revoke_family!
39
- StandardId::Events.publish(
40
- StandardId::Events::OAUTH_REFRESH_TOKEN_REUSE_DETECTED,
41
- account_id: @refresh_payload[:sub],
42
- client_id: @refresh_payload[:client_id],
43
- refresh_token_id: @current_refresh_token_record.id
44
- )
59
+ emit_reuse_detected_event
45
60
  raise StandardId::InvalidGrantError, "Refresh token reuse detected"
46
61
  end
47
62
 
48
63
  unless @current_refresh_token_record.active?
49
64
  raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
50
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
51
75
 
52
- # Atomically revoke the current token as part of rotation.
53
- # Uses a conditional UPDATE to prevent TOCTOU race conditions — only one
54
- # concurrent request can successfully revoke and proceed.
55
76
  rows = StandardId::RefreshToken
56
77
  .where(id: @current_refresh_token_record.id, revoked_at: nil)
57
78
  .update_all(revoked_at: Time.current)
58
79
 
59
- if rows == 0
60
- # A concurrent request revoked the token between the revoked? check
61
- # and the UPDATE. Re-load to determine whether this is a reuse scenario.
62
- @current_refresh_token_record.reload
63
- if @current_refresh_token_record.revoked?
64
- @current_refresh_token_record.revoke_family!
65
- StandardId::Events.publish(
66
- StandardId::Events::OAUTH_REFRESH_TOKEN_REUSE_DETECTED,
67
- account_id: @refresh_payload[:sub],
68
- client_id: @refresh_payload[:client_id],
69
- refresh_token_id: @current_refresh_token_record.id
70
- )
71
- raise StandardId::InvalidGrantError, "Refresh token reuse detected"
72
- end
73
- raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
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"
74
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
+ )
75
105
  end
76
106
 
77
107
  def subject_id
@@ -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.1"
2
+ VERSION = "0.9.0"
3
3
  end
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.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -158,6 +158,7 @@ files:
158
158
  - app/mailers/standard_id/application_mailer.rb
159
159
  - app/models/concerns/standard_id/account_associations.rb
160
160
  - app/models/concerns/standard_id/credentiable.rb
161
+ - app/models/concerns/standard_id/password_strength.rb
161
162
  - app/models/standard_id/application_record.rb
162
163
  - app/models/standard_id/authorization_code.rb
163
164
  - app/models/standard_id/browser_session.rb
@@ -197,7 +198,8 @@ files:
197
198
  - db/migrate/20250903063000_create_standard_id_authorization_codes.rb
198
199
  - db/migrate/20250907090000_create_standard_id_code_challenges.rb
199
200
  - db/migrate/20260311000000_add_provider_to_standard_id_identifiers.rb
200
- - db/migrate/20260311000000_create_standard_id_refresh_tokens.rb
201
+ - db/migrate/20260311100000_create_standard_id_refresh_tokens.rb
202
+ - db/migrate/20260311100001_add_nullify_to_refresh_token_previous_token_fk.rb
201
203
  - lib/generators/standard_id/install/install_generator.rb
202
204
  - lib/generators/standard_id/install/templates/standard_id.rb
203
205
  - lib/standard_config.rb