standard_id 0.7.1 → 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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/standard_id/inertia_rendering.rb +16 -1
  3. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +72 -0
  4. data/app/controllers/concerns/standard_id/social_authentication.rb +42 -1
  5. data/app/controllers/concerns/standard_id/web_authentication.rb +7 -1
  6. data/app/controllers/concerns/standard_id/web_mechanism_gate.rb +28 -0
  7. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +20 -4
  8. data/app/controllers/standard_id/web/base_controller.rb +1 -0
  9. data/app/controllers/standard_id/web/login_controller.rb +10 -2
  10. data/app/controllers/standard_id/web/login_verify_controller.rb +16 -11
  11. data/app/controllers/standard_id/web/reset_password/confirm_controller.rb +1 -0
  12. data/app/controllers/standard_id/web/reset_password/start_controller.rb +1 -0
  13. data/app/controllers/standard_id/web/sessions_controller.rb +1 -0
  14. data/app/controllers/standard_id/web/signup_controller.rb +10 -2
  15. data/app/controllers/standard_id/web/verify_email/base_controller.rb +1 -0
  16. data/app/controllers/standard_id/web/verify_phone/base_controller.rb +1 -0
  17. data/app/jobs/standard_id/cleanup_expired_refresh_tokens_job.rb +16 -0
  18. data/app/models/concerns/standard_id/account_associations.rb +1 -0
  19. data/app/models/standard_id/refresh_token.rb +86 -0
  20. data/app/models/standard_id/session.rb +12 -0
  21. data/config/brakeman.ignore +10 -10
  22. data/db/migrate/20260311000000_add_provider_to_standard_id_identifiers.rb +15 -0
  23. data/db/migrate/20260311000000_create_standard_id_refresh_tokens.rb +20 -0
  24. data/lib/generators/standard_id/install/templates/standard_id.rb +1 -0
  25. data/lib/standard_id/config/schema.rb +28 -0
  26. data/lib/standard_id/engine.rb +14 -0
  27. data/lib/standard_id/errors.rb +21 -0
  28. data/lib/standard_id/events/definitions.rb +8 -2
  29. data/lib/standard_id/http_client.rb +54 -8
  30. data/lib/standard_id/jwt_service.rb +6 -2
  31. data/lib/standard_id/oauth/refresh_token_flow.rb +66 -0
  32. data/lib/standard_id/oauth/token_grant_flow.rb +31 -2
  33. data/lib/standard_id/version.rb +1 -1
  34. data/lib/standard_id/web/session_manager.rb +13 -1
  35. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 79293d8af473e409c125e4412da31b1438fc0099efea935116d9036978251395
4
- data.tar.gz: 568d1a2c768b0bbf17385c02781ec8193ef6a6b94da5118d1b4383755718ccbb
3
+ metadata.gz: a6e84ca4e7d4d01aa3957ed1371ef774fc1203b9006da7591b99e20fadb343b4
4
+ data.tar.gz: cc74bfcf5f471d4bf7b76d621497598fac95fa0f833b256a74c3a4374456850b
5
5
  SHA512:
6
- metadata.gz: 855acbd34604cd3a626d041daf889f3329259cdd23aab12e5f0055e1e75ddd55b728be1103b9b286602d045b1f37f34ef2be6251d96a77609e7e1f69414aa557
7
- data.tar.gz: 3c99a235eb70e8713093d8bc4231677c888eae0f392aed5ccd72bcc09f65147d74c3e6355b877c5928d27a5c40622df6c4e450030da2b2d9affdc5264258d4dd
6
+ metadata.gz: 21932b0dd06f986340284587ccfae60cfea9b0e5075cdc670ff297e23412c0440d0e434e3974f98f65b9071fb15a118c3c36920e08b8887e9857fa612318e857
7
+ data.tar.gz: 7acf89c15d302da9e25da25de4c786441df085f65950a02e8b6fec71b4a90cf3f72c3167929371018869cc4787e6476d5df78036e3b2dde4833525d5c260045f
@@ -42,8 +42,23 @@ module StandardId
42
42
  social_providers: {
43
43
  google_enabled: StandardId.config.google_client_id.present?,
44
44
  apple_enabled: StandardId.config.apple_client_id.present?
45
- }
45
+ },
46
+ enabled_mechanisms: web_enabled_mechanisms
46
47
  }.deep_merge(additional_props)
47
48
  end
