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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 112cdb26b4fc96d4be1830bde78fada616b87a79f6d7adff9657df72d8942a57
4
- data.tar.gz: 4efeaf6a5f3f5bfdaf3747bebc0f5f9c3ccf22b02642c69e7b59c8a3d4aec74a
3
+ metadata.gz: a6e84ca4e7d4d01aa3957ed1371ef774fc1203b9006da7591b99e20fadb343b4
4
+ data.tar.gz: cc74bfcf5f471d4bf7b76d621497598fac95fa0f833b256a74c3a4374456850b
5
5
  SHA512:
6
- metadata.gz: eea26565c62749d409acbdf38a282f63edf16ed907872d9673fea58057ede62ea679adb1765cd1e55855a48ca67cdabc5baeb7fa52277529828366c560462e7e
7
- data.tar.gz: c250360c48f34dc9d52b6f1391e7816735dd1d3e9be63d7d80a0e8ae9b87d7ee178f8c78867eead4b7c792ac62b6a753ea90be73caf9e1f60e4c94415c57f1fd
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(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
@@ -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
@@ -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
 
@@ -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
- 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,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.8.0"
2
+ VERSION = "0.8.1"
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.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