diff --git a/README.md b/README.md index 710c2e808..708d46a49 100644 --- a/README.md +++ b/README.md @@ -400,7 +400,30 @@ The bearer is not cryptographically bound to the certificate; the API separately requires an accepted client certificate. ```ruby +require "openai" + +certificates = OpenSSL::X509::Certificate.load( + File.binread(ENV.fetch("OPENAI_CLIENT_CERTIFICATE_CHAIN")) +) +raise "Expected an enrolled client certificate" if certificates.empty? + +client_certificate, *intermediate_certificates = certificates +client_private_key = OpenSSL::PKey.read( + File.binread(ENV.fetch("OPENAI_CLIENT_KEY")), + ENV["OPENAI_CLIENT_KEY_PASSPHRASE"] +) +unless client_certificate.check_private_key(client_private_key) + raise "The enrolled certificate and private key do not match" +end + +api_origin = ENV.fetch("OPENAI_X509_API_ORIGIN", "https://mtls.api.openai.com") +api_host = URI(api_origin).host&.downcase +approved_hosts = ["mtls.auth.openai.com", api_host].freeze native_http_client = OpenAI::NetHTTPClient.new do |connection| + unless connection.use_ssl? && connection.port == 443 && approved_hosts.include?(connection.address.downcase) + raise "Refusing to present the enrolled certificate to an unexpected destination" + end + connection.cert = client_certificate connection.extra_chain_cert = intermediate_certificates connection.key = client_private_key @@ -409,7 +432,8 @@ end transport = OpenAI::Auth::X509Transport.new( http_client: native_http_client, certificate_identity: :static, - proxy: :direct + proxy: :direct, + api_origin: api_origin ) identity = OpenAI::Auth::X509WorkloadIdentity.new( @@ -434,7 +458,8 @@ transport when rotating credentials. Direct mode rejects ambient proxies; use `proxy: :http_connect` only when an HTTP CONNECT proxy is configured. HTTPS proxies are rejected before proxy credentials can be transmitted. Arbitrary custom transports, Azure/Bedrock providers, and Realtime WebSockets are not -supported. Preview access must be enabled for the enrolled organization. +supported. Preview access must be enabled for the organization, and mTLS can be +configured for the enrolled organization or project. See the complete [X.509 workload identity live smoke example](examples/x509_workload_identity.rb). It performs a real token exchange diff --git a/lib/openai/auth/workload_identity_auth.rb b/lib/openai/auth/workload_identity_auth.rb index 288c100e5..d522a85e5 100644 --- a/lib/openai/auth/workload_identity_auth.rb +++ b/lib/openai/auth/workload_identity_auth.rb @@ -45,6 +45,7 @@ def get_token(deadline: nil) action = nil token = nil generation = nil + previous_token = nil # Installing refresh cleanup is part of the state transition. No async # exception may observe @refreshing after it changes but before the ensure. @@ -59,6 +60,7 @@ def get_token(deadline: nil) action = :return end elsif token_unusable? || needs_refresh? + previous_token = @cached_token @refreshing = true generation = {complete: false, error: nil, token: nil, expires_at: nil} @refresh_generation = generation @@ -76,12 +78,21 @@ def get_token(deadline: nil) end rescue StandardError => error + fallback = false @mutex.synchronize do - @refresh_error = error unless @token_exchange.nil? - generation[:error] = error + now = OpenAI::Internal::Util.monotonic_secs unless @token_exchange.nil? + if now && proactive_refresh_fallback?(error, previous_token, deadline, now) + remaining = @cached_token_expires_at_monotonic - now + @cached_token_refresh_at_monotonic = now + [5.0, remaining / 2].min + @refresh_error = error + fallback = true + else + @refresh_error = error unless @token_exchange.nil? + generation[:error] = error + end end - raise + raise unless fallback ensure @mutex.synchronize do if generation[:error].nil? @@ -136,6 +147,18 @@ def inspect end end + private def proactive_refresh_fallback?(error, previous_token, deadline, now) + return false if @token_exchange.nil? || previous_token.nil? || !@cached_token.equal?(previous_token) + expires_at = @cached_token_expires_at_monotonic + return false if expires_at.nil? || now >= expires_at + return false if deadline && now >= deadline + return true if error.is_a?(OpenAI::Errors::APIConnectionError) + return false unless error.is_a?(OpenAI::Errors::APIError) + + status = error.status + [408, 409, 429].include?(status) || (status.is_a?(Integer) && (500..599).cover?(status)) + end + private def wait_for_refresh(deadline, generation) @mutex.synchronize do until generation.fetch(:complete) @@ -173,6 +196,8 @@ def inspect end private def raise_refresh_error! + raise @refresh_error if @token_exchange && @refresh_error + raise( OpenAI::Errors::AuthenticationError.new( url: @token_exchange_url, diff --git a/lib/openai/auth/x509_token_exchange.rb b/lib/openai/auth/x509_token_exchange.rb index bbca509bc..e633035f1 100644 --- a/lib/openai/auth/x509_token_exchange.rb +++ b/lib/openai/auth/x509_token_exchange.rb @@ -38,6 +38,13 @@ def initialize(config, transport:) @transport = transport end + # Avoid exposing nested workload identity configuration in diagnostics. + # + # @return [String] + def inspect + "#<#{self.class.name}:0x#{object_id.to_s(16)}>" + end + # @param deadline [Float, nil] absolute monotonic request deadline # @return [Hash{Symbol=>String, Float}] def fetch(deadline: nil) diff --git a/lib/openai/auth/x509_workload_identity.rb b/lib/openai/auth/x509_workload_identity.rb index 625281f0e..10d4e9134 100644 --- a/lib/openai/auth/x509_workload_identity.rb +++ b/lib/openai/auth/x509_workload_identity.rb @@ -29,6 +29,13 @@ def initialize( freeze end + # Avoid exposing provider or service-account identifiers in diagnostics. + # + # @return [String] + def inspect + "#<#{self.class.name}:0x#{object_id.to_s(16)}>" + end + private def validate_identifier(value, name) identifier = String.new(value.to_s) unless identifier.valid_encoding? diff --git a/lib/openai/client.rb b/lib/openai/client.rb index 0d77395ef..ee426cfd6 100644 --- a/lib/openai/client.rb +++ b/lib/openai/client.rb @@ -330,10 +330,14 @@ class Client < OpenAI::Internal::Transport::BaseClient end end - rescue Timeout::Error => error + rescue Timeout::Error raise unless x509_request - raise OpenAI::Errors::APITimeoutError.new(url: request.fetch(:url), message: error.message), cause: nil + url = request.fetch(:url).dup + url.query = nil + url.fragment = nil + message = "request timed out during workload identity authentication" + raise OpenAI::Errors::APITimeoutError.new(url: url, message: message), cause: nil end private def workload_identity_request?(request) diff --git a/rbi/openai/auth/x509_token_exchange.rbi b/rbi/openai/auth/x509_token_exchange.rbi index 259ee154e..1858c5fbe 100644 --- a/rbi/openai/auth/x509_token_exchange.rbi +++ b/rbi/openai/auth/x509_token_exchange.rbi @@ -9,6 +9,10 @@ module OpenAI def initialize(config, transport:) end + sig { returns(String) } + def inspect + end + sig { params(deadline: T.nilable(Float)).returns(T::Hash[Symbol, T.any(String, Float)]) } def fetch(deadline: nil) end diff --git a/rbi/openai/auth/x509_workload_identity.rbi b/rbi/openai/auth/x509_workload_identity.rbi index 232e4d126..bea50b37e 100644 --- a/rbi/openai/auth/x509_workload_identity.rbi +++ b/rbi/openai/auth/x509_workload_identity.rbi @@ -26,6 +26,10 @@ module OpenAI refresh_buffer_seconds: 1200 ) end + + sig { returns(String) } + def inspect + end end end end diff --git a/scripts/live-smoke.rb b/scripts/live-smoke.rb index b6a95e94c..5e32a0c5d 100755 --- a/scripts/live-smoke.rb +++ b/scripts/live-smoke.rb @@ -40,8 +40,9 @@ def self.run_cli(model:, output:, error_output:, client: nil) error_output.puts("[live-smoke] #{error.message}") false rescue StandardError => error - status = error.respond_to?(:status) && error.status ? " (HTTP #{error.status})" : "" - error_output.puts("[live-smoke] #{error.class}#{status}") + status = error.respond_to?(:status) ? error.status : nil + status_message = status.is_a?(Integer) ? " (HTTP #{status})" : "" + error_output.puts("[live-smoke] #{error.class}#{status_message}") false end end diff --git a/sig/openai/auth/x509_token_exchange.rbs b/sig/openai/auth/x509_token_exchange.rbs index 2b3750876..80c40d9b9 100644 --- a/sig/openai/auth/x509_token_exchange.rbs +++ b/sig/openai/auth/x509_token_exchange.rbs @@ -6,6 +6,8 @@ module OpenAI transport: OpenAI::Auth::X509Transport ) -> void + def inspect: -> String + def fetch: (?deadline: Float?) -> ::Hash[Symbol, (String | Float)] end end diff --git a/sig/openai/auth/x509_workload_identity.rbs b/sig/openai/auth/x509_workload_identity.rbs index e8fd66725..9d51d8b91 100644 --- a/sig/openai/auth/x509_workload_identity.rbs +++ b/sig/openai/auth/x509_workload_identity.rbs @@ -12,6 +12,8 @@ module OpenAI ?service_account_id: (String | Symbol)?, ?refresh_buffer_seconds: Integer ) -> void + + def inspect: -> String end end end diff --git a/test/openai/auth/x509_client_security_test.rb b/test/openai/auth/x509_client_security_test.rb new file mode 100644 index 000000000..80a98364e --- /dev/null +++ b/test/openai/auth/x509_client_security_test.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +class OpenAI::Test::X509ClientSecurityTest < Minitest::Test + def setup + super + @native = OpenAI::NetHTTPClient.new + @transport = OpenAI::Auth::X509Transport.new(http_client: @native, certificate_identity: :static) + @identity = OpenAI::Auth::X509WorkloadIdentity.new( + identity_provider_id: "fake-sensitive-provider-id", + service_account_id: "fake-sensitive-service-account-id" + ) + end + + def teardown + @native.close + super + end + + def test_authentication_timeouts_redact_signed_query_and_fragment_without_mutating_request + client_class = Class.new(OpenAI::Client) do + attr_reader(:original_url) + + private def build_request(request, options) + built = super + @original_url = built.fetch(:url) + @original_url.fragment = "fake-sensitive-fragment" + built + end + end + + client = client_class.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + timeout: 0.01, + max_retries: 1 + ) + response = OpenAI::HTTPClient::Response.new(status: 429, headers: {"retry-after" => "60"}, body: "") + + error = @native.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::APITimeoutError) do + client.models.retrieve( + "fake-model", + request_options: {extra_query: {"signature" => "fake-sensitive-query"}} + ) + end + end + + assert_equal("https://mtls.api.openai.com/v1/models/fake-model", error.url.to_s) + assert_nil(error.url.query) + assert_nil(error.url.fragment) + assert_match(/fake-sensitive-query/, client.original_url.query) + assert_equal("fake-sensitive-fragment", client.original_url.fragment) + refute_match(/fake-sensitive-query|fake-sensitive-fragment/, error.inspect) + assert_nil(error.cause) + end + + def test_workload_identity_and_token_exchange_inspection_redacts_configuration + exchange = OpenAI::Auth::X509TokenExchange.new(@identity, transport: @transport) + + [@identity, exchange].each do |object| + expected = "#<#{object.class.name}:0x#{object.object_id.to_s(16)}>" + + assert_equal(expected, object.inspect) + assert_match(/\A#<#{Regexp.escape(object.class.name)}:0x[0-9a-f]+>\z/, object.to_s) + refute_includes(object.inspect, @identity.identity_provider_id) + refute_includes(object.inspect, @identity.service_account_id) + end + end + + def test_authentication_timeout_messages_never_expose_underlying_customer_data + client = OpenAI::Client.new(api_key: nil, workload_identity: @identity, http_client: @transport) + failure = -> (_request) { raise Timeout::Error, "fake-sensitive-signed-query=secret" } + + error = @native.stub(:execute, failure) do + assert_raises(OpenAI::Errors::APITimeoutError) do + client.models.retrieve("fake-model") + end + end + + assert_match(/timed out during workload identity authentication/, error.message) + refute_includes(error.message, "fake-sensitive-signed-query") + refute_includes(error.full_message(highlight: false), "fake-sensitive-signed-query") + assert_nil(error.cause) + end +end diff --git a/test/openai/auth/x509_proactive_refresh_test.rb b/test/openai/auth/x509_proactive_refresh_test.rb new file mode 100644 index 000000000..793f37edc --- /dev/null +++ b/test/openai/auth/x509_proactive_refresh_test.rb @@ -0,0 +1,283 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +class OpenAI::Test::X509ProactiveRefreshTest < Minitest::Test + extend Minitest::Serial + + def setup + super + @native = OpenAI::NetHTTPClient.new + @transport = OpenAI::Auth::X509Transport.new(http_client: @native, certificate_identity: :static) + @identity = OpenAI::Auth::X509WorkloadIdentity.new( + identity_provider_id: "idp_fake", + service_account_id: "svc_acct_fake", + refresh_buffer_seconds: 30 + ) + end + + def teardown + @native.close + super + end + + def test_public_client_falls_back_to_valid_bearer_after_transient_proactive_refresh_failures + failures = [408, 409, 429, 500, 503, OpenAI::Errors::APIConnectionError, OpenAI::Errors::APITimeoutError] + + failures.each do |failure| + client = new_client + now = 100.0 + issuer_attempts = 0 + api_authorizations = [] + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + issuer_attempts += 1 + if issuer_attempts == 1 + token_response + elsif failure.is_a?(Integer) + failure_response(failure) + else + raise failure.new(url: request.url) + end + else + api_authorizations << request.headers.fetch("authorization") + model_response + end + end + + OpenAI::Internal::Util.stub(:monotonic_secs, -> { now }) do + @native.stub(:execute, dispatch) do + assert_equal("fake-model", client.models.retrieve("first").id) + now = 191.0 + assert_equal("fake-model", client.models.retrieve("second").id) + assert_equal("fake-model", client.models.retrieve("third").id) + end + end + + assert_equal(2, issuer_attempts) + assert_equal(["Bearer fake-valid-token"] * 3, api_authorizations) + end + end + + def test_proactive_refresh_cooldown_is_bounded_and_never_extends_token_lifetime + client = new_client + clock = {now: 100.0} + issuer_attempts = 0 + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + issuer_attempts += 1 + issuer_attempts == 1 ? token_response : failure_response(503) + else + model_response + end + end + + OpenAI::Internal::Util.stub(:monotonic_secs, -> { clock.fetch(:now) }) do + @native.stub(:execute, dispatch) do + assert_equal("fake-model", client.models.retrieve("first").id) + clock[:now] = 191.0 + assert_equal("fake-model", client.models.retrieve("second").id) + assert_equal(2, issuer_attempts) + clock[:now] = 195.9 + assert_equal("fake-model", client.models.retrieve("third").id) + assert_equal(2, issuer_attempts) + clock[:now] = 196.0 + assert_equal("fake-model", client.models.retrieve("fourth").id) + assert_equal(3, issuer_attempts) + clock[:now] = 220.0 + error = assert_raises(OpenAI::Errors::APIError) { client.models.retrieve("expired") } + assert_equal(503, error.status) + end + end + end + + def test_expired_and_concurrently_invalidated_bearers_never_receive_fallback + [:expired, :invalidated].each do |state| + client = new_client + now = 100.0 + issuer_attempts = 0 + api_attempts = 0 + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + issuer_attempts += 1 + if issuer_attempts == 1 + token_response + else + client.workload_identity_auth.invalidate_token("fake-valid-token") if state == :invalidated + failure_response(503) + end + else + api_attempts += 1 + model_response + end + end + + OpenAI::Internal::Util.stub(:monotonic_secs, -> { now }) do + @native.stub(:execute, dispatch) do + assert_equal("fake-model", client.models.retrieve("first").id) + now = state == :expired ? 220.0 : 191.0 + error = assert_raises(OpenAI::Errors::APIError) { client.models.retrieve("second") } + assert_equal(503, error.status) + end + end + + assert_equal(2, issuer_attempts) + assert_equal(1, api_attempts) + end + end + + def test_nontransient_oauth_rejections_never_fall_back_to_the_cached_bearer + [400, 401, 403, 404].each do |status| + client = new_client + now = 100.0 + issuer_attempts = 0 + api_attempts = 0 + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + issuer_attempts += 1 + issuer_attempts == 1 ? token_response : failure_response(status) + else + api_attempts += 1 + model_response + end + end + + OpenAI::Internal::Util.stub(:monotonic_secs, -> { now }) do + @native.stub(:execute, dispatch) do + assert_equal("fake-model", client.models.retrieve("first").id) + now = 191.0 + error = assert_raises(OpenAI::Errors::APIError) { client.models.retrieve("second") } + assert_equal(status, error.status) + end + end + + assert_equal(2, issuer_attempts) + assert_equal(1, api_attempts) + end + end + + def test_expired_caller_deadline_never_falls_back_to_an_unexpired_bearer + client = new_client + auth = client.workload_identity_auth + now = 100.0 + failure = OpenAI::Errors::APIError.new( + url: URI("https://mtls.auth.openai.com/oauth/token"), + status: 503, + message: "issuer temporarily unavailable" + ) + + OpenAI::Internal::Util.stub(:monotonic_secs, -> { now }) do + @native.stub(:execute, -> (_request) { token_response }) do + assert_equal("fake-valid-token", auth.get_token) + end + + now = 191.0 + exchange = lambda do |deadline:| + now = deadline + 1 + raise failure + end + + auth.stub(:fetch_token_from_exchange, exchange) do + error = assert_raises(OpenAI::Errors::APIError) { auth.get_token(deadline: 192.0) } + assert_same(failure, error) + end + end + end + + def test_token_expiring_during_fallback_validation_preserves_the_original_issuer_failure + client = new_client + auth = client.workload_identity_auth + failure = OpenAI::Errors::APIError.new( + url: URI("https://mtls.auth.openai.com/oauth/token"), + status: 503, + message: "issuer temporarily unavailable" + ) + + OpenAI::Internal::Util.stub(:monotonic_secs, -> { 100.0 }) do + @native.stub(:execute, -> (_request) { token_response }) do + assert_equal("fake-valid-token", auth.get_token) + end + end + + ticks = 0 + clock = lambda do + ticks += 1 + if ticks <= 2 + 191.0 + elsif ticks == 3 && caller_locations(2, 1).fetch(0).label.end_with?("#token_expired?") + 219.9 + else + 220.1 + end + end + + OpenAI::Internal::Util.stub(:monotonic_secs, clock) do + auth.stub(:fetch_token_from_exchange, -> (**_options) { raise failure }) do + error = assert_raises(OpenAI::Errors::APIError) { auth.get_token } + assert_same(failure, error) + end + end + + assert_operator( + auth.instance_variable_get(:@cached_token_refresh_at_monotonic), + :<=, + auth.instance_variable_get(:@cached_token_expires_at_monotonic) + ) + end + + def test_token_expiring_after_fallback_acceptance_preserves_the_original_issuer_failure + client = new_client + auth = client.workload_identity_auth + failure = OpenAI::Errors::APIError.new( + url: URI("https://mtls.auth.openai.com/oauth/token"), + status: 503, + message: "issuer temporarily unavailable" + ) + + OpenAI::Internal::Util.stub(:monotonic_secs, -> { 100.0 }) do + @native.stub(:execute, -> (_request) { token_response }) do + assert_equal("fake-valid-token", auth.get_token) + end + end + + clock = {ticks: [191.0, 191.0, 219.9, 220.1]} + OpenAI::Internal::Util.stub(:monotonic_secs, -> { clock.fetch(:ticks).shift || 220.1 }) do + auth.stub(:fetch_token_from_exchange, -> (**_options) { raise failure }) do + error = assert_raises(OpenAI::Errors::APIError) { auth.get_token } + assert_same(failure, error) + assert_equal(503, error.status) + end + end + + assert_same(failure, auth.instance_variable_get(:@refresh_error)) + end + + private def new_client + OpenAI::Client.new(api_key: nil, workload_identity: @identity, http_client: @transport, max_retries: 0) + end + + private def token_response + OpenAI::HTTPClient::Response.new( + status: 200, + headers: {}, + body: JSON.generate( + access_token: "fake-valid-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 120 + ) + ) + end + + private def failure_response(status) + OpenAI::HTTPClient::Response.new(status: status, headers: {}, body: "") + end + + private def model_response + OpenAI::HTTPClient::Response.new( + status: 200, + headers: {"content-type" => "application/json"}, + body: JSON.generate(id: "fake-model", created: 1, object: "model", owned_by: "openai") + ) + end +end diff --git a/test/scripts/live_smoke_test.rb b/test/scripts/live_smoke_test.rb index 8ef2cad21..72137e7e5 100644 --- a/test/scripts/live_smoke_test.rb +++ b/test/scripts/live_smoke_test.rb @@ -143,6 +143,30 @@ def test_api_errors_report_only_the_status_and_exception_class [client, models].each(&:verify) end + def test_untrusted_status_values_cannot_leak_secrets_or_inject_log_lines + sensitive_status = "403\nfake-sensitive-status-token" + failure = StandardError.new("fake-sensitive-exception-message") + failure.define_singleton_method(:status) { sensitive_status } + models = Minitest::Mock.new + models.expect(:list, nil) { raise failure } + client = Minitest::Mock.new + client.expect(:models, models) + error_output = StringIO.new + + refute( + OpenAILiveSmoke.run_cli( + client: client, + model: MODEL, + output: StringIO.new, + error_output: error_output + ) + ) + + assert_equal("[live-smoke] StandardError\n", error_output.string) + refute_includes(error_output.string, "fake-sensitive") + [client, models].each(&:verify) + end + def test_client_initialization_errors_never_print_sensitive_base_urls sensitive_base_url = "https://[fake-sensitive-base-url-token" environment = {