49
+
50
+ def web_enabled_mechanisms
51
+ web = StandardId.config.web
52
+ {
53
+ password_login: web.password_login,
54
+ signup: web.signup,
55
+ passwordless_login: web.passwordless_login,
56
+ social_login: web.social_login,
57
+ password_reset: web.password_reset,
58
+ email_verification: web.email_verification,
59
+ phone_verification: web.phone_verification,
60
+ sessions_management: web.sessions_management
61
+ }
62
+ end
48
63
  end
49
64
  end
@@ -0,0 +1,72 @@
1
+ module StandardId
2
+ module LifecycleHooks
3
+ extend ActiveSupport::Concern
4
+
5
+ private
6
+
7
+ # Invoke the after_sign_in hook if configured.
8
+ #
9
+ # @param account [Object] the authenticated account
10
+ # @param context [Hash] context about the sign-in
11
+ # - :connection [String] "email", "password", or "social"
12
+ # - :provider [String, nil] e.g. "google", "apple", or nil
13
+ # - :first_sign_in [Boolean] whether this is the account's first browser session
14
+ # @return [String, nil] redirect path override, or nil for default
15
+ # @raise [StandardId::AuthenticationDenied] to reject the sign-in
16
+ def invoke_after_sign_in(account, context)
17
+ hook = StandardId.config.after_sign_in
18
+ return nil unless hook.respond_to?(:call)
19
+
20
+ context = context.merge(first_sign_in: first_sign_in?(account))
21
+ hook.call(account, request, context)
22
+ end
23
+
24
+ # Invoke the after_account_created hook if configured.
25
+ #
26
+ # @param account [Object] the newly created account
27
+ # @param context [Hash] context about the creation
28
+ # - :mechanism [String] "passwordless", "social", or "signup"
29
+ # - :provider [String, nil] e.g. "google", "apple", or nil
30
+ # @return [void]
31
+ def invoke_after_account_created(account, context)
32
+ hook = StandardId.config.after_account_created
33
+ return unless hook.respond_to?(:call)
34
+
35
+ hook.call(account, request, context)
36
+ end
37
+
38
+ # Determine if this is the account's first browser session.
39
+ # A count of 1 means the session just created is the only one.
40
+ def first_sign_in?(account)
41
+ account.sessions.where(type: "StandardId::BrowserSession").active.count <= 1
42
+ end
43
+
44
+ # Handle AuthenticationDenied by revoking the session and redirecting to login.
45
+ # If the account was just created, clean it up to avoid orphaned records.
46
+ #
47
+ # @param error [StandardId::AuthenticationDenied] the denial error
48
+ # @param account [Object, nil] the account to clean up if newly created
49
+ # @param newly_created [Boolean] whether the account was created during this request
50
+ def handle_authentication_denied(error, account: nil, newly_created: false)
51
+ session_manager.revoke_current_session!
52
+ destroy_newly_created_account(account) if newly_created
53
+ message = error.message
54
+ # When raised without arguments, StandardError#message returns the class name
55
+ message = "Sign-in was denied" if message.blank? || message == error.class.name
56
+ redirect_to StandardId::WebEngine.routes.url_helpers.login_path, alert: message
57
+ end
58
+
59
+ # Destroy a newly created account and all its dependents.
60
+ # Used when after_sign_in rejects a just-created account to avoid orphans.
61
+ def destroy_newly_created_account(account)
62
+ return unless account&.persisted?
63
+
64
+ ActiveRecord::Base.transaction do
65
+ account.sessions.destroy_all
66
+ account.identifiers.each { |i| i.credentials.destroy_all }
67
+ account.identifiers.destroy_all
68
+ account.destroy
69
+ end
70
+ end
71
+ end
72
+ end
@@ -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,28 @@
1
+ module StandardId
2
+ module WebMechanismGate
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ # Declares which web mechanism this controller requires.
7
+ # If the mechanism is disabled via config, requests return 404.
8
+ #
9
+ # class SignupController < BaseController
10
+ # requires_web_mechanism :signup
11
+ # end
12
+ def requires_web_mechanism(mechanism_name)
13
+ before_action -> { enforce_web_mechanism!(mechanism_name) }
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def enforce_web_mechanism!(mechanism_name)
20
+ unless StandardId.config.web.respond_to?(mechanism_name)
21
+ raise ArgumentError, "Unknown web mechanism: #{mechanism_name.inspect}. " \
22
+ "Valid mechanisms: #{StandardId.config.web.class.instance_methods(false).grep_v(/=/).sort.join(', ')}"
23
+ end
24
+
25
+ head :not_found unless StandardId.config.web.public_send(mechanism_name)
26
+ end
27
+ end
28
+ end
@@ -4,10 +4,12 @@ module StandardId
4
4
  module Callback
