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 +4 -4
- 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/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 +9 -14
- data/app/models/standard_id/session.rb +7 -5
- data/db/migrate/20260311100001_add_nullify_to_refresh_token_previous_token_fk.rb +7 -0
- data/lib/standard_id/config/schema.rb +3 -3
- data/lib/standard_id/oauth/refresh_token_flow.rb +54 -24
- data/lib/standard_id/testing/factories/credentials.rb +2 -2
- data/lib/standard_id/version.rb +1 -1
- metadata +4 -2
- /data/db/migrate/{20260311000000_create_standard_id_refresh_tokens.rb → 20260311100000_create_standard_id_refresh_tokens.rb} +0 -0
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
|
|
@@ -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,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
|
|
|
@@ -34,7 +34,8 @@ module StandardId
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def revoke!
|
|
37
|
-
|
|
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
|
-
#
|
|
56
|
-
#
|
|
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
|
-
|
|
61
|
-
while root.previous_token.present?
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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:
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 { "
|
|
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
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
|
|
@@ -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/
|
|
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
|