mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 06:21:23 +00:00
feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support… (#894)
* feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support self-signed certificates in self-hosted environments * fix: NoMethodError by defining SSL helper methods before configure block executes * refactor: Refactor SessionsController to use shared SslConfigurable module and simplify SSL initializer redundant checks * refactor: improve SSL configuration robustness and error detection accuracy * fix:HTTParty SSL options, add file validation guards, prevent Tempfile GC, and redact URLs in error logs * fix: Fix SSL concern indentation and stub Simplefin POST correctly in tests * fix: normalize ssl_verify to always return boolean instead of nil * fix: solve failing SimpleFin test * refactor: trim unused error-handling code from SslConfigurable, replace Tempfile with fixed-path CA bundle, fix namespace pollution in initializers, and add unit tests for core SSL configuration and Langfuse CRL callback. * fix: added require ileutils in the initializer and require ostruct in the test file. * fix: solve autoload conflict that broke provider loading, validate all certs in PEM bundles, and add missing requires.
This commit is contained in:
@@ -48,3 +48,36 @@ LANGFUSE_HOST = https://cloud.langfuse.com
|
||||
|
||||
# Set to `true` to get error messages rendered in the /chats UI
|
||||
AI_DEBUG_MODE =
|
||||
|
||||
# =============================================================================
|
||||
# SSL/TLS Configuration for Self-Signed Certificates
|
||||
# =============================================================================
|
||||
# Use these settings when connecting to services with self-signed or internal
|
||||
# CA certificates (e.g., self-hosted Keycloak, Authentik, or AI endpoints).
|
||||
#
|
||||
# SSL_CA_FILE: Path to custom CA certificate file (PEM format)
|
||||
# - The certificate that signed your server's SSL certificate
|
||||
# - Must be readable by the application
|
||||
# - Will be validated at startup
|
||||
# SSL_CA_FILE = /certs/my-ca.crt
|
||||
#
|
||||
# SSL_VERIFY: Enable/disable SSL certificate verification
|
||||
# - Default: true (verification enabled)
|
||||
# - Set to "false" ONLY for development/testing
|
||||
# - WARNING: Disabling removes protection against man-in-the-middle attacks
|
||||
# SSL_VERIFY = true
|
||||
#
|
||||
# SSL_DEBUG: Enable verbose SSL logging for troubleshooting
|
||||
# - Default: false
|
||||
# - When enabled, logs detailed SSL connection information
|
||||
# - Useful for diagnosing certificate issues
|
||||
# SSL_DEBUG = false
|
||||
#
|
||||
# Example docker-compose.yml configuration:
|
||||
# services:
|
||||
# app:
|
||||
# environment:
|
||||
# SSL_CA_FILE: /certs/my-ca.crt
|
||||
# SSL_DEBUG: "true"
|
||||
# volumes:
|
||||
# - ./my-ca.crt:/certs/my-ca.crt:ro
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class SessionsController < ApplicationController
|
||||
extend SslConfigurable
|
||||
|
||||
before_action :set_session, only: :destroy
|
||||
skip_authentication only: %i[index new create openid_connect failure post_logout mobile_sso_start]
|
||||
|
||||
@@ -305,7 +307,7 @@ class SessionsController < ApplicationController
|
||||
if provider_config[:strategy] == "openid_connect" && provider_config[:issuer].present?
|
||||
begin
|
||||
discovery_url = discovery_url_for(provider_config[:issuer])
|
||||
response = Faraday.get(discovery_url) do |req|
|
||||
response = Faraday.new(ssl: self.class.faraday_ssl_options).get(discovery_url) do |req|
|
||||
req.options.timeout = 5
|
||||
req.options.open_timeout = 3
|
||||
end
|
||||
|
||||
126
app/models/concerns/ssl_configurable.rb
Normal file
126
app/models/concerns/ssl_configurable.rb
Normal file
@@ -0,0 +1,126 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Provides centralized SSL configuration for HTTP clients.
|
||||
#
|
||||
# This module enables support for self-signed certificates in self-hosted
|
||||
# environments by reading configuration from Rails.configuration.x.ssl.
|
||||
#
|
||||
# Features:
|
||||
# - Custom CA certificate support for self-signed certificates
|
||||
# - Optional SSL verification bypass (for development/testing only)
|
||||
# - Debug logging for troubleshooting SSL issues
|
||||
#
|
||||
# Usage (extend for class methods — the only supported pattern):
|
||||
# class MyHttpClient
|
||||
# extend SslConfigurable
|
||||
#
|
||||
# def self.make_request
|
||||
# Faraday.new(url, ssl: faraday_ssl_options) { |f| ... }
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# Environment Variables (configured in config/initializers/00_ssl.rb):
|
||||
# SSL_CA_FILE - Path to custom CA certificate file (PEM format)
|
||||
# SSL_VERIFY - Set to "false" to disable SSL verification
|
||||
# SSL_DEBUG - Set to "true" to enable verbose SSL logging
|
||||
module SslConfigurable
|
||||
# Returns SSL options hash for Faraday connections
|
||||
#
|
||||
# @return [Hash] SSL options for Faraday
|
||||
# @example
|
||||
# Faraday.new(url, ssl: faraday_ssl_options) do |f|
|
||||
# f.request :json
|
||||
# f.response :raise_error
|
||||
# end
|
||||
def faraday_ssl_options
|
||||
options = {}
|
||||
|
||||
options[:verify] = ssl_verify?
|
||||
|
||||
if ssl_ca_file.present?
|
||||
options[:ca_file] = ssl_ca_file
|
||||
log_ssl_debug("Faraday SSL: Using custom CA file: #{ssl_ca_file}")
|
||||
end
|
||||
|
||||
log_ssl_debug("Faraday SSL: Verification disabled") unless ssl_verify?
|
||||
log_ssl_debug("Faraday SSL options: #{options.inspect}") if options.present?
|
||||
|
||||
options
|
||||
end
|
||||
|
||||
# Returns SSL options hash for HTTParty requests
|
||||
#
|
||||
# @return [Hash] SSL options for HTTParty
|
||||
# @example
|
||||
# class MyProvider
|
||||
# include HTTParty
|
||||
# extend SslConfigurable
|
||||
# default_options.merge!(httparty_ssl_options)
|
||||
# end
|
||||
def httparty_ssl_options
|
||||
options = { verify: ssl_verify? }
|
||||
|
||||
if ssl_ca_file.present?
|
||||
options[:ssl_ca_file] = ssl_ca_file
|
||||
log_ssl_debug("HTTParty SSL: Using custom CA file: #{ssl_ca_file}")
|
||||
end
|
||||
|
||||
log_ssl_debug("HTTParty SSL: Verification disabled") unless ssl_verify?
|
||||
|
||||
options
|
||||
end
|
||||
|
||||
# Returns SSL verify mode for Net::HTTP
|
||||
#
|
||||
# @return [Integer] OpenSSL verify mode constant (VERIFY_PEER or VERIFY_NONE)
|
||||
# @example
|
||||
# http = Net::HTTP.new(uri.host, uri.port)
|
||||
# http.use_ssl = true
|
||||
# http.verify_mode = net_http_verify_mode
|
||||
# http.ca_file = ssl_ca_file if ssl_ca_file.present?
|
||||
def net_http_verify_mode
|
||||
mode = ssl_verify? ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
||||
log_ssl_debug("Net::HTTP verify mode: #{mode == OpenSSL::SSL::VERIFY_PEER ? 'VERIFY_PEER' : 'VERIFY_NONE'}")
|
||||
mode
|
||||
end
|
||||
|
||||
# Returns CA file path if configured
|
||||
#
|
||||
# @return [String, nil] Path to CA file or nil if not configured
|
||||
def ssl_ca_file
|
||||
ssl_configuration.ca_file
|
||||
end
|
||||
|
||||
# Returns whether SSL verification is enabled
|
||||
# nil or true both mean verification is enabled; only explicit false disables it
|
||||
#
|
||||
# @return [Boolean] true if SSL verification is enabled
|
||||
def ssl_verify?
|
||||
ssl_configuration.verify != false
|
||||
end
|
||||
|
||||
# Returns whether SSL debug logging is enabled
|
||||
#
|
||||
# @return [Boolean] true if debug logging is enabled
|
||||
def ssl_debug?
|
||||
ssl_configuration.debug == true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns the SSL configuration from Rails
|
||||
#
|
||||
# @return [ActiveSupport::OrderedOptions] SSL configuration
|
||||
def ssl_configuration
|
||||
Rails.configuration.x.ssl
|
||||
end
|
||||
|
||||
# Logs a debug message if SSL debug mode is enabled
|
||||
#
|
||||
# @param message [String] Message to log
|
||||
def log_ssl_debug(message)
|
||||
return unless ssl_debug?
|
||||
|
||||
Rails.logger.debug("[SSL Debug] #{message}")
|
||||
end
|
||||
end
|
||||
@@ -1,9 +1,30 @@
|
||||
class Eval::Langfuse::Client
|
||||
extend SslConfigurable
|
||||
|
||||
BASE_URLS = {
|
||||
us: "https://us.cloud.langfuse.com/api/public",
|
||||
eu: "https://cloud.langfuse.com/api/public"
|
||||
}.freeze
|
||||
|
||||
# OpenSSL 3.x version threshold for CRL workaround
|
||||
# See: https://github.com/ruby/openssl/issues/619
|
||||
OPENSSL_3_VERSION = 0x30000000
|
||||
|
||||
# CRL-related OpenSSL error codes that can be safely bypassed
|
||||
# These errors occur when CRL (Certificate Revocation List) is unavailable
|
||||
def self.crl_errors
|
||||
@crl_errors ||= begin
|
||||
errors = [
|
||||
OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL,
|
||||
OpenSSL::X509::V_ERR_CRL_HAS_EXPIRED,
|
||||
OpenSSL::X509::V_ERR_CRL_NOT_YET_VALID
|
||||
]
|
||||
# V_ERR_UNABLE_TO_GET_CRL_ISSUER may not exist in all OpenSSL versions
|
||||
errors << OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL_ISSUER if defined?(OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL_ISSUER)
|
||||
errors.freeze
|
||||
end
|
||||
end
|
||||
|
||||
class Error < StandardError; end
|
||||
class ConfigurationError < Error; end
|
||||
class ApiError < Error
|
||||
@@ -176,12 +197,24 @@ class Eval::Langfuse::Client
|
||||
http.read_timeout = 30
|
||||
http.open_timeout = 10
|
||||
|
||||
# Fix for OpenSSL 3.x CRL checking issues
|
||||
# Apply SSL configuration from centralized config
|
||||
http.verify_mode = self.class.net_http_verify_mode
|
||||
http.ca_file = self.class.ssl_ca_file if self.class.ssl_ca_file.present?
|
||||
|
||||
# Fix for OpenSSL 3.x CRL checking issues (only when verification is enabled)
|
||||
# See: https://github.com/ruby/openssl/issues/619
|
||||
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
||||
if OpenSSL::OPENSSL_VERSION_NUMBER >= 0x30000000
|
||||
# Disable CRL checking which can fail on some certificates
|
||||
http.verify_callback = ->(_preverify_ok, _store_ctx) { true }
|
||||
# Only bypass CRL-specific errors, not all certificate verification
|
||||
if self.class.ssl_verify? && OpenSSL::OPENSSL_VERSION_NUMBER >= OPENSSL_3_VERSION
|
||||
crl_error_codes = self.class.crl_errors
|
||||
http.verify_callback = ->(preverify_ok, store_ctx) {
|
||||
# Bypass only CRL-specific errors (these fail when CRL is unavailable)
|
||||
# For all other errors, preserve the original verification result
|
||||
if crl_error_codes.include?(store_ctx.error)
|
||||
true
|
||||
else
|
||||
preverify_ok
|
||||
end
|
||||
}
|
||||
end
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
class Provider::Coinbase
|
||||
include HTTParty
|
||||
extend SslConfigurable
|
||||
|
||||
class Error < StandardError; end
|
||||
class AuthenticationError < Error; end
|
||||
class RateLimitError < Error; end
|
||||
@@ -7,6 +10,9 @@ class Provider::Coinbase
|
||||
# CDP API base URL
|
||||
API_BASE_URL = "https://api.coinbase.com".freeze
|
||||
|
||||
base_uri API_BASE_URL
|
||||
default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options))
|
||||
|
||||
attr_reader :api_key, :api_secret
|
||||
|
||||
def initialize(api_key:, api_secret:)
|
||||
@@ -57,7 +63,8 @@ class Provider::Coinbase
|
||||
# Get spot price for a currency pair (e.g., "BTC-USD")
|
||||
# This is a public endpoint that doesn't require authentication
|
||||
def get_spot_price(currency_pair)
|
||||
response = HTTParty.get("#{API_BASE_URL}/v2/prices/#{currency_pair}/spot", timeout: 10)
|
||||
# Use self.class.get to inherit class-level SSL and timeout defaults
|
||||
response = self.class.get("/v2/prices/#{currency_pair}/spot", timeout: 10)
|
||||
handle_response(response)["data"]
|
||||
rescue => e
|
||||
Rails.logger.warn("Coinbase: Failed to fetch spot price for #{currency_pair}: #{e.message}")
|
||||
@@ -78,13 +85,13 @@ class Provider::Coinbase
|
||||
private
|
||||
|
||||
def get(path, params: {})
|
||||
url = "#{API_BASE_URL}#{path}"
|
||||
url = path
|
||||
url += "?#{params.to_query}" if params.any?
|
||||
|
||||
response = HTTParty.get(
|
||||
# Use self.class.get to inherit class-level SSL and timeout defaults
|
||||
response = self.class.get(
|
||||
url,
|
||||
headers: auth_headers("GET", path),
|
||||
timeout: 30
|
||||
headers: auth_headers("GET", path)
|
||||
)
|
||||
|
||||
handle_response(response)
|
||||
@@ -101,16 +108,14 @@ class Provider::Coinbase
|
||||
uri = URI.parse(next_uri)
|
||||
current_path = uri.path
|
||||
current_path += "?#{uri.query}" if uri.query
|
||||
url = "#{API_BASE_URL}#{current_path}"
|
||||
else
|
||||
current_path = path
|
||||
url = "#{API_BASE_URL}#{path}"
|
||||
end
|
||||
|
||||
response = HTTParty.get(
|
||||
url,
|
||||
headers: auth_headers("GET", current_path.split("?").first),
|
||||
timeout: 30
|
||||
# Use self.class.get to inherit class-level SSL and timeout defaults
|
||||
response = self.class.get(
|
||||
current_path,
|
||||
headers: auth_headers("GET", current_path.split("?").first)
|
||||
)
|
||||
|
||||
data = handle_response(response)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Handles authentication and requests to the CoinStats OpenAPI.
|
||||
class Provider::Coinstats < Provider
|
||||
include HTTParty
|
||||
extend SslConfigurable
|
||||
|
||||
# Subclass so errors caught in this provider are raised as Provider::Coinstats::Error
|
||||
Error = Class.new(Provider::Error)
|
||||
@@ -9,7 +10,7 @@ class Provider::Coinstats < Provider
|
||||
BASE_URL = "https://openapiv1.coinstats.app"
|
||||
|
||||
headers "User-Agent" => "Sure Finance CoinStats Client (https://github.com/we-promise/sure)"
|
||||
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
|
||||
default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
|
||||
|
||||
attr_reader :api_key
|
||||
|
||||
|
||||
@@ -2,11 +2,12 @@ require "cgi"
|
||||
|
||||
class Provider::EnableBanking
|
||||
include HTTParty
|
||||
extend SslConfigurable
|
||||
|
||||
BASE_URL = "https://api.enablebanking.com".freeze
|
||||
|
||||
headers "User-Agent" => "Sure Finance Enable Banking Client"
|
||||
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
|
||||
default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
|
||||
|
||||
attr_reader :application_id, :private_key
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
class Provider::Lunchflow
|
||||
include HTTParty
|
||||
extend SslConfigurable
|
||||
|
||||
headers "User-Agent" => "Sure Finance Lunch Flow Client"
|
||||
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
|
||||
default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
|
||||
|
||||
attr_reader :api_key, :base_url
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
class Provider::Mercury
|
||||
include HTTParty
|
||||
extend SslConfigurable
|
||||
|
||||
headers "User-Agent" => "Sure Finance Mercury Client"
|
||||
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
|
||||
default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
|
||||
|
||||
attr_reader :token, :base_url
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ class Provider::Simplefin
|
||||
# These are centralized in `Rails.configuration.x.simplefin.*` via
|
||||
# `config/initializers/simplefin.rb`.
|
||||
include HTTParty
|
||||
extend SslConfigurable
|
||||
|
||||
headers "User-Agent" => "Sure Finance SimpleFin Client"
|
||||
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
|
||||
default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
|
||||
|
||||
# Retry configuration for transient network failures
|
||||
MAX_RETRIES = 3
|
||||
@@ -34,8 +35,9 @@ class Provider::Simplefin
|
||||
|
||||
# Use retry logic for transient network failures during token claim
|
||||
# Claim should be fast; keep request-path latency bounded.
|
||||
# Use self.class.post to inherit class-level SSL and timeout defaults
|
||||
response = with_retries("POST /claim", max_retries: 1, sleep: false) do
|
||||
HTTParty.post(claim_url, timeout: 15)
|
||||
self.class.post(claim_url, timeout: 15)
|
||||
end
|
||||
|
||||
case response.code
|
||||
@@ -71,8 +73,9 @@ class Provider::Simplefin
|
||||
|
||||
# The access URL already contains HTTP Basic Auth credentials
|
||||
# Use retry logic with exponential backoff for transient network failures
|
||||
# Use self.class.get to inherit class-level SSL and timeout defaults
|
||||
response = with_retries("GET /accounts") do
|
||||
HTTParty.get(accounts_url)
|
||||
self.class.get(accounts_url)
|
||||
end
|
||||
|
||||
case response.code
|
||||
@@ -98,7 +101,8 @@ class Provider::Simplefin
|
||||
end
|
||||
|
||||
def get_info(base_url)
|
||||
response = HTTParty.get("#{base_url}/info")
|
||||
# Use self.class.get to inherit class-level SSL and timeout defaults
|
||||
response = self.class.get("#{base_url}/info")
|
||||
|
||||
case response.code
|
||||
when 200
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Provider::TwelveData < Provider
|
||||
include ExchangeRateConcept, SecurityConcept
|
||||
extend SslConfigurable
|
||||
|
||||
# Subclass so errors caught in this provider are raised as Provider::TwelveData::Error
|
||||
Error = Class.new(Provider::Error)
|
||||
@@ -234,7 +235,7 @@ class Provider::TwelveData < Provider
|
||||
end
|
||||
|
||||
def client
|
||||
@client ||= Faraday.new(url: base_url) do |faraday|
|
||||
@client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday|
|
||||
faraday.request(:retry, {
|
||||
max: 2,
|
||||
interval: 0.05,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Provider::YahooFinance < Provider
|
||||
include ExchangeRateConcept, SecurityConcept
|
||||
extend SslConfigurable
|
||||
|
||||
# Subclass so errors caught in this provider are raised as Provider::YahooFinance::Error
|
||||
Error = Class.new(Provider::Error)
|
||||
@@ -494,7 +495,7 @@ class Provider::YahooFinance < Provider
|
||||
end
|
||||
|
||||
def client
|
||||
@client ||= Faraday.new(url: base_url) do |faraday|
|
||||
@client ||= Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday|
|
||||
faraday.request(:retry, {
|
||||
max: max_retries,
|
||||
interval: retry_interval,
|
||||
@@ -609,7 +610,7 @@ class Provider::YahooFinance < Provider
|
||||
|
||||
# Client for authentication requests (no error raising - fc.yahoo.com returns 404 but sets cookie)
|
||||
def auth_client
|
||||
@auth_client ||= Faraday.new do |faraday|
|
||||
@auth_client ||= Faraday.new(ssl: self.class.faraday_ssl_options) do |faraday|
|
||||
faraday.headers["User-Agent"] = random_user_agent
|
||||
faraday.headers["Accept"] = "*/*"
|
||||
faraday.headers["Accept-Language"] = "en-US,en;q=0.9"
|
||||
@@ -620,7 +621,7 @@ class Provider::YahooFinance < Provider
|
||||
|
||||
# Client for authenticated requests (includes cookie header)
|
||||
def authenticated_client(cookie)
|
||||
Faraday.new(url: base_url) do |faraday|
|
||||
Faraday.new(url: base_url, ssl: self.class.faraday_ssl_options) do |faraday|
|
||||
faraday.request(:retry, {
|
||||
max: max_retries,
|
||||
interval: retry_interval,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
class SsoProvider < ApplicationRecord
|
||||
include Encryptable
|
||||
extend SslConfigurable
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
@@ -116,7 +117,7 @@ class SsoProvider < ApplicationRecord
|
||||
|
||||
begin
|
||||
discovery_url = issuer.end_with?("/") ? "#{issuer}.well-known/openid-configuration" : "#{issuer}/.well-known/openid-configuration"
|
||||
response = Faraday.get(discovery_url) do |req|
|
||||
response = Faraday.new(ssl: self.class.faraday_ssl_options).get(discovery_url) do |req|
|
||||
req.options.timeout = 5
|
||||
req.options.open_timeout = 3
|
||||
end
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
# Tests SSO provider configuration by validating discovery endpoints
|
||||
class SsoProviderTester
|
||||
extend SslConfigurable
|
||||
|
||||
attr_reader :provider, :result
|
||||
|
||||
Result = Struct.new(:success?, :message, :details, keyword_init: true)
|
||||
@@ -34,7 +36,7 @@ class SsoProviderTester
|
||||
discovery_url = build_discovery_url(provider.issuer)
|
||||
|
||||
begin
|
||||
response = Faraday.get(discovery_url) do |req|
|
||||
response = faraday_client.get(discovery_url) do |req|
|
||||
req.options.timeout = 10
|
||||
req.options.open_timeout = 5
|
||||
end
|
||||
@@ -146,7 +148,7 @@ class SsoProviderTester
|
||||
metadata_url = provider.settings&.dig("idp_metadata_url")
|
||||
if metadata_url.present?
|
||||
begin
|
||||
response = Faraday.get(metadata_url) do |req|
|
||||
response = faraday_client.get(metadata_url) do |req|
|
||||
req.options.timeout = 10
|
||||
req.options.open_timeout = 5
|
||||
end
|
||||
@@ -198,4 +200,8 @@ class SsoProviderTester
|
||||
"#{issuer}/.well-known/openid-configuration"
|
||||
end
|
||||
end
|
||||
|
||||
def faraday_client
|
||||
@faraday_client ||= Faraday.new(ssl: self.class.faraday_ssl_options)
|
||||
end
|
||||
end
|
||||
|
||||
267
config/initializers/00_ssl.rb
Normal file
267
config/initializers/00_ssl.rb
Normal file
@@ -0,0 +1,267 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "openssl"
|
||||
require "fileutils"
|
||||
|
||||
# Centralized SSL/TLS configuration for outbound HTTPS connections.
|
||||
#
|
||||
# This enables support for self-signed certificates in self-hosted environments
|
||||
# where servers use internal CAs or self-signed certificates.
|
||||
#
|
||||
# Environment Variables:
|
||||
# SSL_CA_FILE - Path to custom CA certificate file (PEM format)
|
||||
# SSL_VERIFY - Set to "false" to disable SSL verification (NOT RECOMMENDED for production)
|
||||
# SSL_DEBUG - Set to "true" to enable verbose SSL logging
|
||||
#
|
||||
# Example usage in docker-compose.yml:
|
||||
# environment:
|
||||
# SSL_CA_FILE: /certs/my-ca.crt
|
||||
# volumes:
|
||||
# - ./my-ca.crt:/certs/my-ca.crt:ro
|
||||
#
|
||||
# Security Warning:
|
||||
# Disabling SSL verification (SSL_VERIFY=false) removes protection against
|
||||
# man-in-the-middle attacks. Only use this for development/testing environments.
|
||||
#
|
||||
# IMPORTANT: When a valid SSL_CA_FILE is provided, this initializer sets the
|
||||
# SSL_CERT_FILE environment variable to a combined CA bundle (system CAs + custom CA).
|
||||
# This is a *global* side-effect that affects ALL SSL connections in the Ruby process,
|
||||
# including gems that do not go through SslConfigurable (e.g. openid_connect).
|
||||
# This is intentional — it ensures OIDC discovery, webhook callbacks, and any other
|
||||
# outbound HTTPS connection trusts both public CAs and the user's custom CA.
|
||||
|
||||
# Path for the combined CA bundle file (predictable location for debugging)
|
||||
COMBINED_CA_BUNDLE_PATH = Rails.root.join("tmp", "ssl_ca_bundle.pem").freeze
|
||||
|
||||
# Boot-time helper for SSL certificate validation and bundle creation.
|
||||
#
|
||||
# This is intentionally a standalone module (not nested under SslConfigurable)
|
||||
# because SslConfigurable is autoloaded by Zeitwerk from app/models/concerns/.
|
||||
# Reopening that module here at boot time would register the constant before
|
||||
# Zeitwerk, preventing the real concern (with httparty_ssl_options, etc.) from
|
||||
# ever being loaded — causing NameError at class load time in providers.
|
||||
module SslInitializerHelper
|
||||
module_function
|
||||
|
||||
# PEM certificate format markers (X.509 standard)
|
||||
PEM_CERT_BEGIN = "-----BEGIN CERTIFICATE-----"
|
||||
PEM_CERT_END = "-----END CERTIFICATE-----"
|
||||
|
||||
# Validates a CA certificate file.
|
||||
# Supports single certs and multi-cert PEM bundles (CA chains).
|
||||
#
|
||||
# @param path [String] Path to the CA certificate file
|
||||
# @return [Hash] Validation result with :path, :valid, and :error keys
|
||||
def validate_ca_certificate_file(path)
|
||||
result = { path: nil, valid: false, error: nil }
|
||||
|
||||
unless File.exist?(path)
|
||||
result[:error] = "File not found: #{path}"
|
||||
Rails.logger.warn("[SSL] SSL_CA_FILE specified but file not found: #{path}")
|
||||
return result
|
||||
end
|
||||
|
||||
unless File.readable?(path)
|
||||
result[:error] = "File not readable: #{path}"
|
||||
Rails.logger.warn("[SSL] SSL_CA_FILE specified but file not readable: #{path}")
|
||||
return result
|
||||
end
|
||||
|
||||
unless File.file?(path)
|
||||
result[:error] = "Path is not a file: #{path}"
|
||||
Rails.logger.warn("[SSL] SSL_CA_FILE specified but is not a file: #{path}")
|
||||
return result
|
||||
end
|
||||
|
||||
# Sanity check file size (CA certs should be < 1MB)
|
||||
file_size = File.size(path)
|
||||
if file_size > 1_000_000
|
||||
result[:error] = "File too large (#{file_size} bytes) - expected a PEM certificate"
|
||||
Rails.logger.warn("[SSL] SSL_CA_FILE is unexpectedly large: #{path} (#{file_size} bytes)")
|
||||
return result
|
||||
end
|
||||
|
||||
# Validate PEM format using standard X.509 markers
|
||||
content = File.read(path)
|
||||
unless content.include?(PEM_CERT_BEGIN)
|
||||
result[:error] = "Invalid PEM format - missing BEGIN CERTIFICATE marker"
|
||||
Rails.logger.warn("[SSL] SSL_CA_FILE does not appear to be a valid PEM certificate: #{path}")
|
||||
return result
|
||||
end
|
||||
|
||||
unless content.include?(PEM_CERT_END)
|
||||
result[:error] = "Invalid PEM format - missing END CERTIFICATE marker"
|
||||
Rails.logger.warn("[SSL] SSL_CA_FILE has incomplete PEM format: #{path}")
|
||||
return result
|
||||
end
|
||||
|
||||
# Parse and validate every certificate in the PEM file.
|
||||
# OpenSSL::X509::Certificate.new only parses the first PEM block,
|
||||
# so multi-cert bundles (CA chains) need per-block validation.
|
||||
begin
|
||||
pem_blocks = content.scan(/#{PEM_CERT_BEGIN}[\s\S]+?#{PEM_CERT_END}/)
|
||||
raise OpenSSL::X509::CertificateError, "No certificates found in PEM file" if pem_blocks.empty?
|
||||
|
||||
pem_blocks.each_with_index do |pem, index|
|
||||
OpenSSL::X509::Certificate.new(pem)
|
||||
rescue OpenSSL::X509::CertificateError => e
|
||||
raise OpenSSL::X509::CertificateError, "Certificate #{index + 1} of #{pem_blocks.size} is invalid: #{e.message}"
|
||||
end
|
||||
|
||||
result[:path] = path
|
||||
result[:valid] = true
|
||||
rescue OpenSSL::X509::CertificateError => e
|
||||
result[:error] = "Invalid certificate: #{e.message}"
|
||||
Rails.logger.warn("[SSL] SSL_CA_FILE contains invalid certificate: #{e.message}")
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
# Finds the system CA certificate bundle path using OpenSSL's detection
|
||||
#
|
||||
# @return [String, nil] Path to system CA bundle or nil if not found
|
||||
def find_system_ca_bundle
|
||||
# First, check if SSL_CERT_FILE is already set (user may have their own bundle)
|
||||
existing_cert_file = ENV["SSL_CERT_FILE"]
|
||||
if existing_cert_file.present? && File.exist?(existing_cert_file) && File.readable?(existing_cert_file)
|
||||
return existing_cert_file
|
||||
end
|
||||
|
||||
# Use OpenSSL's built-in CA file detection
|
||||
openssl_ca_file = OpenSSL::X509::DEFAULT_CERT_FILE
|
||||
if openssl_ca_file.present? && File.exist?(openssl_ca_file) && File.readable?(openssl_ca_file)
|
||||
return openssl_ca_file
|
||||
end
|
||||
|
||||
# Use OpenSSL's default certificate directory as fallback
|
||||
openssl_ca_dir = OpenSSL::X509::DEFAULT_CERT_DIR
|
||||
if openssl_ca_dir.present? && Dir.exist?(openssl_ca_dir)
|
||||
# Look for common bundle file names in the certificate directory
|
||||
%w[ca-certificates.crt ca-bundle.crt cert.pem].each do |bundle_name|
|
||||
bundle_path = File.join(openssl_ca_dir, bundle_name)
|
||||
return bundle_path if File.exist?(bundle_path) && File.readable?(bundle_path)
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
# Creates a combined CA bundle with system CAs and custom CA.
|
||||
# Writes to a predictable path (tmp/ssl_ca_bundle.pem) for easy debugging
|
||||
# and to avoid Tempfile GC lifecycle issues.
|
||||
#
|
||||
# @param custom_ca_path [String] Path to the custom CA certificate
|
||||
# @param output_path [String] Where to write the combined bundle
|
||||
# @return [String, nil] Path to the combined bundle, or nil on failure
|
||||
def create_combined_ca_bundle(custom_ca_path, output_path: COMBINED_CA_BUNDLE_PATH)
|
||||
system_ca = find_system_ca_bundle
|
||||
unless system_ca
|
||||
Rails.logger.warn("[SSL] Could not find system CA bundle - using custom CA only")
|
||||
return nil
|
||||
end
|
||||
|
||||
begin
|
||||
system_content = File.read(system_ca)
|
||||
custom_content = File.read(custom_ca_path)
|
||||
|
||||
# Ensure the parent directory exists
|
||||
FileUtils.mkdir_p(File.dirname(output_path))
|
||||
|
||||
File.write(output_path, system_content + "\n# Custom CA Certificate\n" + custom_content)
|
||||
|
||||
Rails.logger.info("[SSL] Created combined CA bundle: #{output_path}")
|
||||
Rails.logger.info("[SSL] - System CA source: #{system_ca}")
|
||||
Rails.logger.info("[SSL] - Custom CA source: #{custom_ca_path}")
|
||||
|
||||
output_path.to_s
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[SSL] Failed to create combined CA bundle: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Logs SSL configuration summary at startup
|
||||
#
|
||||
# @param ssl_config [ActiveSupport::OrderedOptions] SSL configuration
|
||||
def log_ssl_configuration(ssl_config)
|
||||
if ssl_config.debug
|
||||
Rails.logger.info("[SSL] Debug mode enabled - verbose SSL logging active")
|
||||
end
|
||||
|
||||
if ssl_config.ca_file.present?
|
||||
if ssl_config.ca_file_valid
|
||||
Rails.logger.info("[SSL] Custom CA certificate configured and validated: #{ssl_config.ca_file}")
|
||||
else
|
||||
Rails.logger.error("[SSL] Custom CA certificate configured but invalid: #{ssl_config.ca_file_error}")
|
||||
end
|
||||
end
|
||||
|
||||
unless ssl_config.verify
|
||||
Rails.logger.warn("[SSL] " + "=" * 60)
|
||||
Rails.logger.warn("[SSL] WARNING: SSL verification is DISABLED")
|
||||
Rails.logger.warn("[SSL] This is insecure and should only be used for development/testing")
|
||||
Rails.logger.warn("[SSL] Set SSL_VERIFY=true or remove the variable for production")
|
||||
Rails.logger.warn("[SSL] " + "=" * 60)
|
||||
end
|
||||
|
||||
if ssl_config.debug
|
||||
Rails.logger.info("[SSL] Configuration summary:")
|
||||
Rails.logger.info("[SSL] - SSL verification: #{ssl_config.verify ? 'ENABLED' : 'DISABLED'}")
|
||||
Rails.logger.info("[SSL] - Custom CA file: #{ssl_config.ca_file || 'not configured'}")
|
||||
Rails.logger.info("[SSL] - CA file valid: #{ssl_config.ca_file_valid}")
|
||||
Rails.logger.info("[SSL] - Combined CA bundle: #{ssl_config.combined_ca_bundle || 'not created'}")
|
||||
Rails.logger.info("[SSL] - SSL_CERT_FILE: #{ENV['SSL_CERT_FILE'] || 'not set'}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Configure SSL settings
|
||||
Rails.application.configure do
|
||||
config.x.ssl ||= ActiveSupport::OrderedOptions.new
|
||||
|
||||
truthy_values = %w[1 true yes on].freeze
|
||||
falsy_values = %w[0 false no off].freeze
|
||||
|
||||
# Debug mode for verbose SSL logging
|
||||
debug_env = ENV["SSL_DEBUG"].to_s.strip.downcase
|
||||
config.x.ssl.debug = truthy_values.include?(debug_env)
|
||||
|
||||
# SSL verification (default: true)
|
||||
verify_env = ENV["SSL_VERIFY"].to_s.strip.downcase
|
||||
config.x.ssl.verify = !falsy_values.include?(verify_env)
|
||||
|
||||
# Custom CA certificate file for trusting self-signed certificates
|
||||
ca_file = ENV["SSL_CA_FILE"].presence
|
||||
config.x.ssl.ca_file = nil
|
||||
config.x.ssl.ca_file_valid = false
|
||||
|
||||
if ca_file
|
||||
ca_file_status = SslInitializerHelper.validate_ca_certificate_file(ca_file)
|
||||
config.x.ssl.ca_file = ca_file_status[:path]
|
||||
config.x.ssl.ca_file_valid = ca_file_status[:valid]
|
||||
config.x.ssl.ca_file_error = ca_file_status[:error]
|
||||
|
||||
# Create combined CA bundle and set SSL_CERT_FILE for global SSL configuration.
|
||||
#
|
||||
# This sets ENV["SSL_CERT_FILE"] globally so that ALL Ruby SSL connections
|
||||
# (including gems like openid_connect that bypass SslConfigurable) will trust
|
||||
# both system CAs (for public services) and the custom CA (for self-signed services).
|
||||
if ca_file_status[:valid]
|
||||
combined_path = SslInitializerHelper.create_combined_ca_bundle(ca_file_status[:path])
|
||||
if combined_path
|
||||
config.x.ssl.combined_ca_bundle = combined_path
|
||||
ENV["SSL_CERT_FILE"] = combined_path
|
||||
Rails.logger.info("[SSL] Set SSL_CERT_FILE=#{combined_path} for global SSL configuration")
|
||||
else
|
||||
# Fallback: just use the custom CA (may break connections to public services)
|
||||
Rails.logger.warn("[SSL] Could not create combined CA bundle, using custom CA only. " \
|
||||
"Connections to public services (not using your custom CA) may fail.")
|
||||
ENV["SSL_CERT_FILE"] = ca_file_status[:path]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Log configuration summary at startup
|
||||
SslInitializerHelper.log_ssl_configuration(config.x.ssl)
|
||||
end
|
||||
@@ -77,7 +77,14 @@ Rails.application.config.middleware.use OmniAuth::Builder do
|
||||
client_options: {
|
||||
identifier: client_id,
|
||||
secret: client_secret,
|
||||
redirect_uri: redirect_uri
|
||||
redirect_uri: redirect_uri,
|
||||
ssl: begin
|
||||
ssl_config = Rails.configuration.x.ssl
|
||||
ssl_opts = {}
|
||||
ssl_opts[:ca_file] = ssl_config.ca_file if ssl_config&.ca_file.present?
|
||||
ssl_opts[:verify] = false if ssl_config&.verify == false
|
||||
ssl_opts
|
||||
end
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -471,7 +471,68 @@ When adding an OIDC provider, Sure validates the `.well-known/openid-configurati
|
||||
- Ensure the issuer URL is correct and accessible
|
||||
- Check firewall rules allow outbound HTTPS to the issuer
|
||||
- Verify the issuer returns valid JSON with an `issuer` field
|
||||
- For self-signed certificates, you may need to configure SSL verification
|
||||
- For self-signed certificates, configure SSL verification (see below)
|
||||
|
||||
### Self-signed certificate support
|
||||
|
||||
If your identity provider uses self-signed certificates or certificates from an internal CA, configure the following environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `SSL_CA_FILE` | Path to custom CA certificate (PEM format) | Not set |
|
||||
| `SSL_VERIFY` | Enable/disable SSL verification | `true` |
|
||||
| `SSL_DEBUG` | Enable verbose SSL logging | `false` |
|
||||
|
||||
**Option 1: Custom CA certificate (recommended)**
|
||||
|
||||
Mount your CA certificate into the container and set `SSL_CA_FILE`:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
SSL_CA_FILE: /certs/my-ca.crt
|
||||
volumes:
|
||||
- ./my-ca.crt:/certs/my-ca.crt:ro
|
||||
```
|
||||
|
||||
The certificate file must:
|
||||
- Be in PEM format (starts with `-----BEGIN CERTIFICATE-----`)
|
||||
- Be readable by the application
|
||||
- Be the CA certificate that signed your server's SSL certificate
|
||||
|
||||
**Option 2: Disable SSL verification (NOT recommended for production)**
|
||||
|
||||
For testing only, you can disable SSL verification:
|
||||
|
||||
```bash
|
||||
SSL_VERIFY=false
|
||||
```
|
||||
|
||||
**Warning:** Disabling SSL verification removes protection against man-in-the-middle attacks. Only use this for development or testing environments.
|
||||
|
||||
**Troubleshooting SSL issues**
|
||||
|
||||
Enable debug logging to diagnose SSL certificate problems:
|
||||
|
||||
```bash
|
||||
SSL_DEBUG=true
|
||||
```
|
||||
|
||||
This will log detailed information about SSL connections, including:
|
||||
- Which CA file is being used
|
||||
- SSL verification mode
|
||||
- Detailed error messages with resolution hints
|
||||
|
||||
Common error messages and solutions:
|
||||
|
||||
| Error | Solution |
|
||||
|-------|----------|
|
||||
| `self-signed certificate` | Set `SSL_CA_FILE` to your CA certificate |
|
||||
| `certificate verify failed` | Ensure `SSL_CA_FILE` points to the correct CA |
|
||||
| `certificate has expired` | Renew the server's SSL certificate |
|
||||
| `unknown CA` | Add the issuing CA to `SSL_CA_FILE` |
|
||||
|
||||
### Rate limiting errors (429)
|
||||
|
||||
|
||||
165
test/models/concerns/ssl_configurable_test.rb
Normal file
165
test/models/concerns/ssl_configurable_test.rb
Normal file
@@ -0,0 +1,165 @@
|
||||
require "test_helper"
|
||||
|
||||
class SslConfigurableTest < ActiveSupport::TestCase
|
||||
# Create a simple test host that extends SslConfigurable, mirroring how
|
||||
# providers use it in the actual codebase.
|
||||
class SslTestHost
|
||||
extend SslConfigurable
|
||||
end
|
||||
|
||||
setup do
|
||||
# Snapshot original config so we can restore it in teardown
|
||||
@original_verify = Rails.configuration.x.ssl.verify
|
||||
@original_ca_file = Rails.configuration.x.ssl.ca_file
|
||||
@original_debug = Rails.configuration.x.ssl.debug
|
||||
end
|
||||
|
||||
teardown do
|
||||
Rails.configuration.x.ssl.verify = @original_verify
|
||||
Rails.configuration.x.ssl.ca_file = @original_ca_file
|
||||
Rails.configuration.x.ssl.debug = @original_debug
|
||||
end
|
||||
|
||||
# -- ssl_verify? --
|
||||
|
||||
test "ssl_verify? returns true when verify is nil (default)" do
|
||||
Rails.configuration.x.ssl.verify = nil
|
||||
assert SslTestHost.ssl_verify?
|
||||
end
|
||||
|
||||
test "ssl_verify? returns true when verify is true" do
|
||||
Rails.configuration.x.ssl.verify = true
|
||||
assert SslTestHost.ssl_verify?
|
||||
end
|
||||
|
||||
test "ssl_verify? returns false when verify is explicitly false" do
|
||||
Rails.configuration.x.ssl.verify = false
|
||||
refute SslTestHost.ssl_verify?
|
||||
end
|
||||
|
||||
# -- ssl_ca_file --
|
||||
|
||||
test "ssl_ca_file returns nil when no CA file is configured" do
|
||||
Rails.configuration.x.ssl.ca_file = nil
|
||||
assert_nil SslTestHost.ssl_ca_file
|
||||
end
|
||||
|
||||
test "ssl_ca_file returns the configured path" do
|
||||
Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt"
|
||||
assert_equal "/certs/my-ca.crt", SslTestHost.ssl_ca_file
|
||||
end
|
||||
|
||||
# -- ssl_debug? --
|
||||
|
||||
test "ssl_debug? returns false when debug is nil" do
|
||||
Rails.configuration.x.ssl.debug = nil
|
||||
refute SslTestHost.ssl_debug?
|
||||
end
|
||||
|
||||
test "ssl_debug? returns true when debug is true" do
|
||||
Rails.configuration.x.ssl.debug = true
|
||||
assert SslTestHost.ssl_debug?
|
||||
end
|
||||
|
||||
# -- faraday_ssl_options --
|
||||
|
||||
test "faraday_ssl_options returns verify true with no CA file by default" do
|
||||
Rails.configuration.x.ssl.verify = true
|
||||
Rails.configuration.x.ssl.ca_file = nil
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.faraday_ssl_options
|
||||
|
||||
assert_equal true, options[:verify]
|
||||
assert_nil options[:ca_file]
|
||||
end
|
||||
|
||||
test "faraday_ssl_options includes ca_file when configured" do
|
||||
Rails.configuration.x.ssl.verify = true
|
||||
Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt"
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.faraday_ssl_options
|
||||
|
||||
assert_equal true, options[:verify]
|
||||
assert_equal "/certs/my-ca.crt", options[:ca_file]
|
||||
end
|
||||
|
||||
test "faraday_ssl_options returns verify false when verification disabled" do
|
||||
Rails.configuration.x.ssl.verify = false
|
||||
Rails.configuration.x.ssl.ca_file = nil
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.faraday_ssl_options
|
||||
|
||||
assert_equal false, options[:verify]
|
||||
end
|
||||
|
||||
test "faraday_ssl_options includes both verify false and ca_file when both configured" do
|
||||
Rails.configuration.x.ssl.verify = false
|
||||
Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt"
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.faraday_ssl_options
|
||||
|
||||
assert_equal false, options[:verify]
|
||||
assert_equal "/certs/my-ca.crt", options[:ca_file]
|
||||
end
|
||||
|
||||
# -- httparty_ssl_options --
|
||||
|
||||
test "httparty_ssl_options returns verify true with no CA file by default" do
|
||||
Rails.configuration.x.ssl.verify = true
|
||||
Rails.configuration.x.ssl.ca_file = nil
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.httparty_ssl_options
|
||||
|
||||
assert_equal true, options[:verify]
|
||||
assert_nil options[:ssl_ca_file]
|
||||
end
|
||||
|
||||
test "httparty_ssl_options includes ssl_ca_file when configured" do
|
||||
Rails.configuration.x.ssl.verify = true
|
||||
Rails.configuration.x.ssl.ca_file = "/certs/my-ca.crt"
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.httparty_ssl_options
|
||||
|
||||
assert_equal true, options[:verify]
|
||||
assert_equal "/certs/my-ca.crt", options[:ssl_ca_file]
|
||||
end
|
||||
|
||||
test "httparty_ssl_options returns verify false when verification disabled" do
|
||||
Rails.configuration.x.ssl.verify = false
|
||||
Rails.configuration.x.ssl.ca_file = nil
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
options = SslTestHost.httparty_ssl_options
|
||||
|
||||
assert_equal false, options[:verify]
|
||||
end
|
||||
|
||||
# -- net_http_verify_mode --
|
||||
|
||||
test "net_http_verify_mode returns VERIFY_PEER when verification enabled" do
|
||||
Rails.configuration.x.ssl.verify = true
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
assert_equal OpenSSL::SSL::VERIFY_PEER, SslTestHost.net_http_verify_mode
|
||||
end
|
||||
|
||||
test "net_http_verify_mode returns VERIFY_NONE when verification disabled" do
|
||||
Rails.configuration.x.ssl.verify = false
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
assert_equal OpenSSL::SSL::VERIFY_NONE, SslTestHost.net_http_verify_mode
|
||||
end
|
||||
|
||||
test "net_http_verify_mode returns VERIFY_PEER when verify is nil" do
|
||||
Rails.configuration.x.ssl.verify = nil
|
||||
Rails.configuration.x.ssl.debug = false
|
||||
|
||||
assert_equal OpenSSL::SSL::VERIFY_PEER, SslTestHost.net_http_verify_mode
|
||||
end
|
||||
end
|
||||
66
test/models/eval/langfuse_client_test.rb
Normal file
66
test/models/eval/langfuse_client_test.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
require "test_helper"
|
||||
require "ostruct"
|
||||
|
||||
class Eval::LangfuseClientTest < ActiveSupport::TestCase
|
||||
# -- CRL error list --
|
||||
|
||||
test "crl_errors includes standard CRL error codes" do
|
||||
errors = Eval::Langfuse::Client.crl_errors
|
||||
|
||||
assert_includes errors, OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL
|
||||
assert_includes errors, OpenSSL::X509::V_ERR_CRL_HAS_EXPIRED
|
||||
assert_includes errors, OpenSSL::X509::V_ERR_CRL_NOT_YET_VALID
|
||||
end
|
||||
|
||||
test "crl_errors is frozen" do
|
||||
assert Eval::Langfuse::Client.crl_errors.frozen?
|
||||
end
|
||||
|
||||
# -- CRL verify callback behavior --
|
||||
# The callback should bypass only CRL-specific errors while preserving the
|
||||
# original verification result for all other error types.
|
||||
|
||||
test "CRL callback returns true for CRL-unavailable errors" do
|
||||
crl_error_codes = Eval::Langfuse::Client.crl_errors
|
||||
store_ctx = OpenStruct.new(error: OpenSSL::X509::V_ERR_UNABLE_TO_GET_CRL)
|
||||
|
||||
callback = build_crl_callback(crl_error_codes)
|
||||
|
||||
assert callback.call(false, store_ctx), "CRL errors should be bypassed even when preverify_ok is false"
|
||||
end
|
||||
|
||||
test "CRL callback preserves preverify_ok for non-CRL errors" do
|
||||
crl_error_codes = Eval::Langfuse::Client.crl_errors
|
||||
# V_OK (0) is not a CRL error
|
||||
store_ctx = OpenStruct.new(error: 0)
|
||||
|
||||
callback = build_crl_callback(crl_error_codes)
|
||||
|
||||
assert callback.call(true, store_ctx), "Non-CRL errors with preverify_ok=true should pass"
|
||||
refute callback.call(false, store_ctx), "Non-CRL errors with preverify_ok=false should fail"
|
||||
end
|
||||
|
||||
test "CRL callback rejects cert errors that are not CRL-related" do
|
||||
crl_error_codes = Eval::Langfuse::Client.crl_errors
|
||||
# V_ERR_CERT_HAS_EXPIRED is a real cert error, not CRL
|
||||
store_ctx = OpenStruct.new(error: OpenSSL::X509::V_ERR_CERT_HAS_EXPIRED)
|
||||
|
||||
callback = build_crl_callback(crl_error_codes)
|
||||
|
||||
refute callback.call(false, store_ctx), "Non-CRL cert errors should not be bypassed"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Reconstructs the same lambda used in Eval::Langfuse::Client#execute_request
|
||||
# for isolated testing without needing a real Net::HTTP connection.
|
||||
def build_crl_callback(crl_error_codes)
|
||||
->(preverify_ok, store_ctx) {
|
||||
if crl_error_codes.include?(store_ctx.error)
|
||||
true
|
||||
else
|
||||
preverify_ok
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
# First call raises timeout, second call succeeds
|
||||
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
|
||||
|
||||
HTTParty.expects(:get)
|
||||
Provider::Simplefin.expects(:get)
|
||||
.times(2)
|
||||
.raises(Net::ReadTimeout.new("Connection timed out"))
|
||||
.then.returns(mock_response)
|
||||
@@ -25,7 +25,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
test "retries on Net::OpenTimeout and succeeds on retry" do
|
||||
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
|
||||
|
||||
HTTParty.expects(:get)
|
||||
Provider::Simplefin.expects(:get)
|
||||
.times(2)
|
||||
.raises(Net::OpenTimeout.new("Connection timed out"))
|
||||
.then.returns(mock_response)
|
||||
@@ -39,7 +39,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
test "retries on SocketError and succeeds on retry" do
|
||||
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
|
||||
|
||||
HTTParty.expects(:get)
|
||||
Provider::Simplefin.expects(:get)
|
||||
.times(2)
|
||||
.raises(SocketError.new("Failed to open TCP connection"))
|
||||
.then.returns(mock_response)
|
||||
@@ -51,7 +51,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "raises SimplefinError after max retries exceeded" do
|
||||
HTTParty.expects(:get)
|
||||
Provider::Simplefin.expects(:get)
|
||||
.times(4) # Initial + 3 retries
|
||||
.raises(Net::ReadTimeout.new("Connection timed out"))
|
||||
|
||||
@@ -66,7 +66,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
test "does not retry on non-retryable errors" do
|
||||
HTTParty.expects(:get)
|
||||
Provider::Simplefin.expects(:get)
|
||||
.times(1)
|
||||
.raises(ArgumentError.new("Invalid argument"))
|
||||
|
||||
@@ -80,7 +80,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
test "handles HTTP 429 rate limit response" do
|
||||
mock_response = OpenStruct.new(code: 429, body: "Rate limit exceeded")
|
||||
|
||||
HTTParty.expects(:get).returns(mock_response)
|
||||
Provider::Simplefin.expects(:get).returns(mock_response)
|
||||
|
||||
error = assert_raises(Provider::Simplefin::SimplefinError) do
|
||||
@provider.get_accounts(@access_url)
|
||||
@@ -93,7 +93,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
test "handles HTTP 500 server error response" do
|
||||
mock_response = OpenStruct.new(code: 500, body: "Internal Server Error")
|
||||
|
||||
HTTParty.expects(:get).returns(mock_response)
|
||||
Provider::Simplefin.expects(:get).returns(mock_response)
|
||||
|
||||
error = assert_raises(Provider::Simplefin::SimplefinError) do
|
||||
@provider.get_accounts(@access_url)
|
||||
@@ -106,7 +106,7 @@ class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
setup_token = Base64.encode64("https://example.com/claim")
|
||||
mock_response = OpenStruct.new(code: 200, body: "https://example.com/access")
|
||||
|
||||
HTTParty.expects(:post)
|
||||
Provider::Simplefin.expects(:post)
|
||||
.times(2)
|
||||
.raises(Net::ReadTimeout.new("Connection timed out"))
|
||||
.then.returns(mock_response)
|
||||
|
||||
Reference in New Issue
Block a user