5
5
  class ProvidersController < StandardId::Web::BaseController
6
6
  public_controller
7
+ requires_web_mechanism :social_login
7
8
 
8
9
  include StandardId::WebAuthentication
9
10
  include StandardId::SocialAuthentication
10
11
  include StandardId::Web::SocialLoginParams
12
+ include StandardId::LifecycleHooks
11
13
 
12
14
  # Social callbacks must be accessible without an existing browser session
13
15
  # because they create/sign-in the session upon successful callback.
@@ -28,21 +30,35 @@ module StandardId
28
30
  provider_response = get_user_info_from_provider(redirect_uri:, nonce:)
29
31
  social_info = provider_response[:user_info]
30
32
  provider_tokens = provider_response[:tokens]
31
- account = find_or_create_account_from_social(social_info)
33
+ begin
34
+ account = find_or_create_account_from_social(social_info)
35
+ rescue ActiveRecord::RecordNotUnique
36
+ # Race condition: concurrent request created the account first — retry to find it
37
+ account = find_or_create_account_from_social(social_info)
38
+ end
39
+ newly_created = account.previously_new_record?
32
40
  session_manager.sign_in_account(account)
33
41
 
42
+ provider_name = provider.provider_name
43
+ invoke_after_account_created(account, { mechanism: "social", provider: provider_name }) if newly_created
44
+
34
45
  run_social_callback(
35
- provider: provider.provider_name,
46
+ provider: provider_name,
36
47
  social_info: social_info,
37
48
  provider_tokens: provider_tokens,
38
49
  account: account,
39
50
  original_request_params: state_data
40
51
  )
41
52
 
42
- destination = state_data["redirect_uri"]
43
- redirect_options = { notice: "Successfully signed in with #{provider.provider_name.humanize}" }
53
+ context = { connection: "social", provider: provider_name }
54
+ redirect_override = invoke_after_sign_in(account, context)
55
+
56
+ destination = redirect_override || state_data["redirect_uri"]
57
+ redirect_options = { notice: "Successfully signed in with #{provider_name.humanize}" }
44
58
  redirect_options[:allow_other_host] = true if allow_other_host_redirect?(destination)
45
59
  redirect_to destination, redirect_options
60
+ rescue StandardId::AuthenticationDenied => e
61
+ handle_authentication_denied(e, account: account, newly_created: newly_created)
46
62
  rescue StandardId::OAuthError => e
47
63
  redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
48
64
  end
@@ -4,6 +4,7 @@ module StandardId
4
4
  include StandardId::ControllerPolicy
5
5
  include StandardId::WebAuthentication
6
6
  include StandardId::SetCurrentRequestDetails
7
+ include StandardId::WebMechanismGate
7
8
 
8
9
  include StandardId::WebEngine.routes.url_helpers
9
10
  helper StandardId::WebEngine.routes.url_helpers
@@ -6,6 +6,7 @@ module StandardId
6
6
  include StandardId::InertiaRendering
7
7
  include StandardId::Web::SocialLoginParams
8
8
  include StandardId::PasswordlessStrategy
9
+ include StandardId::LifecycleHooks
9
10
 
10
11
  layout "public"
11
12
 
@@ -32,16 +33,23 @@ module StandardId
32
33
  private
33
34
 
34
35
  def passwordless_enabled?
35
- StandardId.config.passwordless.enabled
36
+ StandardId.config.web.passwordless_login
36
37
  end
37
38
 
38
39
  def handle_password_login
40
+ return head(:not_found) unless StandardId.config.web.password_login
41
+
39
42
  if sign_in_account(login_params)
40
- redirect_to params[:redirect_uri] || after_authentication_url, status: :see_other, notice: "Successfully signed in"
43
+ context = { connection: "password", provider: nil }
44
+ redirect_override = invoke_after_sign_in(current_account, context)
45
+ destination = redirect_override || params[:redirect_uri] || after_authentication_url
46
+ redirect_to destination, status: :see_other, notice: "Successfully signed in"
41
47
  else
42
48
  flash.now[:alert] = "Invalid email or password"
43
49
  render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
44
50
  end
51
+ rescue StandardId::AuthenticationDenied => e
52
+ handle_authentication_denied(e)
45
53
  end
46
54
 
47
55
  def handle_passwordless_login
@@ -2,14 +2,14 @@ module StandardId
2
2
  module Web
3
3
  class LoginVerifyController < BaseController
