Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand Down
31 changes: 28 additions & 3 deletions lib/openai/auth/workload_identity_auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Comment thread
jbeckwith-oai marked this conversation as resolved.
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?
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions lib/openai/auth/x509_token_exchange.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions lib/openai/auth/x509_workload_identity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
8 changes: 6 additions & 2 deletions lib/openai/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions rbi/openai/auth/x509_token_exchange.rbi
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions rbi/openai/auth/x509_workload_identity.rbi
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ module OpenAI
refresh_buffer_seconds: 1200
)
end

sig { returns(String) }
def inspect
end
end
end
end
5 changes: 3 additions & 2 deletions scripts/live-smoke.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions sig/openai/auth/x509_token_exchange.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module OpenAI
transport: OpenAI::Auth::X509Transport
) -> void

def inspect: -> String

def fetch: (?deadline: Float?) -> ::Hash[Symbol, (String | Float)]
end
end
Expand Down
2 changes: 2 additions & 0 deletions sig/openai/auth/x509_workload_identity.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ module OpenAI
?service_account_id: (String | Symbol)?,
?refresh_buffer_seconds: Integer
) -> void

def inspect: -> String
end
end
end
88 changes: 88 additions & 0 deletions test/openai/auth/x509_client_security_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading