standard_id 0.8.0 → 0.8.1
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/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/standard_id/refresh_token.rb +86 -0
- data/app/models/standard_id/session.rb +12 -0
- data/db/migrate/20260311000000_add_provider_to_standard_id_identifiers.rb +15 -0
- data/db/migrate/20260311000000_create_standard_id_refresh_tokens.rb +20 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +1 -0
- data/lib/standard_id/config/schema.rb +1 -0
- 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 +66 -0
- data/lib/standard_id/oauth/token_grant_flow.rb +31 -2
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/session_manager.rb +13 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a6e84ca4e7d4d01aa3957ed1371ef774fc1203b9006da7591b99e20fadb343b4
|
|
4
|
+
data.tar.gz: cc74bfcf5f471d4bf7b76d621497598fac95fa0f833b256a74c3a4374456850b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 21932b0dd06f986340284587ccfae60cfea9b0e5075cdc670ff297e23412c0440d0e434e3974f98f65b9071fb15a118c3c36920e08b8887e9857fa612318e857
|
|
7
|
+
data.tar.gz: 7acf89c15d302da9e25da25de4c786441df085f65950a02e8b6fec71b4a90cf3f72c3167929371018869cc4787e6476d5df78036e3b2dde4833525d5c260045f
|
|
@@ -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
|
|
@@ -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,86 @@
|
|
|
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
|
+
update!(revoked_at: Time.current) unless revoked?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Revoke this token and all tokens in the same family chain.
|
|
41
|
+
# A "family" is all tokens linked via previous_token_id.
|
|
42
|
+
# Only revokes tokens that aren't already revoked, preserving historical
|
|
43
|
+
# revoked_at timestamps for audit purposes.
|
|
44
|
+
def revoke_family!
|
|
45
|
+
family_tokens.where(revoked_at: nil).update_all(revoked_at: Time.current)
|
|
46
|
+
end
|
|
47
|
+
|
|
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
|
+
private
|
|
53
|
+
|
|
54
|
+
# 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.
|
|
58
|
+
def family_tokens
|
|
59
|
+
root = self
|
|
60
|
+
depth = 0
|
|
61
|
+
while root.previous_token.present? && depth < MAX_FAMILY_DEPTH
|
|
62
|
+
root = root.previous_token
|
|
63
|
+
depth += 1
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
self.class.where(id: collect_family_ids(root.id))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def collect_family_ids(root_id)
|
|
70
|
+
ids = [root_id]
|
|
71
|
+
current_ids = [root_id]
|
|
72
|
+
depth = 0
|
|
73
|
+
|
|
74
|
+
while depth < MAX_FAMILY_DEPTH
|
|
75
|
+
next_ids = self.class.where(previous_token_id: current_ids).pluck(:id)
|
|
76
|
+
break if next_ids.empty?
|
|
77
|
+
|
|
78
|
+
ids.concat(next_ids)
|
|
79
|
+
current_ids = next_ids
|
|
80
|
+
depth += 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
ids
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
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
|
|
8
11
|
|
|
9
12
|
scope :active, -> { where(revoked_at: nil).where("expires_at > ?", Time.current) }
|
|
10
13
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
|
@@ -36,6 +39,9 @@ module StandardId
|
|
|
36
39
|
def revoke!(reason: nil)
|
|
37
40
|
@reason = reason
|
|
38
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)
|
|
39
45
|
end
|
|
40
46
|
|
|
41
47
|
private
|
|
@@ -52,6 +58,12 @@ module StandardId
|
|
|
52
58
|
self.lookup_hash = Digest::SHA256.hexdigest("#{token}:#{Rails.configuration.secret_key_base}")
|
|
53
59
|
end
|
|
54
60
|
|
|
61
|
+
# Revoke any still-active refresh tokens before the session row is deleted,
|
|
62
|
+
# so tokens don't become orphaned but usable.
|
|
63
|
+
def revoke_active_refresh_tokens
|
|
64
|
+
refresh_tokens.active.update_all(revoked_at: Time.current)
|
|
65
|
+
end
|
|
66
|
+
|
|
55
67
|
def just_revoked?
|
|
56
68
|
saved_change_to_revoked_at? && revoked?
|
|
57
69
|
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
|
|
@@ -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],
|
|
@@ -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
|
|
|
@@ -14,11 +14,66 @@ module StandardId
|
|
|
14
14
|
raise StandardId::InvalidGrantError, "Refresh token was not issued to this client"
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
validate_refresh_token_record!
|
|
17
18
|
validate_scope_narrowing!
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
private
|
|
21
22
|
|
|
23
|
+
def validate_refresh_token_record!
|
|
24
|
+
jti = @refresh_payload[:jti]
|
|
25
|
+
# Legacy tokens minted before jti tracking was added cannot be looked
|
|
26
|
+
# up or revoked through the RefreshToken model. This shim can be removed
|
|
27
|
+
# once all pre-jti tokens have expired (refresh_token_lifetime after deploy).
|
|
28
|
+
return if jti.blank?
|
|
29
|
+
|
|
30
|
+
@current_refresh_token_record = StandardId::RefreshToken.find_by_jti(jti)
|
|
31
|
+
|
|
32
|
+
unless @current_refresh_token_record
|
|
33
|
+
raise StandardId::InvalidGrantError, "Refresh token not found"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if @current_refresh_token_record.revoked?
|
|
37
|
+
# Reuse detected: this token was already rotated. Revoke entire family.
|
|
38
|
+
@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
|
+
)
|
|
45
|
+
raise StandardId::InvalidGrantError, "Refresh token reuse detected"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
unless @current_refresh_token_record.active?
|
|
49
|
+
raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
|
|
50
|
+
end
|
|
51
|
+
|
|
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
|
+
rows = StandardId::RefreshToken
|
|
56
|
+
.where(id: @current_refresh_token_record.id, revoked_at: nil)
|
|
57
|
+
.update_all(revoked_at: Time.current)
|
|
58
|
+
|
|
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"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
22
77
|
def subject_id
|
|
23
78
|
@refresh_payload[:sub]
|
|
24
79
|
end
|
|
@@ -46,6 +101,17 @@ module StandardId
|
|
|
46
101
|
@refresh_payload[:aud]
|
|
47
102
|
end
|
|
48
103
|
|
|
104
|
+
def refresh_token_session_id
|
|
105
|
+
@current_refresh_token_record&.session_id
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Returns the (now-revoked) token record so it can be linked as
|
|
109
|
+
# previous_token on the newly minted refresh token, maintaining the
|
|
110
|
+
# family chain for reuse detection.
|
|
111
|
+
def previous_refresh_token_record
|
|
112
|
+
@current_refresh_token_record
|
|
113
|
+
end
|
|
114
|
+
|
|
49
115
|
def validate_scope_narrowing!
|
|
50
116
|
return unless params[:scope].present?
|
|
51
117
|
|
|
@@ -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
|
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.8.
|
|
4
|
+
version: 0.8.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -153,6 +153,7 @@ 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
|
|
@@ -169,6 +170,7 @@ files:
|
|
|
169
170
|
- app/models/standard_id/identifier.rb
|
|
170
171
|
- app/models/standard_id/password_credential.rb
|
|
171
172
|
- app/models/standard_id/phone_number_identifier.rb
|
|
173
|
+
- app/models/standard_id/refresh_token.rb
|
|
172
174
|
- app/models/standard_id/service_session.rb
|
|
173
175
|
- app/models/standard_id/session.rb
|
|
174
176
|
- app/models/standard_id/username_identifier.rb
|
|
@@ -194,6 +196,8 @@ files:
|
|
|
194
196
|
- db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb
|
|
195
197
|
- db/migrate/20250903063000_create_standard_id_authorization_codes.rb
|
|
196
198
|
- db/migrate/20250907090000_create_standard_id_code_challenges.rb
|
|
199
|
+
- db/migrate/20260311000000_add_provider_to_standard_id_identifiers.rb
|
|
200
|
+
- db/migrate/20260311000000_create_standard_id_refresh_tokens.rb
|
|
197
201
|
- lib/generators/standard_id/install/install_generator.rb
|
|
198
202
|
- lib/generators/standard_id/install/templates/standard_id.rb
|
|
199
203
|
- lib/standard_config.rb
|