4
4
  public_controller
5
+ requires_web_mechanism :passwordless_login
5
6
 
6
7
  include StandardId::InertiaRendering
8
+ include StandardId::LifecycleHooks
7
9
 
8
10
  layout "public"
9
11
 
10
12
  skip_before_action :require_browser_session!, only: [:show, :update]
11
-
12
- before_action :ensure_passwordless_enabled!
13
13
  before_action :redirect_if_authenticated, only: [:show]
14
14
  before_action :require_otp_payload!
15
15
 
@@ -39,23 +39,28 @@ module StandardId
39
39
  return
40
40
  end
41
41
 
42
- session_manager.sign_in_account(result.account)
43
- emit_authentication_succeeded(result.account)
42
+ account = result.account
43
+ newly_created = account.previously_new_record?
44
44
 
45
- session.delete(:standard_id_otp_payload)
45
+ session_manager.sign_in_account(account)
46
+ emit_authentication_succeeded(account)
46
47
 
47
- redirect_to after_authentication_url, status: :see_other, notice: "Successfully signed in"
48
- end
48
+ invoke_after_account_created(account, { mechanism: "passwordless", provider: nil }) if newly_created
49
49
 
50
- private
50
+ context = { connection: @otp_data[:connection], provider: nil }
51
+ redirect_override = invoke_after_sign_in(account, context)
51
52
 
52
- def ensure_passwordless_enabled!
53
- return if StandardId.config.passwordless.enabled
53
+ session.delete(:standard_id_otp_payload)
54
54
 
55
+ destination = redirect_override || after_authentication_url
56
+ redirect_to destination, status: :see_other, notice: "Successfully signed in"
57
+ rescue StandardId::AuthenticationDenied => e
55
58
  session.delete(:standard_id_otp_payload)
56
- redirect_to login_path, alert: "Passwordless login is not available"
59
+ handle_authentication_denied(e, account: account, newly_created: newly_created)
57
60
  end
58
61
 
62
+ private
63
+
59
64
  def redirect_if_authenticated
60
65
  redirect_to after_authentication_url, status: :see_other if authenticated?
61
66
  end
@@ -3,6 +3,7 @@ module StandardId
3
3
  module ResetPassword
4
4
  class ConfirmController < BaseController
5
5
  public_controller
6
+ requires_web_mechanism :password_reset
6
7
 
7
8
  layout "public"
8
9
 
@@ -3,6 +3,7 @@ module StandardId
3
3
  module ResetPassword
4
4
  class StartController < BaseController
5
5
  public_controller
6
+ requires_web_mechanism :password_reset
6
7
 
7
8
  layout "public"
8
9
 
@@ -2,6 +2,7 @@ module StandardId
2
2
  module Web
3
3
  class SessionsController < BaseController
4
4
  authenticated_controller
5
+ requires_web_mechanism :sessions_management
5
6
 
6
7
  def index
7
8
  @sessions = current_account.sessions.active.order(created_at: :desc)
@@ -2,8 +2,10 @@ module StandardId
2
2
  module Web
3
3
  class SignupController < BaseController
4
4
  public_controller
5
+ requires_web_mechanism :signup
5
6
 
6
7
  include StandardId::InertiaRendering
8
+ include StandardId::LifecycleHooks
7
9
 
8
10
  layout "public"
9
11
 
@@ -38,14 +40,20 @@ module StandardId
38
40
 
39
41
  if form.submit
40
42
  session_manager.sign_in_account(form.account)
41
- redirect_to params[:redirect_uri] || after_authentication_url,
42
- notice: "Account created successfully"
43
+ invoke_after_account_created(form.account, { mechanism: "signup", provider: nil })
44
+
45
+ context = { connection: "password", provider: nil }
46
+ redirect_override = invoke_after_sign_in(form.account, context)
47
+ destination = redirect_override || params[:redirect_uri] || after_authentication_url
48
+ redirect_to destination, notice: "Account created successfully"
43
49
  else
44
50
  @redirect_uri = params[:redirect_uri] || after_authentication_url
45
51
  @connection = params[:connection]
46
52
  flash.now[:alert] = form.errors.full_messages.join(", ")
47
53
  render_with_inertia action: :show, props: auth_page_props(errors: form.errors.to_hash), status: :unprocessable_content
48
54
  end
55
+ rescue StandardId::AuthenticationDenied => e
56
+ handle_authentication_denied(e, account: form.account, newly_created: form.account&.previously_new_record?)
49
57
  end
50
58
 
51
59
  def social_signup_url
@@ -3,6 +3,7 @@ module StandardId
3
3
  module VerifyEmail
4
4
  class BaseController < StandardId::Web::BaseController
5
5
  public_controller
6
+ requires_web_mechanism :email_verification
6
7
 
7
8
  skip_before_action :require_browser_session!
8
9
  end
@@ -3,6 +3,7 @@ module StandardId
3
3
  module VerifyPhone
4
4
  class BaseController < StandardId::Web::BaseController
5
5
  public_controller
6
+ requires_web_mechanism :phone_verification
6
7
 
7
8
  skip_before_action :require_browser_session!
8
9
  end
@@ -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
@@ -1,9 +1,5 @@
1
1
  {
2
2
  "ignored_warnings": [
3
- {
4
- "fingerprint": "24fc02735a2ad863d6bf1171a4a329b208e9e7c41841fa0149d8e6878d4ce299",
5
- "note": "Auth engine intentionally redirects to params[:redirect_uri] after signup for OAuth/post-auth flow"
6
- },
7
3
  {
8
4
  "fingerprint": "6b35e9906d62a9b9cd0dff9cf53924d40e74bc4f96cfccf27e67e93551113243",
9
5
  "note": "Auth engine intentionally redirects to params[:redirect_uri] after logout for OAuth/post-auth flow"
@@ -16,15 +12,19 @@
16
12
  "fingerprint": "bdbc72619da2ba771b1185ccf16acce801066689bf51adf116eab8c8714b39af",
17
13
  "note": "HEAD vs GET distinction is inconsequential here; storing return URL on GET-only is safe"
18
14
  },
19
- {
20
- "fingerprint": "16bd6ec7c3fa130eb80c15fc90c87f9859d89b37258807bfaffe4101366611a6",
21
- "note": "Auth engine intentionally redirects to params[:redirect_uri] after login for OAuth/post-auth flow"
22
- },
23
15
  {
24
16
  "fingerprint": "e4f96cb212c73c3165c3db6eaa6368c29d362b61264f034e80c9fa6705d72e5b",
25
17
  "note": "Auth engine intentionally redirects to params[:redirect_uri] when user is not authenticated"
18
+ },
19
+ {
20
+ "fingerprint": "68be03a57d3ef2cfb68582fc78ac2eb6b96aaa0a9897a9a975c24b889fdbb2aa",
21
+ "note": "after_sign_in hook redirect is controlled by the host app configuration, not user input"
22
+ },
23
+ {
24
+ "fingerprint": "277cf277d1c94f46d0abaeba9c51312d1d17e6f62c2e8d457dda47a6aad422aa",
25
+ "note": "after_sign_in hook redirect is controlled by the host app configuration, not user input"
26
26
  }
27
27
  ],
28
- "updated": "2026-02-27",
29
- "brakeman_version": "8.0.2"
28
+ "updated": "2026-03-21",
29
+ "brakeman_version": "8.0.4"
30
30
  }
@@ -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],
@@ -21,6 +21,20 @@ StandardConfig.schema.draw do
21
21
  # Callable (lambda/proc) that returns a Hash of extra Sentry user context fields.
22
22
  # Receives (account, session) where session may be nil. Non-callable values are ignored.
23
23
  field :sentry_context, type: :any, default: nil
24
+
25
+ # Post-authentication lifecycle hooks (synchronous, WebEngine only)
26
+ #
27
+ # after_sign_in: Called after successful sign-in, before redirect.
28
+ # Receives: (account, request, context)
29
+ # Context: { first_sign_in: bool, connection: "email"/"password"/"social", provider: nil/"google"/"apple" }
30
+ # Return: nil (default redirect) or a path string (override redirect)
31
+ # Raise StandardId::AuthenticationDenied.new("message") to reject sign-in.
32
+ field :after_sign_in, type: :any, default: nil
33
+
34
+ # after_account_created: Called after a new account is created via any mechanism.
35
+ # Receives: (account, request, context)
36
+ # Context: { mechanism: "passwordless"/"social"/"signup", provider: nil/"google"/"apple" }
37
+ field :after_account_created, type: :any, default: nil
24
38
  end
25
39
 
26
40
  scope :events do
@@ -28,6 +42,8 @@ StandardConfig.schema.draw do
28
42
  end
29
43
 
30
44
  scope :passwordless do
45
+ # Deprecated: use web.passwordless_login to control WebEngine passwordless login.
46
+ # Retained for backwards compatibility with consuming apps that set this field.
31
47
  field :enabled, type: :boolean, default: false
32
48
  field :connection, type: :string, default: "email"
33
49
  field :code_ttl, type: :integer, default: 600 # 10 minutes in seconds
@@ -80,5 +96,17 @@ StandardConfig.schema.draw do
80
96
  field :social_account_attributes, type: :any, default: nil
81
97
  field :allowed_redirect_url_prefixes, type: :array, default: []
82
98
  field :available_scopes, type: :array, default: -> { [] }
99
+ field :link_strategy, type: :symbol, default: :strict
100
+ end
101
+
102
+ scope :web do
103
+ field :password_login, type: :boolean, default: true
104
+ field :signup, type: :boolean, default: true
105
+ field :passwordless_login, type: :boolean, default: false
106
+ field :social_login, type: :boolean, default: true
107
+ field :password_reset, type: :boolean, default: true
108
+ field :email_verification, type: :boolean, default: true
109
+ field :phone_verification, type: :boolean, default: true
110
+ field :sessions_management, type: :boolean, default: true
83
111
  end
84
112
  end
@@ -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
@@ -70,6 +70,27 @@ module StandardId
70
70
  def oauth_error_code = :unsupported_response_type
71
71
  end
72
72
 
73
+ # Lifecycle hook errors
74
+ class AuthenticationDenied < StandardError; end
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
+
73
94
  # Audience verification errors
74
95
  class InvalidAudienceError < StandardError
75
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.7.1"
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.7.1
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -112,12 +112,14 @@ files:
112
112
  - app/controllers/concerns/standard_id/controller_policy.rb
113
113
  - app/controllers/concerns/standard_id/inertia_rendering.rb
114
114
  - app/controllers/concerns/standard_id/inertia_support.rb
115
+ - app/controllers/concerns/standard_id/lifecycle_hooks.rb
115
116
  - app/controllers/concerns/standard_id/passwordless_strategy.rb
116
117
  - app/controllers/concerns/standard_id/sentry_context.rb
117
118
  - app/controllers/concerns/standard_id/set_current_request_details.rb
118
119
  - app/controllers/concerns/standard_id/social_authentication.rb
119
120
  - app/controllers/concerns/standard_id/web/social_login_params.rb
120
121
  - app/controllers/concerns/standard_id/web_authentication.rb
122
+ - app/controllers/concerns/standard_id/web_mechanism_gate.rb
121
123
  - app/controllers/standard_id/api/authorization_controller.rb
122
124
  - app/controllers/standard_id/api/base_controller.rb
123
125
  - app/controllers/standard_id/api/oauth/base_controller.rb
@@ -151,6 +153,7 @@ files:
151
153
  - app/forms/standard_id/web/signup_form.rb
152
154
  - app/helpers/standard_id/application_helper.rb
153
155
  - app/jobs/standard_id/application_job.rb
156
+ - app/jobs/standard_id/cleanup_expired_refresh_tokens_job.rb
154
157
  - app/jobs/standard_id/cleanup_expired_sessions_job.rb
155
158
  - app/mailers/standard_id/application_mailer.rb
156
159
  - app/models/concerns/standard_id/account_associations.rb
@@ -167,6 +170,7 @@ files:
167
170
  - app/models/standard_id/identifier.rb
168
171
  - app/models/standard_id/password_credential.rb
169
172
  - app/models/standard_id/phone_number_identifier.rb
173
+ - app/models/standard_id/refresh_token.rb
170
174
  - app/models/standard_id/service_session.rb
171
175
  - app/models/standard_id/session.rb
172
176
  - app/models/standard_id/username_identifier.rb
@@ -192,6 +196,8 @@ files:
192
196
  - db/migrate/20250901134520_create_standard_id_client_secret_credentials.rb
193
197
  - db/migrate/20250903063000_create_standard_id_authorization_codes.rb
194
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
195
201
  - lib/generators/standard_id/install/install_generator.rb
196
202
  - lib/generators/standard_id/install/templates/standard_id.rb
197
203
  - lib/standard_config.rb
@@ -265,6 +271,7 @@ metadata:
265
271
  homepage_uri: https://github.com/rarebit-one/standard_id
266
272
  source_code_uri: https://github.com/rarebit-one/standard_id
267
273
  changelog_uri: https://github.com/rarebit-one/standard_id/blob/main/CHANGELOG.md
274
+ bug_tracker_uri: https://github.com/rarebit-one/standard_id/issues
268
275
  rdoc_options: []
269
276
  require_paths:
270
277
  - lib