From 46c2d05799844c8ab5734f205ebe6f8336ba7571 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 24 Aug 2026 16:07:23 -0700 Subject: [PATCH 01/10] feat(auth): add caller-attested X.509 transport capability --- lib/openai.rb | 1 + lib/openai/auth/x509_transport.rb | 304 ++++++ lib/openai/net_http_client.rb | 29 +- rbi/openai/auth/x509_transport.rbi | 47 + rbi/openai/net_http_client.rbi | 10 +- sig/openai/auth/x509_transport.rbs | 27 + sig/openai/net_http_client.rbs | 6 +- .../auth/x509_transport_security_test.rb | 82 ++ test/openai/auth/x509_transport_test.rb | 960 ++++++++++++++++++ test/openai/http_client_test.rb | 161 +++ 10 files changed, 1618 insertions(+), 9 deletions(-) create mode 100644 lib/openai/auth/x509_transport.rb create mode 100644 rbi/openai/auth/x509_transport.rbi create mode 100644 sig/openai/auth/x509_transport.rbs create mode 100644 test/openai/auth/x509_transport_security_test.rb create mode 100644 test/openai/auth/x509_transport_test.rb diff --git a/lib/openai.rb b/lib/openai.rb index 981bb4acc..430655e5f 100644 --- a/lib/openai.rb +++ b/lib/openai.rb @@ -60,6 +60,7 @@ require_relative "openai/internal/request_options_scope" require_relative "openai/internal/vector_store_file_uploader" require_relative "openai/net_http_client" +require_relative "openai/auth/x509_transport" require_relative "openai/provider" require_relative "openai/internal/provider" require_relative "openai/providers/azure" diff --git a/lib/openai/auth/x509_transport.rb b/lib/openai/auth/x509_transport.rb new file mode 100644 index 000000000..bb17d0ef6 --- /dev/null +++ b/lib/openai/auth/x509_transport.rb @@ -0,0 +1,304 @@ +# frozen_string_literal: true + +module OpenAI + module Auth + # A caller-attested, application-owned transport for X.509 authentication. + # + # The caller attests that its native client consistently selects one static + # certificate identity and the declared proxy configuration. This attestation + # cannot cryptographically bind a bearer token to a client certificate. + # Certificate rotation requires a new native client and transport capability. + # + # @api public + class X509Transport + ISSUER_ORIGIN = "https://mtls.auth.openai.com" + ISSUER_PATH = "/oauth/token" + API_HOSTS = %w[mtls.api.openai.com mtls-us.api.openai.com mtls-eu.api.openai.com].freeze + PROXY_MODES = [:direct, :http_connect].freeze + FORBIDDEN_HEADERS = %w[api-key x-api-key proxy-authorization].freeze + EXCHANGE_FORBIDDEN_HEADERS = %w[authorization cookie openai-organization openai-project].freeze + HEADER_NAME_PATTERN = /\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/ + BEARER_PATTERN = /\ABearer [A-Za-z0-9\-._~+\/]+=*\z/ + GuardedVerificationCallback = Class.new(Proc) + private_constant( + :ISSUER_PATH, + :API_HOSTS, + :PROXY_MODES, + :FORBIDDEN_HEADERS, + :EXCHANGE_FORBIDDEN_HEADERS, + :HEADER_NAME_PATTERN, + :BEARER_PATTERN, + :GuardedVerificationCallback + ) + + # The approved OpenAI API origin for this immutable capability. + # + # @return [String] + attr_reader :api_origin + + # The proxy behavior explicitly attested by the application. + # + # @return [Symbol] + attr_reader :proxy_mode + + # @param http_client [OpenAI::NetHTTPClient] caller-owned configured native transport + # @param certificate_identity [Symbol] must explicitly be :static + # @param proxy [Symbol] either :direct or :http_connect + # @param api_origin [String] approved OpenAI mTLS API origin + def initialize( + http_client:, + certificate_identity:, + proxy: :direct, + api_origin: "https://mtls.api.openai.com" + ) + unless http_client.instance_of?(OpenAI::NetHTTPClient) + raise ArgumentError, "X.509 transport requires a caller-owned OpenAI::NetHTTPClient" + end + + unless certificate_identity == :static + raise ArgumentError, "X.509 transport requires an explicitly attested static certificate identity" + end + + unless PROXY_MODES.include?(proxy) + raise ArgumentError, "X.509 transport supports only direct connections and HTTP CONNECT proxies" + end + + @api_origin = normalize_api_origin(api_origin).freeze + @proxy_mode = proxy + @http_client = http_client + freeze + end + + # Sends one request through the caller's attested native transport. + # + # @param request [OpenAI::HTTPClient::Request] + # @return [OpenAI::HTTPClient::Response] + def execute(request) + unless request.is_a?(OpenAI::HTTPClient::Request) + raise ArgumentError, "X.509 transport requires an OpenAI::HTTPClient::Request" + end + + url = normalize_destination(request.url) + exchange = exchange_request?(url, request.method) + headers = validated_headers(request.headers, url: url, exchange: exchange) + safe_request = OpenAI::HTTPClient::Request.new( + method: request.method, + url: url.freeze, + headers: headers, + body: request.body, + timeout: request.timeout + ) + response = begin + native_response = @http_client.execute(safe_request) do |connection| + validate_tls_policy!(connection) + validate_proxy_policy!(connection) + end + + unless native_response.is_a?(OpenAI::HTTPClient::Response) + raise TypeError, "`http_client#execute` must return an OpenAI::HTTPClient::Response" + end + + reject_redirect!(native_response, url: url) if (300..399).cover?(native_response.status) + native_response + + rescue OpenAI::Errors::APIConnectionError => error + sanitized = sanitized_connection_error(error, url: url, headers: headers, body: safe_request.body) + raise unless sanitized + + raise sanitized, cause: nil + end + + body = OpenAI::Internal::Util.chain_fused(response.body) do |chunks| + response.body.each { |chunk| chunks << chunk } + + rescue OpenAI::Errors::APIConnectionError => error + sanitized = sanitized_connection_error(error, url: url, headers: headers, body: safe_request.body) + raise unless sanitized + + raise sanitized, cause: nil + end + + OpenAI::HTTPClient::Response.new(status: response.status, headers: response.headers, body: body) + end + + # Validates the effective API destination before a credential is acquired. + # + # @api private + def validate_api_request!(url:, headers:) + destination = normalize_destination(url) + unless "https://#{destination.host.downcase}" == @api_origin + raise ArgumentError, "X.509 API request must use its attested API origin" + end + + validated_headers(headers, url: destination, exchange: false) + end + + private def normalize_api_origin(value) + url = URI.parse(value.to_s) + valid = url.is_a?(URI::HTTPS) && + url.userinfo.nil? && + url.port == URI::HTTPS::DEFAULT_PORT && + API_HOSTS.include?(url.host&.downcase) && + url.path.empty? && + url.query.nil? && + url.fragment.nil? + raise ArgumentError unless valid + + "https://#{url.host.downcase}" + rescue ArgumentError, URI::InvalidURIError + raise ArgumentError, "X.509 transport requires an approved OpenAI mTLS API origin" + end + + private def normalize_destination(value) + url = URI.parse(value.to_s) + unless url.is_a?(URI::HTTPS) && url.userinfo.nil? && url.port == URI::HTTPS::DEFAULT_PORT + raise ArgumentError, "X.509 transport requires an approved HTTPS destination" + end + + origin = "https://#{url.host&.downcase}" + unless origin == ISSUER_ORIGIN || origin == @api_origin + raise ArgumentError, "X.509 transport rejected an unapproved destination" + end + + url + rescue URI::InvalidURIError + raise ArgumentError, "X.509 transport requires an approved HTTPS destination" + end + + private def exchange_request?(url, method) + return false if "https://#{url.host.downcase}" == @api_origin + + unless method == :post && url.path == ISSUER_PATH && url.query.nil? && url.fragment.nil? + raise ArgumentError, "X.509 exchange requires the exact POST issuer endpoint" + end + + true + end + + private def validated_headers(headers, url:, exchange:) + normalized = {} + comparison_keys = {} + headers.each do |name, value| + field = String.new(name.to_s) + unless HEADER_NAME_PATTERN.match?(field) + raise ArgumentError, "X.509 request header names must be valid HTTP field tokens" + end + + field = field.downcase.freeze + key = field.tr("_", "-") + safe_value = String.new(value.to_s).freeze + protected_header = key == "host" || + key == "authorization" || + FORBIDDEN_HEADERS.include?(key) || + EXCHANGE_FORBIDDEN_HEADERS.include?(key) + if normalized.key?(field) || (protected_header && comparison_keys.key?(key)) + raise ArgumentError, "X.509 request contains duplicate credential headers" + end + + if FORBIDDEN_HEADERS.include?(key) || (exchange && EXCHANGE_FORBIDDEN_HEADERS.include?(key)) + raise ArgumentError, "X.509 request contains an unsupported credential header" + end + + if key == "host" && !allowed_authority?(safe_value, url) + raise ArgumentError, "X.509 request Host must match its approved destination" + end + + if key == "authorization" && !BEARER_PATTERN.match?(safe_value) + raise ArgumentError, "X.509 API authorization must contain one valid bearer token" + end + + comparison_keys[key] = true if protected_header + normalized[field] = safe_value + end + + normalized.freeze + end + + private def allowed_authority?(authority, url) + [url.host.downcase, "#{url.host.downcase}:443"].include?(authority.to_s.downcase) + end + + private def validate_tls_policy!(connection) + reject_debug_output!(connection) + + if connection.verify_mode == OpenSSL::SSL::VERIFY_NONE + raise ArgumentError, "X.509 transport requires server peer verification" + end + + if connection.verify_hostname == false + raise ArgumentError, "X.509 transport requires server hostname verification" + end + + callback = connection.verify_callback + return if callback.nil? || callback.instance_of?(GuardedVerificationCallback) + + if connection.started? + raise ArgumentError, "X.509 transport rejects an existing unguarded TLS verification callback" + end + + connection.verify_callback = GuardedVerificationCallback.new do |verified, context| + result = callback.call(verified, context) + reject_debug_output!(connection) + result && verified + end + end + + private def reject_debug_output!(connection) + # Net::HTTP supports setting unredacted wire logging but provides no public reader. + if connection.instance_variable_get(:@debug_output) + raise ArgumentError, "X.509 transport rejects unredacted HTTP debug output" + end + end + + private def validate_proxy_policy!(connection) + if @proxy_mode == :direct + raise ArgumentError, "X.509 direct transport rejects ambient proxies" if connection.proxy? + return + end + + unless connection.proxy? + raise ArgumentError, "X.509 HTTP CONNECT transport requires an active HTTP proxy" + end + + unsupported_proxy = connection.proxy_from_env? && connection.proxy_uri&.scheme != "http" + proxy_tls = if connection.respond_to?(:proxy_use_ssl) + connection.proxy_use_ssl + elsif connection.respond_to?(:proxy_use_ssl=) + # Supported Net::HTTP releases expose only a per-connection public writer. + connection.instance_variable_get(:@proxy_use_ssl) + end + + unsupported_proxy ||= proxy_tls + if unsupported_proxy + raise ArgumentError, "X.509 HTTP CONNECT transport rejects non-HTTP proxy schemes" + end + end + + private def sanitized_url(url) + safe = url.dup + safe.query = nil + safe.fragment = nil + safe + end + + private def sanitized_connection_error(error, url:, headers:, body:) + sensitive_request = !body.nil? || + headers.any? { |name, _value| OpenAI::Internal::Logging.credential_header?(name) } + return if !sensitive_request && error.url.query.nil? && error.url.fragment.nil? + + error.class.new(url: sanitized_url(url)) + end + + private def reject_redirect!(response, url:) + OpenAI::Internal::Util.close_fused!(response.body) + raise( + OpenAI::Errors::APIError.new( + url: sanitized_url(url), + status: response.status, + message: "X.509 transport does not follow redirect responses" + ) + ) + end + end + end +end diff --git a/lib/openai/net_http_client.rb b/lib/openai/net_http_client.rb index ce92cb59c..bc7df2f98 100644 --- a/lib/openai/net_http_client.rb +++ b/lib/openai/net_http_client.rb @@ -160,7 +160,7 @@ def initialize(original) end end - blk.call(pooled_connection.connection) + blk.call(pooled_connection.connection, pooled_connection) end return pool.with(timeout: remaining_timeout(deadline), &checkout) if deadline @@ -236,8 +236,9 @@ def close # Executes a request using a pooled Net::HTTP connection. # # @param request [OpenAI::HTTPClient::Request] + # @yieldparam connection [Net::HTTP] configured connection, before any network I/O # @return [OpenAI::HTTPClient::Response] - def execute(request) + def execute(request, &connection_validator) url = request.url deadline = request.timeout&.then { OpenAI::Internal::Util.monotonic_secs + _1 } @@ -248,7 +249,29 @@ def execute(request) enum = Enumerator.new do |y| next if finished - with_pool(url, deadline: deadline) do |conn| + with_pool(url, deadline: deadline) do |conn, pooled_connection| + previously_started = conn.started? + validation_complete = false + begin + connection_validator&.call(conn) + + if !previously_started && conn.started? + raise ArgumentError, "connection validation must leave the connection unstarted" + end + + expected_ssl = %w[https wss].include?(url.scheme) + unless conn.use_ssl? == expected_ssl + raise ArgumentError, "connection validation must preserve TLS for the requested URL" + end + + validation_complete = true + ensure + unless validation_complete + close_connection(conn) + pooled_connection.connection = nil + end + end + eof = false closing = nil ::Thread.handle_interrupt(Object => :never) do diff --git a/rbi/openai/auth/x509_transport.rbi b/rbi/openai/auth/x509_transport.rbi new file mode 100644 index 000000000..30d96096d --- /dev/null +++ b/rbi/openai/auth/x509_transport.rbi @@ -0,0 +1,47 @@ +# typed: strong + +module OpenAI + module Auth + class X509Transport + ISSUER_ORIGIN = T.let("https://mtls.auth.openai.com", String) + + sig { returns(String) } + attr_reader :api_origin + + sig { returns(Symbol) } + attr_reader :proxy_mode + + sig do + params( + http_client: OpenAI::NetHTTPClient, + certificate_identity: Symbol, + proxy: Symbol, + api_origin: String + ) + .void + end + def initialize( + http_client:, + certificate_identity:, + proxy: :direct, + api_origin: "https://mtls.api.openai.com" + ) + end + + sig do + params(request: OpenAI::HTTPClient::Request).returns( + OpenAI::HTTPClient::Response + ) + end + def execute(request) + end + + sig do + params(url: URI::Generic, headers: T::Hash[String, String]) + .returns(T::Hash[String, String]) + end + def validate_api_request!(url:, headers:) + end + end + end +end diff --git a/rbi/openai/net_http_client.rbi b/rbi/openai/net_http_client.rbi index 468ee65f3..7977bcbb8 100644 --- a/rbi/openai/net_http_client.rbi +++ b/rbi/openai/net_http_client.rbi @@ -28,7 +28,7 @@ module OpenAI params( url: URI::Generic, deadline: T.nilable(Float), - blk: T.proc.params(arg0: Net::HTTP).void + blk: T.proc.params(arg0: Net::HTTP, arg1: T.untyped).void ) .void end @@ -55,11 +55,13 @@ module OpenAI end sig do - params(request: OpenAI::HTTPClient::Request).returns( - OpenAI::HTTPClient::Response + params( + request: OpenAI::HTTPClient::Request, + connection_validator: T.nilable(T.proc.params(connection: Net::HTTP).void) ) + .returns(OpenAI::HTTPClient::Response) end - def execute(request) + def execute(request, &connection_validator) end sig do diff --git a/sig/openai/auth/x509_transport.rbs b/sig/openai/auth/x509_transport.rbs new file mode 100644 index 000000000..a17e1497c --- /dev/null +++ b/sig/openai/auth/x509_transport.rbs @@ -0,0 +1,27 @@ +module OpenAI + module Auth + class X509Transport + ISSUER_ORIGIN: String + + attr_reader api_origin: String + + attr_reader proxy_mode: Symbol + + def initialize: ( + http_client: OpenAI::NetHTTPClient, + certificate_identity: Symbol, + ?proxy: Symbol, + ?api_origin: String + ) -> void + + def execute: ( + OpenAI::HTTPClient::Request request + ) -> OpenAI::HTTPClient::Response + + def validate_api_request!: ( + url: URI::Generic, + headers: ::Hash[String, String] + ) -> ::Hash[String, String] + end + end +end diff --git a/sig/openai/net_http_client.rbs b/sig/openai/net_http_client.rbs index 0cbec9f86..0a5f41d9d 100644 --- a/sig/openai/net_http_client.rbs +++ b/sig/openai/net_http_client.rbs @@ -18,7 +18,7 @@ module OpenAI URI::Generic url, deadline: Float? ) { - (top arg0) -> void + (top arg0, top arg1) -> void } -> void private def configure_connection: ( @@ -33,7 +33,9 @@ module OpenAI def execute: ( OpenAI::HTTPClient::Request request - ) -> OpenAI::HTTPClient::Response + ) ?{ + (Net::HTTP connection) -> void + } -> OpenAI::HTTPClient::Response def initialize: (?size: Integer) ?{ (Net::HTTP) -> void } -> void end diff --git a/test/openai/auth/x509_transport_security_test.rb b/test/openai/auth/x509_transport_security_test.rb new file mode 100644 index 000000000..200be7565 --- /dev/null +++ b/test/openai/auth/x509_transport_security_test.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +class OpenAI::Test::X509TransportSecurityTest < Minitest::Test + def setup + super + @http_client = OpenAI::NetHTTPClient.new + @transport = OpenAI::Auth::X509Transport.new( + http_client: @http_client, + certificate_identity: :static + ) + end + + def teardown + @http_client.close + super + end + + def test_verification_callbacks_cannot_enable_raw_debug_output_during_handshake + output = StringIO.new + connection = Net::HTTP.new("mtls.api.openai.com", 443, nil) + connection.use_ssl = true + connection.verify_callback = -> (verified, _context) { + connection.set_debug_output(output) + verified + } + dispatch = lambda do |_request, &validator| + validator.call(connection) + connection.verify_callback.call(true, nil) + flunk("a verification callback enabled raw debug output") + end + + error = @http_client.stub(:execute, dispatch) do + assert_raises(ArgumentError) do + @transport.execute(request(headers: {"authorization" => "Bearer fake-secret-token"})) + end + end + + assert_match(/debug output/, error.message) + refute_includes(output.string, "fake-secret-token") + end + + def test_redirect_cleanup_errors_never_retain_sensitive_request_data + destination = URI("https://mtls.api.openai.com/v1/models?signature=fake-query-secret#fake-fragment") + classes = [OpenAI::Errors::APIConnectionError, OpenAI::Errors::APITimeoutError] + + classes.each do |error_class| + retained = Net::HTTP::Get.new(destination) + retained["authorization"] = "Bearer fake-retained-secret" + original = error_class.new(url: destination, request: retained, message: "fake-error-secret") + source = OpenAI::Internal::Util.fused_enum([].each) do + raise original, cause: IOError.new("fake-cause-secret") + end + + response = OpenAI::HTTPClient::Response.new(status: 307, headers: {}, body: source) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(error_class) do + @transport.execute( + request(url: destination, headers: {"authorization" => "Bearer fake-retained-secret"}) + ) + end + end + + assert_equal("https://mtls.api.openai.com/v1/models", error.url.to_s) + assert_nil(error.instance_variable_get(:@request)) + assert_nil(error.cause) + refute_match(/fake-query-secret|fake-retained-secret|fake-error-secret|fake-cause-secret/, error.inspect) + end + end + + private def request(url: URI("https://mtls.api.openai.com/v1/models"), headers: {}) + OpenAI::HTTPClient::Request.new( + method: :get, + url: url, + headers: headers, + body: nil, + timeout: 2.0 + ) + end +end diff --git a/test/openai/auth/x509_transport_test.rb b/test/openai/auth/x509_transport_test.rb new file mode 100644 index 000000000..db960ab26 --- /dev/null +++ b/test/openai/auth/x509_transport_test.rb @@ -0,0 +1,960 @@ +# frozen_string_literal: true + +require_relative "../test_helper" +require_relative "../support/mtls_wire_harness" +require "open3" +require "rbconfig" + +class OpenAI::Test::X509TransportTest < Minitest::Test + extend Minitest::Serial + + Harness = OpenAI::Test::MTLSWireHarness + WIRE_SUBPROCESS = "OPENAI_RUBY_X509_TRANSPORT_WIRE_SUBPROCESS" + WIRE_SUBPROCESS_TIMEOUT = 15 + WIRE_SUBPROCESS_TERM_GRACE = 1 + + def setup + super + @http_client = OpenAI::NetHTTPClient.new + end + + def teardown + @http_client.close + super + end + + def test_requires_a_concrete_native_client_and_an_explicit_static_identity + assert_raises(ArgumentError) do + OpenAI::Auth::X509Transport.new(http_client: Object.new, certificate_identity: :static) + end + + assert_raises(ArgumentError) do + OpenAI::Auth::X509Transport.new(http_client: @http_client, certificate_identity: :dynamic) + end + + assert_raises(ArgumentError) do + OpenAI::Auth::X509Transport.new( + http_client: @http_client, + certificate_identity: :static, + proxy: :https_connect + ) + end + + capability = capability_for(@http_client) + assert_predicate(capability, :frozen?) + assert_equal("https://mtls.api.openai.com", capability.api_origin) + assert_equal(:direct, capability.proxy_mode) + end + + def test_rejects_unapproved_api_origins_before_dispatch + origins = [ + "http://mtls.api.openai.com", + "https://api.openai.com", + "https://tenant.openai.azure.com", + "https://mtls.api.openai.com:8443", + "https://user@mtls.api.openai.com", + "https://mtls.api.openai.com/v1", + "https://mtls.api.openai.com." + ] + + origins.each do |origin| + error = assert_raises(ArgumentError) do + capability_for(@http_client, api_origin: origin) + end + + assert_match(/approved OpenAI mTLS API origin/, error.message) + end + end + + def test_accepts_approved_regional_origins + %w[mtls.api.openai.com mtls-us.api.openai.com mtls-eu.api.openai.com].each do |hostname| + capability = capability_for(@http_client, api_origin: "https://#{hostname}:443") + assert_equal("https://#{hostname}", capability.api_origin) + end + end + + def test_rejects_wrong_issuer_path_method_or_api_authority + capability = capability_for(@http_client) + invalid_requests = [ + request(:get, "https://mtls.auth.openai.com/oauth/token"), + request(:post, "https://mtls.auth.openai.com/oauth/token/"), + request(:post, "https://mtls.auth.openai.com/oauth/token?audience=other"), + request(:get, "https://mtls-eu.api.openai.com/v1/models"), + request(:get, "http://mtls.api.openai.com/v1/models"), + request(:get, "https://user@mtls.api.openai.com/v1/models"), + request(:get, "https://mtls.api.openai.com:8443/v1/models") + ] + + @http_client.stub(:execute, -> (_value) { flunk("request must be rejected before dispatch") }) do + invalid_requests.each do |invalid_request| + assert_raises(ArgumentError) { capability.execute(invalid_request) } + end + end + end + + def test_rejects_target_credentials_authority_confusion_and_duplicate_aliases + capability = capability_for(@http_client) + invalid_headers = [ + {"authorization" => "Basic fake"}, + {"authorization" => "Bearer fake-token", "x-api-key" => "fake-key"}, + {"authorization" => "Bearer fake-token", "X_API_KEY" => "fake-key"}, + {"authorization" => "Bearer fake-token", "Proxy_Authorization" => "Basic fake"}, + {"authorization" => "Bearer fake-token", "host" => "attacker.invalid"}, + {"authorization" => "Bearer fake-token", "Host" => "mtls.api.openai.com:8443"}, + {"authorization" => "Bearer fake-token", "AUTHORIZATION" => "Bearer other"}, + {"x: safe\r\nauthorization" => "Bearer injected"}, + {"bad\u0000name" => "fake-value"}, + {"bad\tname" => "fake-value"}, + {"bad:name" => "fake-value"}, + {"bad name" => "fake-value"}, + {"\u212Aey" => "fake-value"}, + {"" => "fake-value"} + ] + + @http_client.stub(:execute, -> (_value) { flunk("request must be rejected before dispatch") }) do + invalid_headers.each do |headers| + assert_raises(ArgumentError) do + capability.execute(request(:get, "https://mtls.api.openai.com/v1/models", headers: headers)) + end + end + end + end + + def test_exchange_rejects_inherited_authorization_cookies_and_tenant_headers + capability = capability_for(@http_client) + rejected = [ + "authorization", + "cookie", + "openai-organization", + "openai-project", + "proxy_authorization", + "x: safe\r\nauthorization", + "x: safe\r\nhost", + "x: safe\r\ncontent-length" + ] + + @http_client.stub(:execute, -> (_value) { flunk("exchange must be rejected before dispatch") }) do + rejected.each do |name| + assert_raises(ArgumentError) do + capability.execute( + request(:post, "https://mtls.auth.openai.com/oauth/token", headers: {name => "fake-value"}) + ) + end + end + end + end + + def test_preserves_underscores_in_safe_custom_header_names + capability = capability_for(@http_client) + observed = nil + response = OpenAI::HTTPClient::Response.new(status: 200, headers: {}, body: "") + + @http_client.stub( + :execute, + -> (request) { + observed = request + response + } + ) do + capability.execute( + request( + :get, + "https://mtls.api.openai.com/v1/models", + headers: { + "authorization" => "Bearer fake-token", + "x_trace_id" => "fake-trace", + "x-trace-id" => "fake-dashed-trace" + } + ) + ) + end + + assert_equal("fake-trace", observed.headers.fetch("x_trace_id")) + assert_equal("fake-dashed-trace", observed.headers.fetch("x-trace-id")) + end + + def test_api_request_validation_returns_deeply_frozen_plain_string_headers + capability = capability_for(@http_client) + mutable_string = Class.new(String) do + def to_s = self + end + + original = mutable_string.new("Bearer fake-token") + + headers = capability.validate_api_request!( + url: URI("https://mtls.api.openai.com/v1/models"), + headers: {"authorization" => original, "x_trace_id" => mutable_string.new("fake-trace")} + ) + + assert_predicate(headers, :frozen?) + headers.each do |name, value| + assert_instance_of(String, name) + assert_instance_of(String, value) + assert_predicate(name, :frozen?) + assert_predicate(value, :frozen?) + end + + original.replace("Bearer fake-replacement") + assert_equal("Bearer fake-token", headers.fetch("authorization")) + assert_equal("fake-trace", headers.fetch("x_trace_id")) + end + + def test_rejects_redirect_without_retaining_signed_location + response = OpenAI::HTTPClient::Response.new( + status: 307, + headers: {"location" => "https://attacker.invalid/?signature=fake-secret"}, + body: "" + ) + capability = capability_for(@http_client) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::APIError) do + capability.execute( + request( + :get, + "https://mtls.api.openai.com/v1/models?signature=fake-request-secret&cursor=fake-cursor#fake-fragment", + headers: {"authorization" => "Bearer fake-token"} + ) + ) + end + end + + assert_equal(307, error.status) + refute_match(/fake-secret|attacker/, error.inspect) + assert_equal("https://mtls.api.openai.com/v1/models", error.url.to_s) + assert_nil(error.headers) + assert_nil(error.body) + end + + def test_transport_errors_redact_signed_query_data_and_sensitive_causes + capability = capability_for(@http_client) + classes = [OpenAI::Errors::APIConnectionError, OpenAI::Errors::APITimeoutError] + + classes.each do |error_class| + destination = URI("https://mtls.api.openai.com/v1/models?signature=fake-query-secret#fake-fragment") + original = error_class.new(url: destination, message: "fake-error-secret") + sensitive_cause = IOError.new("fake-cause-secret") + dispatch = -> (_request) { raise original, cause: sensitive_cause } + + error = @http_client.stub(:execute, dispatch) do + assert_raises(error_class) do + capability.execute(request(:get, destination, headers: {"authorization" => "Bearer fake-token"})) + end + end + + assert_equal("https://mtls.api.openai.com/v1/models", error.url.to_s) + assert_nil(error.cause) + assert_nil(error.headers) + assert_nil(error.body) + refute_match(/fake-query-secret|fake-fragment|fake-error-secret|fake-cause-secret/, error.inspect) + end + end + + def test_transport_errors_never_retain_bearer_requests_without_signed_queries + capability = capability_for(@http_client) + destination = URI("https://mtls.api.openai.com/v1/models") + classes = [OpenAI::Errors::APIConnectionError, OpenAI::Errors::APITimeoutError] + + classes.each do |error_class| + retained = Net::HTTP::Get.new(destination) + retained["authorization"] = "Bearer fake-retained-secret" + original = error_class.new(url: destination, request: retained, message: "fake-secret-diagnostics") + dispatch = -> (_request) { raise original, cause: IOError.new("fake-cause-secret") } + + error = @http_client.stub(:execute, dispatch) do + assert_raises(error_class) do + capability.execute( + request(:get, destination, headers: {"authorization" => "Bearer fake-retained-secret"}) + ) + end + end + + assert_equal(destination.to_s, error.url.to_s) + assert_nil(error.instance_variable_get(:@request)) + assert_nil(error.cause) + refute_match(/fake-retained-secret|fake-secret-diagnostics|fake-cause-secret/, error.inspect) + end + end + + def test_transport_errors_never_retain_credential_free_request_bodies + capability = capability_for(@http_client) + destination = URI("https://mtls.auth.openai.com/oauth/token") + classes = [OpenAI::Errors::APIConnectionError, OpenAI::Errors::APITimeoutError] + + classes.product([false, true]).each do |error_class, deferred| + retained = Net::HTTP::Post.new(destination) + retained.body_stream = StringIO.new("fake-private-body") + original = error_class.new(url: destination, request: retained, message: "fake-secret-diagnostics") + failure = -> { raise original, cause: IOError.new("fake-cause-secret") } + dispatch = if deferred + -> (_request) { + OpenAI::HTTPClient::Response.new(status: 200, headers: {}, body: Enumerator.new { failure.call }) + } + else + -> (_request) { failure.call } + end + + error = @http_client.stub(:execute, dispatch) do + assert_raises(error_class) do + response = capability.execute(request(:post, destination, body: "fake-private-body")) + response.body.to_a + end + end + + assert_equal(destination.to_s, error.url.to_s) + assert_nil(error.instance_variable_get(:@request)) + assert_nil(error.cause) + refute_match(/fake-private-body|fake-secret-diagnostics|fake-cause-secret/, error.inspect) + end + end + + def test_deferred_transport_errors_never_retain_signed_urls_or_bearer_requests + capability = capability_for(@http_client) + classes = [OpenAI::Errors::APIConnectionError, OpenAI::Errors::APITimeoutError] + destinations = [ + URI("https://mtls.api.openai.com/v1/models?signature=fake-query-secret#fake-fragment"), + URI("https://mtls.api.openai.com/v1/models") + ] + + classes.product(destinations).each do |error_class, destination| + retained = Net::HTTP::Get.new(destination) + retained["authorization"] = "Bearer fake-retained-secret" + original = error_class.new(url: destination, request: retained, message: "fake-error-secret") + events = [] + body = Enumerator.new do |chunks| + events << :started + chunks << "fake-safe-prefix" + raise original, cause: IOError.new("fake-cause-secret") + ensure + events << :closed + end + + native_response = OpenAI::HTTPClient::Response.new(status: 200, headers: {}, body: body) + + response = @http_client.stub(:execute, -> (_request) { native_response }) do + capability.execute( + request(:get, destination, headers: {"authorization" => "Bearer fake-retained-secret"}) + ) + end + + assert_empty(events) + observed_chunks = [] + error = assert_raises(error_class) { response.body.each { |chunk| observed_chunks << chunk } } + + assert_equal(["fake-safe-prefix"], observed_chunks) + assert_equal([:started, :closed], events) + assert_equal("https://mtls.api.openai.com/v1/models", error.url.to_s) + assert_nil(error.instance_variable_get(:@request)) + assert_nil(error.cause) + refute_match( + /fake-query-secret|fake-fragment|fake-retained-secret|fake-error-secret|fake-cause-secret/, + error.inspect + ) + end + end + + def test_deferred_credential_free_transport_errors_preserve_safe_causes + capability = capability_for(@http_client) + destination = URI("https://mtls.api.openai.com/v1/models") + original = OpenAI::Errors::APIConnectionError.new(url: destination) + safe_cause = IOError.new("safe connection failure") + body = Enumerator.new { raise original, cause: safe_cause } + native_response = OpenAI::HTTPClient::Response.new(status: 200, headers: {}, body: body) + + response = @http_client.stub(:execute, -> (_request) { native_response }) do + capability.execute(request(:get, destination)) + end + + error = assert_raises(OpenAI::Errors::APIConnectionError) { response.body.to_a } + assert_same(original, error) + assert_same(safe_cause, error.cause) + end + + def test_closing_wrapped_response_closes_the_original_stream + capability = capability_for(@http_client) + events = [] + source = Enumerator.new do |chunks| + events << :started + chunks << "fake-first-chunk" + chunks << "fake-second-chunk" + end + + body = OpenAI::Internal::Util.fused_enum(source) { events << :closed } + native_response = OpenAI::HTTPClient::Response.new(status: 200, headers: {}, body: body) + + response = @http_client.stub(:execute, -> (_request) { native_response }) do + capability.execute(request(:get, "https://mtls.api.openai.com/v1/models")) + end + + assert_empty(events) + assert_equal("fake-first-chunk", response.body.next) + assert_equal([:started], events) + + OpenAI::Internal::Util.close_fused!(response.body) + + assert_equal([:started, :closed], events) + end + + def test_rejects_unredacted_net_http_debug_output_before_network_access + debug_output = StringIO.new + configured_client = OpenAI::NetHTTPClient.new { |connection| connection.set_debug_output(debug_output) } + capability = capability_for(configured_client) + + Socket.stub(:tcp, -> (*_args, **_kwargs) { flunk("debug output must fail before network access") }) do + error = assert_raises(ArgumentError) do + capability.execute( + request(:get, "https://mtls.api.openai.com/v1/models", headers: {"authorization" => "Bearer fake-token"}) + ) + end + + assert_match(/debug output/, error.message) + end + + assert_empty(debug_output.string) + ensure + configured_client&.close + end + + def test_connection_errors_before_url_or_header_validation_are_not_masked + capability = capability_for(@http_client) + destination = URI("https://mtls.api.openai.com/v1/models") + url_failure = OpenAI::Errors::APIConnectionError.new(url: destination) + header_failure = OpenAI::Errors::APIConnectionError.new(url: destination) + raising_string = lambda do |failure| + subclass = Class.new(String) do + define_method(:to_s) { raise failure } + end + + subclass.new(destination.to_s) + end + + cases = [ + [raising_string.call(url_failure), {}, url_failure], + [destination, {"authorization" => raising_string.call(header_failure)}, header_failure] + ] + + @http_client.stub(:execute, -> (_value) { flunk("invalid request must fail before dispatch") }) do + cases.each do |url, headers, expected| + invalid_request = OpenAI::HTTPClient::Request.new( + method: :get, + url: url, + headers: headers, + body: nil, + timeout: 2.0 + ) + + error = assert_raises(OpenAI::Errors::APIConnectionError) { capability.execute(invalid_request) } + assert_same(expected, error) + end + end + end + + def test_transport_errors_preserve_safe_causes_when_no_query_is_present + capability = capability_for(@http_client) + destination = URI("https://mtls.api.openai.com/v1/models") + original = OpenAI::Errors::APIConnectionError.new(url: destination) + safe_cause = IOError.new("safe connection failure") + dispatch = -> (_request) { raise original, cause: safe_cause } + + error = @http_client.stub(:execute, dispatch) do + assert_raises(OpenAI::Errors::APIConnectionError) do + capability.execute(request(:get, destination)) + end + end + + assert_same(original, error) + assert_same(safe_cause, error.cause) + end + + def test_real_wire_issuer_and_api_share_attested_client_identity + return unless run_wire_subprocess + + hostnames = %w[mtls.auth.openai.com mtls.api.openai.com] + pki = Harness::PKI.new(hostnames: hostnames) + issuer = Harness::MTLSServer.new( + hostname: hostnames.fetch(0), + pki: pki, + body: {access_token: "fake-workload-token"} + ) + api = Harness::MTLSServer.new(hostname: hostnames.fetch(1), pki: pki) + proxy = Harness::ConnectProxy.new( + authority_ports: { + "#{issuer.hostname}:443" => issuer.local_port, + "#{api.hostname}:443" => api.local_port + }, + expected_connections: 2 + ) + identity = pki.client_identity + verification_results = [] + configured_client = OpenAI::NetHTTPClient.new(size: 1) do |connection| + connection.cert_store = pki.trust_store + connection.cert = identity.certificate + connection.extra_chain_cert = [pki.intermediate_certificate] + connection.key = identity.key + connection.verify_callback = -> (verified, _context) { + verification_results << verified + verified + } + end + + capability = capability_for(configured_client, proxy: :http_connect) + + Harness.with_proxy_environment(proxy.uri) do + issuer_response = capability.execute( + request( + :post, + "https://mtls.auth.openai.com/oauth/token", + headers: {"content-type" => "application/json"}, + body: "{}" + ) + ) + assert_equal("fake-workload-token", JSON.parse(issuer_response.body.to_a.join).fetch("access_token")) + + api_response = capability.execute( + request( + :get, + "https://mtls.api.openai.com/v1/models", + headers: {"authorization" => "Bearer fake-workload-token"} + ) + ) + assert_equal(true, JSON.parse(api_response.body.to_a.join).fetch("ok")) + end + + configured_client.close + issuer_record = issuer.finish.fetch(0) + api_record = api.finish.fetch(0) + proxy_records = proxy.finish + + refute_empty(verification_results) + assert(verification_results.all?) + assert_equal(identity.certificate.to_der, issuer_record.peer_certificate.to_der) + assert_equal(identity.certificate.to_der, api_record.peer_certificate.to_der) + refute_includes(issuer_record.headers, "authorization") + assert_equal("Bearer fake-workload-token", api_record.headers.fetch("authorization")) + proxy_records.each do |record| + assert_equal("Basic cHJveHktdXNlcjpwcm94eS1wYXNz", record.headers.fetch("proxy-authorization")) + refute_includes(record.headers, "authorization") + end + + ensure + configured_client&.close + proxy&.close + issuer&.close + api&.close + end + + def test_real_wire_permissive_callback_cannot_override_untrusted_server_certificate + return unless run_wire_subprocess + + hostname = "mtls.api.openai.com" + pki = Harness::PKI.new(hostnames: [hostname]) + server = Harness::MTLSServer.new(hostname: hostname, pki: pki) + proxy = Harness::ConnectProxy.new( + authority_ports: {"#{hostname}:443" => server.local_port}, + expected_connections: 1 + ) + identity = pki.client_identity + verification_results = [] + configured_client = OpenAI::NetHTTPClient.new do |connection| + Harness.configure_http_connect_proxy(connection, proxy.uri) + connection.cert_store = OpenSSL::X509::Store.new + connection.cert = identity.certificate + connection.extra_chain_cert = [pki.intermediate_certificate] + connection.key = identity.key + connection.verify_callback = -> (verified, _context) { + verification_results << verified + true + } + end + + capability = capability_for(configured_client, proxy: :http_connect) + assert_raises(OpenAI::Errors::APIConnectionError) do + capability.execute( + request( + :get, + "https://mtls.api.openai.com/v1/models", + headers: {"authorization" => "Bearer fake-secret-token"} + ) + ) + end + + configured_client.close + record = server.finish.fetch(0) + assert_includes(verification_results, false) + assert_nil(record.request_line) + assert_instance_of(OpenSSL::SSL::SSLError, record.handshake_error) + assert_equal(1, proxy.finish.length) + ensure + configured_client&.close + proxy&.close + server&.close + end + + def test_real_wire_transport_rejects_an_existing_unguarded_tls_connection + return unless run_wire_subprocess + + hostname = "mtls.api.openai.com" + pki = Harness::PKI.new(hostnames: [hostname]) + server, server_thread, records = start_keepalive_server(pki, hostname) + proxy = Harness::ConnectProxy.new( + authority_ports: {"#{hostname}:443" => server.local_address.ip_port}, + expected_connections: 1 + ) + identity = pki.client_identity + configured_client = OpenAI::NetHTTPClient.new(size: 1) do |connection| + Harness.configure_http_connect_proxy(connection, proxy.uri) + connection.cert_store = OpenSSL::X509::Store.new + connection.cert = identity.certificate + connection.extra_chain_cert = [pki.intermediate_certificate] + connection.key = identity.key + connection.verify_callback = -> (_verified, _context) { true } + end + + destination = "https://mtls.api.openai.com/v1/models" + first = configured_client.execute(request(:get, destination)) + first.body.to_a + + capability = capability_for(configured_client, proxy: :http_connect) + error = assert_raises(ArgumentError) do + capability.execute(request(:get, destination, headers: {"authorization" => "Bearer fake-secret-token"})) + end + + assert_match(/unguarded TLS verification callback/, error.message) + configured_client.close + Timeout.timeout(2) { server_thread.join } + assert_equal(1, records.length) + refute_includes(records.first, "authorization") + assert_equal(1, proxy.finish.length) + ensure + configured_client&.close + proxy&.close + server&.close + server_thread&.kill&.join if server_thread&.alive? + end + + def test_real_wire_transport_reuses_its_own_guarded_tls_connection + return unless run_wire_subprocess + + hostname = "mtls.api.openai.com" + pki = Harness::PKI.new(hostnames: [hostname]) + server, server_thread, records = start_keepalive_server(pki, hostname) + proxy = Harness::ConnectProxy.new( + authority_ports: {"#{hostname}:443" => server.local_address.ip_port}, + expected_connections: 1 + ) + identity = pki.client_identity + callback_results = [] + configured_client = OpenAI::NetHTTPClient.new(size: 1) do |connection| + Harness.configure_http_connect_proxy(connection, proxy.uri) + connection.cert_store = pki.trust_store + connection.cert = identity.certificate + connection.extra_chain_cert = [pki.intermediate_certificate] + connection.key = identity.key + connection.verify_callback = -> (verified, _context) { + callback_results << verified + verified + } + end + + capability = capability_for(configured_client, proxy: :http_connect) + 2.times do |index| + response = capability.execute( + request( + :get, + "https://mtls.api.openai.com/v1/models", + headers: {"authorization" => "Bearer fake-token-#{index}"} + ) + ) + response.body.to_a + end + + configured_client.close + Timeout.timeout(2) { server_thread.join } + assert_equal(["Bearer fake-token-0", "Bearer fake-token-1"], records.map { _1.fetch("authorization") }) + refute_empty(callback_results) + assert(callback_results.all?) + assert_equal(1, proxy.finish.length) + ensure + configured_client&.close + proxy&.close + server&.close + server_thread&.kill&.join if server_thread&.alive? + end + + def test_real_wire_direct_transport_rejects_ambient_proxy_before_connect + return unless run_wire_subprocess + + proxy = Harness::ConnectProxy.new(authority_ports: {}, expected_connections: 0) + capability = capability_for(@http_client, proxy: :direct) + + Harness.with_proxy_environment(proxy.uri) do + error = assert_raises(ArgumentError) do + capability.execute(request(:post, "https://mtls.auth.openai.com/oauth/token")) + end + + assert_match(/direct transport rejects ambient proxies/, error.message) + end + + assert_empty(proxy.finish) + ensure + proxy&.close + end + + def test_real_wire_connect_transport_rejects_https_proxy_before_credentials_are_sent + return unless run_wire_subprocess + + proxy = Harness::ConnectProxy.new(authority_ports: {}, expected_connections: 0) + unsupported_proxy = URI(proxy.uri.to_s.sub(/\Ahttp:/, "https:")) + capability = capability_for(@http_client, proxy: :http_connect) + + Harness.with_proxy_environment(unsupported_proxy) do + error = assert_raises(ArgumentError) do + capability.execute(request(:post, "https://mtls.auth.openai.com/oauth/token")) + end + + assert_match(/rejects non-HTTP proxy schemes/, error.message) + end + + assert_empty(proxy.finish) + ensure + proxy&.close + end + + def test_real_wire_connect_transport_rejects_explicit_https_proxy_before_credentials_are_sent + unless Net::HTTP.method_defined?(:proxy_use_ssl=) + refute_respond_to(Net::HTTP.new("localhost"), :proxy_use_ssl=) + return + end + + return unless run_wire_subprocess + + proxy = Harness::ConnectProxy.new(authority_ports: {}, expected_connections: 0) + configured_client = OpenAI::NetHTTPClient.new do |connection| + Harness.configure_http_connect_proxy(connection, proxy.uri) + connection.proxy_use_ssl = true + end + + capability = capability_for(configured_client, proxy: :http_connect) + + error = assert_raises(ArgumentError) do + capability.execute(request(:post, "https://mtls.auth.openai.com/oauth/token")) + end + + assert_match(/rejects non-HTTP proxy schemes/, error.message) + assert_empty(proxy.finish) + ensure + configured_client&.close + proxy&.close + end + + def test_real_wire_transport_rejects_disabled_server_verification_before_connect + return unless run_wire_subprocess + + proxy = Harness::ConnectProxy.new(authority_ports: {}, expected_connections: 0) + configured_client = nil + unsafe_configurations = { + "peer verification" => -> (connection) { connection.verify_mode = OpenSSL::SSL::VERIFY_NONE }, + "hostname verification" => -> (connection) { connection.verify_hostname = false } + } + + unsafe_configurations.each do |verification, configure| + configured_client = OpenAI::NetHTTPClient.new do |connection| + Harness.configure_http_connect_proxy(connection, proxy.uri) + configure.call(connection) + end + + capability = capability_for(configured_client, proxy: :http_connect) + + error = assert_raises(ArgumentError) do + capability.execute(request(:post, "https://mtls.auth.openai.com/oauth/token")) + end + + assert_match(/#{Regexp.escape(verification)}/, error.message) + configured_client.close + end + + assert_empty(proxy.finish) + ensure + configured_client&.close + proxy&.close + end + + def test_native_execute_callback_cannot_disable_tls_before_connection + proxy = Harness::ConnectProxy.new(authority_ports: {}, expected_connections: 0) + configured_client = OpenAI::NetHTTPClient.new do |connection| + Harness.configure_http_connect_proxy(connection, proxy.uri) + end + + error = assert_raises(ArgumentError) do + configured_client.execute(request(:get, "https://mtls.api.openai.com/v1/models")) do |connection| + connection.use_ssl = false + end + end + + assert_match(/preserve TLS/, error.message) + assert_empty(proxy.finish) + ensure + configured_client&.close + proxy&.close + end + + def test_native_execute_recovers_after_a_callback_temporarily_disables_tls + return unless run_wire_subprocess + + hostname = "mtls.api.openai.com" + pki = Harness::PKI.new(hostnames: [hostname]) + server = Harness::MTLSServer.new(hostname: hostname, pki: pki) + proxy = Harness::ConnectProxy.new( + authority_ports: {"#{hostname}:443" => server.local_port}, + expected_connections: 1 + ) + identity = pki.client_identity + configured_client = OpenAI::NetHTTPClient.new(size: 1) do |connection| + Harness.configure_http_connect_proxy(connection, proxy.uri) + connection.cert_store = pki.trust_store + connection.cert = identity.certificate + connection.extra_chain_cert = [pki.intermediate_certificate] + connection.key = identity.key + end + + native_request = request(:get, "https://mtls.api.openai.com/v1/models") + error = assert_raises(ArgumentError) do + configured_client.execute(native_request) { |connection| connection.use_ssl = false } + end + + assert_match(/preserve TLS/, error.message) + response = configured_client.execute(native_request) + assert_equal(true, JSON.parse(response.body.to_a.join).fetch("ok")) + + configured_client.close + assert_equal(1, server.finish.length) + assert_equal(1, proxy.finish.length) + ensure + configured_client&.close + proxy&.close + server&.close + end + + def test_native_execute_callback_cannot_start_an_unstarted_connection + server = TCPServer.new("127.0.0.1", 0) + port = server.local_address.ip_port + native_request = request(:get, "http://127.0.0.1:#{port}/probe") + configured_client = OpenAI::NetHTTPClient.new + observed_connection = nil + + error = assert_raises(ArgumentError) do + configured_client.execute(native_request) do |connection| + observed_connection = connection + connection.start + end + end + + assert_match(/unstarted/, error.message) + refute_predicate(observed_connection, :started?) + ensure + configured_client&.close + server&.close + end + + def test_wire_subprocess_timeout_terminates_stalled_child + sleep if ENV[WIRE_SUBPROCESS] == "1" + + error = assert_raises(Minitest::Assertion) { run_wire_subprocess(timeout: 0.1) } + assert_match(/X\.509 wire subprocess exceeded 0\.1 seconds/, error.message) + end + + private def run_wire_subprocess(timeout: WIRE_SUBPROCESS_TIMEOUT) + return true if ENV[WIRE_SUBPROCESS] == "1" + + input, output, waiter = Open3.popen2e( + {WIRE_SUBPROCESS => "1"}, + RbConfig.ruby, + File.expand_path(__FILE__), + "--name", + name + ) + input.close + reader = Thread.new { output.read } + timed_out = waiter.join(timeout).nil? + terminate_wire_subprocess(waiter) if timed_out + captured = reader.value + captured << "\nX.509 wire subprocess exceeded #{timeout} seconds\n" if timed_out + + assert_predicate(waiter.value, :success?, captured) + false + ensure + unless waiter.nil? + begin + Process.kill("KILL", waiter.pid) if waiter.alive? + rescue Errno::ESRCH + nil + end + + waiter.join + end + + input&.close unless input&.closed? + output&.close unless output&.closed? + reader&.kill if reader&.alive? + reader&.join + end + + private def terminate_wire_subprocess(waiter) + Process.kill("TERM", waiter.pid) + return unless waiter.join(WIRE_SUBPROCESS_TERM_GRACE).nil? + + Process.kill("KILL", waiter.pid) + waiter.join + rescue Errno::ESRCH + waiter.join + end + + private def start_keepalive_server(pki, hostname) + server = TCPServer.new("127.0.0.1", 0) + ssl_server = OpenSSL::SSL::SSLServer.new(server, pki.server_context(hostname)) + records = [] + worker = Thread.new do + socket = ssl_server.accept + while socket.gets + headers = {} + while (line = socket.gets) && line != "\r\n" + name, value = line.split(":", 2) + headers[name.downcase] = value.to_s.strip + end + + records << headers.freeze + body = JSON.generate(ok: true) + socket.write( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" \ + "Content-Length: #{body.bytesize}\r\nConnection: keep-alive\r\n\r\n#{body}" + ) + end + + ensure + socket&.close + end + + worker.report_on_exception = false + + [server, worker, records] + end + + private def capability_for(http_client, proxy: :direct, api_origin: "https://mtls.api.openai.com") + OpenAI::Auth::X509Transport.new( + http_client: http_client, + certificate_identity: :static, + proxy: proxy, + api_origin: api_origin + ) + end + + private def request(method, url, headers: {}, body: nil) + OpenAI::HTTPClient::Request.new( + method: method, + url: URI(url), + headers: headers, + body: body, + timeout: 2.0 + ) + end +end diff --git a/test/openai/http_client_test.rb b/test/openai/http_client_test.rb index 780385e56..8171e3fd1 100644 --- a/test/openai/http_client_test.rb +++ b/test/openai/http_client_test.rb @@ -25,6 +25,8 @@ class StubNetHTTP :max_retries, :open_timeout, :read_timeout, + :use_ssl, + :verify_mode, :write_timeout ) @@ -690,6 +692,165 @@ def test_net_http_client_closes_a_connection_started_by_a_failing_configurator assert_predicate(connection, :finished?) end + def test_net_http_client_closes_a_connection_started_by_a_failing_validator + connection = StubNetHTTP.new(use_ssl: true) + replacement = StubNetHTTP.new(use_ssl: true) + available = [connection, replacement] + validation_error = StandardError.new("connection validation failed") + client_class = Class.new(OpenAI::NetHTTPClient) do + define_method(:connect) { |**| available.shift } + private(:connect) + end + + http_client = client_class.new + request = OpenAI::HTTPClient::Request.new( + method: :get, + url: URI("https://example.com/v1/probe"), + headers: {"authorization" => "Bearer fake-sensitive-token"}, + body: nil, + timeout: 1 + ) + + raised = assert_raises(StandardError) do + http_client.execute(request) do |http| + assert_same(connection, http) + http.start + raise validation_error + end + end + + assert_same(validation_error, raised) + assert_predicate(connection, :finished?) + refute_predicate(connection, :started?) + refute_includes(raised.full_message(highlight: false), "fake-sensitive-token") + + follow_up_error = StandardError.new("follow-up validation failed") + raised = assert_raises(StandardError) do + http_client.execute(request) do |http| + assert_same(replacement, http) + refute_predicate(http, :started?) + raise follow_up_error + end + end + + assert_same(follow_up_error, raised) + ensure + http_client&.close + end + + def test_net_http_client_discards_an_existing_connection_when_validation_fails + connections = [] + validation_error = StandardError.new("connection validation failed") + client_class = Class.new(OpenAI::NetHTTPClient) do + define_method(:connect) { |**| StubNetHTTP.new(use_ssl: true).tap { connections << _1 } } + private(:connect) + end + + http_client = client_class.new + request = OpenAI::HTTPClient::Request.new( + method: :get, + url: URI("https://example.com/v1/probe"), + headers: {}, + body: nil, + timeout: 1 + ) + connection = checkout_connection(http_client, request.url) + + raised = assert_raises(StandardError) do + http_client.execute(request) do |http| + assert_same(connection, http) + raise validation_error + end + end + + assert_same(validation_error, raised) + refute_predicate(connection, :started?) + assert_predicate(connection, :finished?) + replacement = checkout_connection(http_client, request.url) + refute_same(connection, replacement) + assert_equal(2, connections.length) + ensure + http_client&.close + end + + def test_net_http_client_discards_tls_settings_after_a_failing_validator + connections = [] + validation_error = StandardError.new("connection validation failed") + client_class = Class.new(OpenAI::NetHTTPClient) do + define_method(:connect) do |**| + StubNetHTTP.new(use_ssl: true, request_error: IOError.new("safe network probe")).tap do |connection| + connection.verify_mode = OpenSSL::SSL::VERIFY_PEER + connections << connection + end + end + + private(:connect) + end + + http_client = client_class.new + request = OpenAI::HTTPClient::Request.new( + method: :get, + url: URI("https://example.com/v1/probe"), + headers: {"authorization" => "Bearer fake-sensitive-token"}, + body: nil, + timeout: 1 + ) + + raised = assert_raises(StandardError) do + http_client.execute(request) do |connection| + connection.verify_mode = OpenSSL::SSL::VERIFY_NONE + raise validation_error + end + end + + assert_same(validation_error, raised) + assert_equal(OpenSSL::SSL::VERIFY_NONE, connections.fetch(0).verify_mode) + assert_raises(OpenAI::Errors::APIConnectionError) { http_client.execute(request) } + assert_equal(2, connections.length) + assert_equal(OpenSSL::SSL::VERIFY_PEER, connections.fetch(1).verify_mode) + refute_same(connections.fetch(0), connections.fetch(1)) + ensure + http_client&.close + end + + def test_net_http_client_discards_tls_settings_after_an_invalid_returning_validator + connections = [] + client_class = Class.new(OpenAI::NetHTTPClient) do + define_method(:connect) do |**| + StubNetHTTP.new(use_ssl: true, request_error: IOError.new("safe network probe")).tap do |connection| + connection.verify_mode = OpenSSL::SSL::VERIFY_PEER + connections << connection + end + end + + private(:connect) + end + + http_client = client_class.new + request = OpenAI::HTTPClient::Request.new( + method: :get, + url: URI("https://example.com/v1/probe"), + headers: {"authorization" => "Bearer fake-sensitive-token"}, + body: nil, + timeout: 1 + ) + + error = assert_raises(ArgumentError) do + http_client.execute(request) do |connection| + connection.verify_mode = OpenSSL::SSL::VERIFY_NONE + connection.use_ssl = false + end + end + + assert_match(/preserve TLS/, error.message) + assert_raises(OpenAI::Errors::APIConnectionError) { http_client.execute(request) } + assert_equal(2, connections.length) + assert_equal(OpenSSL::SSL::VERIFY_PEER, connections.fetch(1).verify_mode) + assert_predicate(connections.fetch(1), :use_ssl?) + ensure + http_client&.close + end + def test_net_http_client_wraps_malformed_responses_as_connection_errors [Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Zlib::DataError].each do |error_class| connection = StubNetHTTP.new(use_ssl: true, request_error: error_class.new("malformed")) From 4faf6fc712f835896648641b0ed9e0cbf710ddd8 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 24 Aug 2026 16:47:21 -0700 Subject: [PATCH 02/10] test(auth): make X.509 mTLS wire fixtures portable across Linux --- test/openai/auth/x509_transport_test.rb | 4 ++++ test/openai/support/mtls_wire_harness.rb | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/test/openai/auth/x509_transport_test.rb b/test/openai/auth/x509_transport_test.rb index db960ab26..b013bea81 100644 --- a/test/openai/auth/x509_transport_test.rb +++ b/test/openai/auth/x509_transport_test.rb @@ -487,6 +487,7 @@ def test_real_wire_issuer_and_api_share_attested_client_identity identity = pki.client_identity verification_results = [] configured_client = OpenAI::NetHTTPClient.new(size: 1) do |connection| + Harness.configure_http_connect_proxy(connection, proxy.uri) connection.cert_store = pki.trust_store connection.cert = identity.certificate connection.extra_chain_cert = [pki.intermediate_certificate] @@ -536,6 +537,9 @@ def test_real_wire_issuer_and_api_share_attested_client_identity refute_includes(record.headers, "authorization") end + rescue OpenAI::Errors::APIConnectionError => error + cause = error.cause + raise "Ephemeral mTLS fixture connection failed: #{cause&.class}: #{cause&.message}" ensure configured_client&.close proxy&.close diff --git a/test/openai/support/mtls_wire_harness.rb b/test/openai/support/mtls_wire_harness.rb index dc9357414..75bd878d9 100644 --- a/test/openai/support/mtls_wire_harness.rb +++ b/test/openai/support/mtls_wire_harness.rb @@ -235,6 +235,11 @@ def close headers[name.downcase] = value.to_s.strip end + body_length = Integer(headers.fetch("content-length", "0")) + if body_length.positive? && connection.read(body_length)&.bytesize != body_length + raise IOError, "test request body ended before its declared Content-Length" + end + [request_line, headers.freeze] end end @@ -349,6 +354,18 @@ def close NO_PROXY ].freeze + def self.configure_http_connect_proxy(connection, proxy_uri) + unless proxy_uri.scheme == "http" + raise ArgumentError, "mTLS wire fixtures support only HTTP CONNECT proxies" + end + + connection.proxy_from_env = false + connection.proxy_address = proxy_uri.hostname + connection.proxy_port = proxy_uri.port + connection.proxy_user = URI.decode_www_form_component(proxy_uri.user.to_s) + connection.proxy_pass = URI.decode_www_form_component(proxy_uri.password.to_s) + end + def self.with_proxy_environment(proxy_uri) previous = PROXY_ENV_KEYS.to_h { [_1, ENV[_1]] } PROXY_ENV_KEYS.each { ENV.delete(_1) } From f203acc968d0e5dd01f6a9e67eb2050b3cba06d5 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 24 Aug 2026 21:12:36 -0700 Subject: [PATCH 03/10] feat(auth): add pinned X.509 workload token exchange (#524) ## Summary Second PR in the stacked X.509 workload-identity series; targets the transport-capability branch rather than `main`. - Add immutable `OpenAI::Auth::X509WorkloadIdentity` configuration for the mapped identity-provider and service-account IDs. - Implement the exact pinned X.509 token-exchange request against `https://mtls.auth.openai.com/oauth/token` using only the attested transport. - Require complete bounded OAuth JSON, strict bearer grammar, access-token issuance/type markers, and finite positive expiry of at most one hour. - Close response resources, honor authentication deadlines, and redact unknown/nested OAuth errors and sensitive network diagnostics. - Include focused malformed-response and credential-isolation tests plus matching Sorbet/RBS declarations. The 64 KiB bound applies only to the small OAuth token-exchange response; ordinary API JSON responses and SSE events remain unlimited by this change. ## Verification - `bundle exec ruby test/openai/auth/x509_token_exchange_test.rb` - `bundle exec ruby test/openai/auth/x509_transport_test.rb` - `bundle exec rake lint` ## Stack 1. Attested transport and real-wire security boundary. 2. **This PR:** X.509 identity and pinned token-exchange protocol. 3. Public-client integration and two-leg mTLS end-to-end test. 4. Refresh/retry/concurrency and final-credential hardening. 5. Customer documentation, enrolled-credential live smoke, and installed-gem end-to-end coverage. Review requested from `@openai/sdks-team` because this change introduces an authentication protocol. --- lib/openai.rb | 2 + lib/openai/auth/x509_token_exchange.rb | 169 +++++++ lib/openai/auth/x509_workload_identity.rb | 52 +++ rbi/openai/auth/x509_token_exchange.rbi | 17 + rbi/openai/auth/x509_workload_identity.rbi | 31 ++ sig/openai/auth/x509_token_exchange.rbs | 12 + sig/openai/auth/x509_workload_identity.rbs | 17 + test/openai/auth/x509_token_exchange_test.rb | 443 +++++++++++++++++++ 8 files changed, 743 insertions(+) create mode 100644 lib/openai/auth/x509_token_exchange.rb create mode 100644 lib/openai/auth/x509_workload_identity.rb create mode 100644 rbi/openai/auth/x509_token_exchange.rbi create mode 100644 rbi/openai/auth/x509_workload_identity.rbi create mode 100644 sig/openai/auth/x509_token_exchange.rbs create mode 100644 sig/openai/auth/x509_workload_identity.rbs create mode 100644 test/openai/auth/x509_token_exchange_test.rb diff --git a/lib/openai.rb b/lib/openai.rb index 430655e5f..fe0ab59d3 100644 --- a/lib/openai.rb +++ b/lib/openai.rb @@ -61,6 +61,8 @@ require_relative "openai/internal/vector_store_file_uploader" require_relative "openai/net_http_client" require_relative "openai/auth/x509_transport" +require_relative "openai/auth/x509_workload_identity" +require_relative "openai/auth/x509_token_exchange" require_relative "openai/provider" require_relative "openai/internal/provider" require_relative "openai/providers/azure" diff --git a/lib/openai/auth/x509_token_exchange.rb b/lib/openai/auth/x509_token_exchange.rb new file mode 100644 index 000000000..5e9628289 --- /dev/null +++ b/lib/openai/auth/x509_token_exchange.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +module OpenAI + module Auth + # Performs the fixed certificate-authenticated OpenAI token exchange. + # + # @api private + class X509TokenExchange + MAX_TOKEN_LIFETIME = 3600 + DEFAULT_TIMEOUT = 5.0 + TOKEN_URL = "#{X509Transport::ISSUER_ORIGIN}/oauth/token".freeze + GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" + SUBJECT_TOKEN_TYPE = "urn:openai:params:oauth:token-type:x509" + ISSUED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" + BEARER_PATTERN = /\A[A-Za-z0-9\-._~+\/]+=*\z/ + OAUTH_ERROR_CODES = %w[invalid_grant invalid_subject_token token_exchange_server_error].freeze + private_constant( + :MAX_TOKEN_LIFETIME, + :DEFAULT_TIMEOUT, + :TOKEN_URL, + :GRANT_TYPE, + :SUBJECT_TOKEN_TYPE, + :ISSUED_TOKEN_TYPE, + :BEARER_PATTERN, + :OAUTH_ERROR_CODES + ) + + def initialize(config, transport:) + unless config.instance_of?(X509WorkloadIdentity) + raise ArgumentError, "X.509 exchange requires an X509WorkloadIdentity" + end + + unless transport.instance_of?(X509Transport) + raise ArgumentError, "X.509 exchange requires an attested X509Transport" + end + + @config = config + @transport = transport + end + + # @param deadline [Float, nil] absolute monotonic request deadline + # @return [Hash{Symbol=>String, Float}] + def fetch(deadline: nil) + request = OpenAI::HTTPClient::Request.new( + method: :post, + url: URI(TOKEN_URL), + headers: {"accept" => "application/json", "content-type" => "application/json"}, + body: JSON.generate( + grant_type: GRANT_TYPE, + subject_token_type: SUBJECT_TOKEN_TYPE, + identity_provider_id: @config.identity_provider_id, + service_account_id: @config.service_account_id + ), + timeout: remaining_timeout(deadline) + ) + response = @transport.execute(request) + successful = (200..299).cover?(response.status) + body = parse_response(response, deadline: deadline, strict: successful) + if successful + token = validate_token_response(body, response: response) + remaining_timeout(deadline) + return token + end + + raise_error(response, body) + rescue OpenAI::Errors::APIConnectionError => error + raise error.class.new(url: URI(TOKEN_URL)), cause: nil + end + + private def remaining_timeout(deadline) + return DEFAULT_TIMEOUT if deadline.nil? + + remaining = deadline - OpenAI::Internal::Util.monotonic_secs + unless remaining.positive? + raise Timeout::Error, "request timed out during workload identity authentication" + end + + [remaining, DEFAULT_TIMEOUT].min + end + + private def parse_response(response, deadline:, strict:) + body = +"" + response.body.each do |chunk| + remaining_timeout(deadline) + body << chunk + end + + remaining_timeout(deadline) + unless body.force_encoding(Encoding::UTF_8).valid_encoding? + return nil unless strict + + raise invalid_response("response must use valid UTF-8", response: response) + end + + parsed = JSON.parse(body, symbolize_names: true) + remaining_timeout(deadline) + parsed + rescue JSON::ParserError + return nil unless strict + + raise invalid_response("response must be a complete JSON object", response: response), cause: nil + ensure + OpenAI::Internal::Util.close_fused!(response.body) + end + + private def validate_token_response(body, response:) + unless body.is_a?(Hash) + raise invalid_response("response must be a JSON object", response: response) + end + + token = body[:access_token] + unless token.is_a?(String) && token.valid_encoding? && BEARER_PATTERN.match?(token) + raise invalid_response("access_token must use the bearer token grammar", response: response) + end + + token_type = body[:token_type] + unless token_type.is_a?(String) && token_type.valid_encoding? && token_type.casecmp?("Bearer") + raise invalid_response("token_type must be Bearer", response: response) + end + + unless body[:issued_token_type] == ISSUED_TOKEN_TYPE + raise invalid_response("issued_token_type must identify an access token", response: response) + end + + expires_in = body[:expires_in] + unless expires_in.is_a?(Integer) || expires_in.is_a?(Float) + raise invalid_response("expires_in must be a finite positive number", response: response) + end + + valid_lifetime = expires_in.positive? && expires_in <= MAX_TOKEN_LIFETIME + valid_lifetime &&= expires_in.finite? if expires_in.is_a?(Float) + unless valid_lifetime + raise invalid_response("expires_in must be greater than zero and at most one hour", response: response) + end + + {id: token, expires_in: expires_in.to_f} + end + + private def raise_error(response, body) + headers = response.headers.slice("x-request-id") + if [400, 401, 403].include?(response.status) + error_code = body[:error] if body.is_a?(Hash) + safe_body = {error: error_code} if OAUTH_ERROR_CODES.include?(error_code) + error = OpenAI::Errors::OAuthError.new(status: response.status, body: safe_body, headers: headers) + error.url = URI(TOKEN_URL) + raise error + end + + raise( + OpenAI::Errors::APIError.new( + url: URI(TOKEN_URL), + status: response.status, + headers: headers, + message: "X.509 token exchange failed with status #{response.status}" + ) + ) + end + + private def invalid_response(reason, response:) + OpenAI::Errors::APIError.new( + url: URI(TOKEN_URL), + status: response.status, + headers: response.headers.slice("x-request-id"), + message: "Invalid X.509 token exchange response: #{reason}" + ) + end + end + end +end diff --git a/lib/openai/auth/x509_workload_identity.rb b/lib/openai/auth/x509_workload_identity.rb new file mode 100644 index 000000000..625281f0e --- /dev/null +++ b/lib/openai/auth/x509_workload_identity.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module OpenAI + module Auth + # Immutable configuration for certificate-authenticated workload identity. + # Certificate and private-key ownership remain with the attested transport. + class X509WorkloadIdentity + # @return [String] + attr_reader :identity_provider_id + + # @return [String] + attr_reader :service_account_id + + # @return [Integer] + attr_reader :refresh_buffer_seconds + + def initialize( + identity_provider_id: ENV["IDENTITY_PROVIDER_ID"], + service_account_id: ENV["SERVICE_ACCOUNT_ID"], + refresh_buffer_seconds: 1200 + ) + @identity_provider_id = validate_identifier(identity_provider_id, "identity_provider_id").freeze + @service_account_id = validate_identifier(service_account_id, "service_account_id").freeze + @refresh_buffer_seconds = Integer(refresh_buffer_seconds) + if @refresh_buffer_seconds.negative? + raise ArgumentError, "refresh_buffer_seconds must be greater than or equal to zero" + end + + freeze + end + + private def validate_identifier(value, name) + identifier = String.new(value.to_s) + unless identifier.valid_encoding? + raise ArgumentError, "#{name} must contain valid UTF-8" + end + + begin + identifier = identifier.encode(Encoding::UTF_8) + rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError + raise ArgumentError, "#{name} must contain valid UTF-8", cause: nil + end + + if identifier.strip.empty? + raise ArgumentError, "#{name} must not be blank; pass #{name}: or set #{name.upcase}" + end + + identifier + end + end + end +end diff --git a/rbi/openai/auth/x509_token_exchange.rbi b/rbi/openai/auth/x509_token_exchange.rbi new file mode 100644 index 000000000..259ee154e --- /dev/null +++ b/rbi/openai/auth/x509_token_exchange.rbi @@ -0,0 +1,17 @@ +# typed: strong + +module OpenAI + module Auth + class X509TokenExchange + sig do + params(config: OpenAI::Auth::X509WorkloadIdentity, transport: OpenAI::Auth::X509Transport).void + end + def initialize(config, transport:) + end + + sig { params(deadline: T.nilable(Float)).returns(T::Hash[Symbol, T.any(String, Float)]) } + def fetch(deadline: nil) + end + end + end +end diff --git a/rbi/openai/auth/x509_workload_identity.rbi b/rbi/openai/auth/x509_workload_identity.rbi new file mode 100644 index 000000000..232e4d126 --- /dev/null +++ b/rbi/openai/auth/x509_workload_identity.rbi @@ -0,0 +1,31 @@ +# typed: strong + +module OpenAI + module Auth + class X509WorkloadIdentity + sig { returns(String) } + attr_reader :identity_provider_id + + sig { returns(String) } + attr_reader :service_account_id + + sig { returns(Integer) } + attr_reader :refresh_buffer_seconds + + sig do + params( + identity_provider_id: T.nilable(T.any(String, Symbol)), + service_account_id: T.nilable(T.any(String, Symbol)), + refresh_buffer_seconds: Integer + ) + .void + end + def initialize( + identity_provider_id: ENV["IDENTITY_PROVIDER_ID"], + service_account_id: ENV["SERVICE_ACCOUNT_ID"], + refresh_buffer_seconds: 1200 + ) + end + end + end +end diff --git a/sig/openai/auth/x509_token_exchange.rbs b/sig/openai/auth/x509_token_exchange.rbs new file mode 100644 index 000000000..2b3750876 --- /dev/null +++ b/sig/openai/auth/x509_token_exchange.rbs @@ -0,0 +1,12 @@ +module OpenAI + module Auth + class X509TokenExchange + def initialize: ( + OpenAI::Auth::X509WorkloadIdentity config, + transport: OpenAI::Auth::X509Transport + ) -> void + + def fetch: (?deadline: Float?) -> ::Hash[Symbol, (String | Float)] + end + end +end diff --git a/sig/openai/auth/x509_workload_identity.rbs b/sig/openai/auth/x509_workload_identity.rbs new file mode 100644 index 000000000..e8fd66725 --- /dev/null +++ b/sig/openai/auth/x509_workload_identity.rbs @@ -0,0 +1,17 @@ +module OpenAI + module Auth + class X509WorkloadIdentity + attr_reader identity_provider_id: String + + attr_reader service_account_id: String + + attr_reader refresh_buffer_seconds: Integer + + def initialize: ( + ?identity_provider_id: (String | Symbol)?, + ?service_account_id: (String | Symbol)?, + ?refresh_buffer_seconds: Integer + ) -> void + end + end +end diff --git a/test/openai/auth/x509_token_exchange_test.rb b/test/openai/auth/x509_token_exchange_test.rb new file mode 100644 index 000000000..40aec2fdd --- /dev/null +++ b/test/openai/auth/x509_token_exchange_test.rb @@ -0,0 +1,443 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +class OpenAI::Test::X509TokenExchangeTest < Minitest::Test + ISSUED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" + + def setup + super + @http_client = OpenAI::NetHTTPClient.new + @transport = OpenAI::Auth::X509Transport.new( + http_client: @http_client, + certificate_identity: :static + ) + @identity = OpenAI::Auth::X509WorkloadIdentity.new( + identity_provider_id: "idp_fake", + service_account_id: "svc_acct_fake", + refresh_buffer_seconds: 30 + ) + @exchange = OpenAI::Auth::X509TokenExchange.new(@identity, transport: @transport) + end + + def teardown + @http_client.close + super + end + + def test_configuration_is_immutable_and_validates_identifiers_and_refresh_buffer + assert_predicate(@identity, :frozen?) + assert_predicate(@identity.identity_provider_id, :frozen?) + assert_predicate(@identity.service_account_id, :frozen?) + assert_equal(30, @identity.refresh_buffer_seconds) + + [nil, "", " \t"].each do |blank| + assert_raises(ArgumentError) do + OpenAI::Auth::X509WorkloadIdentity.new(identity_provider_id: blank, service_account_id: "fake") + end + + assert_raises(ArgumentError) do + OpenAI::Auth::X509WorkloadIdentity.new(identity_provider_id: "fake", service_account_id: blank) + end + end + + assert_raises(ArgumentError) do + OpenAI::Auth::X509WorkloadIdentity.new( + identity_provider_id: "fake", + service_account_id: "fake", + refresh_buffer_seconds: -1 + ) + end + end + + def test_configuration_rejects_invalid_identifier_encodings_without_exposing_their_contents + invalid_identifiers = [ + "\xFFfake-sensitive-identifier".b, + "\xFFfake-sensitive-identifier".b.force_encoding(Encoding::UTF_8) + ] + + invalid_identifiers.each do |identifier| + [:identity_provider_id, :service_account_id].each do |field| + options = {identity_provider_id: "idp_fake", service_account_id: "svc_acct_fake"} + options[field] = identifier + + error = assert_raises(ArgumentError) { OpenAI::Auth::X509WorkloadIdentity.new(**options) } + + assert_match(/#{field}.*valid UTF-8/, error.message) + assert_nil(error.cause) + refute_includes(error.full_message(highlight: false), "fake-sensitive-identifier") + end + end + + identity = OpenAI::Auth::X509WorkloadIdentity.new( + identity_provider_id: "idp_é".encode(Encoding::ISO_8859_1), + service_account_id: "svc_acct_fake".encode(Encoding::US_ASCII) + ) + assert_equal(Encoding::UTF_8, identity.identity_provider_id.encoding) + assert_equal(Encoding::UTF_8, identity.service_account_id.encoding) + assert_equal("idp_é", identity.identity_provider_id) + end + + def test_posts_only_the_exact_product_approved_exchange_shape + observed = nil + response = token_response + + result = @http_client.stub( + :execute, + -> (request) { + observed = request + response + } + ) do + @exchange.fetch + end + + assert_equal(:post, observed.method) + assert_equal("https://mtls.auth.openai.com/oauth/token", observed.url.to_s) + assert_equal({"accept" => "application/json", "content-type" => "application/json"}, observed.headers) + assert_equal( + { + "grant_type" => "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token_type" => "urn:openai:params:oauth:token-type:x509", + "identity_provider_id" => "idp_fake", + "service_account_id" => "svc_acct_fake" + }, + JSON.parse(observed.body) + ) + refute_includes(JSON.parse(observed.body), "subject_token") + assert_equal({id: "fake-valid.token_123", expires_in: 120.0}, result) + end + + def test_deadline_is_checked_before_dispatch_and_applied_to_the_request + expired = OpenAI::Internal::Util.monotonic_secs - 1 + @http_client.stub(:execute, -> (_request) { flunk("expired exchange must not dispatch") }) do + assert_raises(Timeout::Error) { @exchange.fetch(deadline: expired) } + end + + observed = nil + response = token_response + deadline = OpenAI::Internal::Util.monotonic_secs + 2 + @http_client.stub( + :execute, + -> (request) { + observed = request + response + } + ) do + @exchange.fetch(deadline: deadline) + end + + assert_operator(observed.timeout, :>, 0) + assert_operator(observed.timeout, :<=, 2) + end + + def test_deadline_is_checked_after_json_parsing_and_token_validation + original_parser = JSON.method(:parse) + [:parsing, :validation].each do |phase| + now = 100.0 + response = token_response + parser = lambda do |source, **options| + parsed = original_parser.call(source, **options) + if phase == :parsing + now = 102.0 + else + parsed.define_singleton_method(:[]) do |key| + now = 102.0 if key == :expires_in + super(key) + end + end + + parsed + end + + OpenAI::Internal::Util.stub(:monotonic_secs, -> { now }) do + JSON.stub(:parse, parser) do + @http_client.stub(:execute, -> (_request) { response }) do + error = assert_raises(Timeout::Error) { @exchange.fetch(deadline: 101.0) } + assert_match(/timed out during workload identity authentication/, error.message) + end + end + end + end + end + + def test_rejects_malformed_bearers_response_shapes_and_token_types + invalid_bodies = [ + nil, + [], + {}, + valid_token.merge(access_token: ""), + valid_token.merge(access_token: "bad token"), + valid_token.merge(access_token: "bad\ntoken"), + valid_token.merge(access_token: "fake=middle"), + valid_token.merge(access_token: "token-☃"), + valid_token.merge(token_type: "Basic"), + valid_token.merge(token_type: nil), + valid_token.except(:token_type), + valid_token.merge(issued_token_type: "urn:other"), + valid_token.except(:issued_token_type) + ] + + invalid_bodies.each do |body| + response = token_response(body: body) + @http_client.stub(:execute, -> (_request) { response }) do + error = assert_raises(OpenAI::Errors::APIError) { @exchange.fetch } + assert_match(/Invalid X\.509 token exchange response/, error.message) + assert_nil(error.body) + end + end + end + + def test_rejects_nonpositive_nonfinite_overflowing_and_overlong_lifetimes + [nil, "120", 0, -1, 3601, 10 ** 400, 1.0 / 0.0, 0.0 / 0.0].each do |expires_in| + body = valid_token.merge(expires_in: expires_in) + json = JSON.generate(body, allow_nan: true) + response = OpenAI::HTTPClient::Response.new(status: 200, headers: {}, body: json) + @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::APIError) { @exchange.fetch } + end + end + end + + def test_accepts_large_valid_token_responses_without_an_arbitrary_size_limit + large_token = "a" * (128 * 1024) + responses = [ + valid_token.merge(access_token: large_token), + valid_token.merge(additional_metadata: "x" * (128 * 1024)) + ] + + responses.each do |body| + response = token_response(body: body) + result = @http_client.stub(:execute, -> (_request) { response }) do + @exchange.fetch + end + + assert_equal(body.fetch(:access_token), result.fetch(:id)) + assert_equal(120.0, result.fetch(:expires_in)) + end + end + + def test_rejects_complete_responses_with_invalid_trailing_content + body = JSON.generate(valid_token) + (" " * (128 * 1024)) + "x" + response = OpenAI::HTTPClient::Response.new(status: 200, headers: {}, body: body) + + @http_client.stub(:execute, -> (_request) { response }) do + error = assert_raises(OpenAI::Errors::APIError) { @exchange.fetch } + assert_match(/complete JSON object/, error.message) + end + end + + def test_invalid_successful_issuer_responses_preserve_only_safe_request_ids + invalid_bodies = { + 200 => "{fake-sensitive-body", + 201 => JSON.generate(valid_token.merge(token_type: "Basic")), + 202 => "\xFFfake-sensitive-body".b + } + + invalid_bodies.each do |status, body| + response = OpenAI::HTTPClient::Response.new( + status: status, + headers: {"x-request-id" => "req_fake", "set-cookie" => "session=fake-cookie"}, + body: body + ) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::APIError) { @exchange.fetch } + end + + assert_equal(status, error.status) + assert_equal("req_fake", error.request_id) + assert_equal({"x-request-id" => "req_fake"}, error.headers) + assert_nil(error.body) + refute_match(/fake-sensitive-body|fake-cookie/, error.full_message(highlight: false)) + end + end + + def test_malformed_token_json_never_exposes_parser_input_through_exception_causes + sensitive_token = "fake-sensitive-bearer-token" + malformed = "{\"access_token\":#{sensitive_token}}" + statuses = {200 => OpenAI::Errors::APIError, 401 => OpenAI::Errors::OAuthError, 503 => OpenAI::Errors::APIError} + + statuses.each do |status, expected_error| + response = OpenAI::HTTPClient::Response.new( + status: status, + headers: {"x-request-id" => "req_fake"}, + body: malformed + ) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(expected_error) { @exchange.fetch } + end + + assert_equal(status, error.status) + assert_nil(error.cause) + assert_nil(error.body) + refute_includes(error.message.to_s, sensitive_token) + refute_includes(error.inspect, sensitive_token) + refute_includes(error.full_message(highlight: false), sensitive_token) + if status == 200 + assert_match(/complete JSON object/, error.message) + else + assert_equal({"x-request-id" => "req_fake"}, error.headers) + end + end + end + + def test_invalid_utf8_response_keys_and_values_raise_sanitized_api_errors + invalid_fields = [ + [valid_token, valid_token.fetch(:access_token)], + [valid_token, valid_token.fetch(:token_type)], + [valid_token, valid_token.fetch(:issued_token_type)], + [valid_token.merge(metadata: "fake-metadata"), "fake-metadata"], + [valid_token, "access_token"] + ] + + invalid_fields.each do |token, marker| + body = JSON.generate(token).b.sub(marker.b, "\xFF".b) + response = OpenAI::HTTPClient::Response.new(status: 200, headers: {}, body: body) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::APIError) { @exchange.fetch } + end + + assert_equal(200, error.status) + assert_match(/Invalid X\.509 token exchange response/, error.message) + assert_nil(error.body) + end + end + + def test_oauth_failures_preserve_only_known_codes_and_safe_request_ids + response = OpenAI::HTTPClient::Response.new( + status: 401, + headers: {"x-request-id" => "req_fake", "set-cookie" => "session=fake-secret"}, + body: JSON.generate(error: "invalid_grant", error_description: "customer secret diagnostics") + ) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::OAuthError) { @exchange.fetch } + end + + assert_equal("https://mtls.auth.openai.com/oauth/token", error.url.to_s) + assert_equal({error: "invalid_grant"}, error.body) + assert_equal({"x-request-id" => "req_fake"}, error.headers) + refute_match(/secret|diagnostics/, error.inspect) + end + + def test_oauth_failures_discard_nested_and_unknown_error_details + [{message: "fake-secret"}, ["fake-secret"], "fake-secret"].each do |error_value| + response = OpenAI::HTTPClient::Response.new( + status: 400, + headers: {}, + body: JSON.generate(error: error_value) + ) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::OAuthError) { @exchange.fetch } + end + + assert_nil(error.body) + refute_match(/fake-secret/, error.inspect) + end + end + + def test_oauth_failures_with_non_json_bodies_preserve_status_and_safe_request_ids + bodies = [[400, ""], [401, "fake-secret"], [403, "not-json"], [403, "\xFF".b]] + + bodies.each do |status, body| + response = OpenAI::HTTPClient::Response.new( + status: status, + headers: {"x-request-id" => "req_fake", "set-cookie" => "session=fake-secret"}, + body: body + ) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::OAuthError) { @exchange.fetch } + end + + assert_equal(status, error.status) + assert_equal({"x-request-id" => "req_fake"}, error.headers) + assert_nil(error.body) + refute_includes(error.inspect, "fake-secret") + end + end + + def test_non_json_server_failures_preserve_status_and_safe_request_ids + response = OpenAI::HTTPClient::Response.new( + status: 503, + headers: {"x-request-id" => "req_fake", "set-cookie" => "session=fake-secret"}, + body: "fake-secret upstream unavailable" + ) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::APIError) { @exchange.fetch } + end + + assert_equal(503, error.status) + assert_equal({"x-request-id" => "req_fake"}, error.headers) + assert_nil(error.body) + refute_includes(error.inspect, "fake-secret") + end + + def test_large_failure_bodies_preserve_status_without_exposing_content + response = OpenAI::HTTPClient::Response.new( + status: 503, + headers: {"x-request-id" => "req_fake"}, + body: "fake-secret" * (128 * 1024) + ) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::APIError) { @exchange.fetch } + end + + assert_equal(503, error.status) + assert_equal({"x-request-id" => "req_fake"}, error.headers) + assert_nil(error.body) + refute_includes(error.inspect, "fake-secret") + end + + def test_mutating_error_urls_cannot_change_future_token_exchange_destinations + failures = [ + -> { token_response(body: {}) }, + -> { OpenAI::HTTPClient::Response.new(status: 401, headers: {}, body: "") }, + -> { OpenAI::HTTPClient::Response.new(status: 503, headers: {}, body: "") }, + -> { OpenAI::Errors::APIConnectionError.new(url: URI("https://mtls.auth.openai.com/oauth/token")) } + ] + + failures.each do |build_failure| + failure = build_failure.call + destinations = [] + dispatch = lambda do |request| + destinations << request.url.to_s + if destinations.one? + raise failure if failure.is_a?(OpenAI::Errors::APIError) + + failure + else + token_response + end + end + + @http_client.stub(:execute, dispatch) do + error = assert_raises(OpenAI::Errors::APIError) { @exchange.fetch } + error.url.path.replace("/mutated-token-endpoint") + + assert_equal("fake-valid.token_123", @exchange.fetch.fetch(:id)) + end + + assert_equal(["https://mtls.auth.openai.com/oauth/token"] * 2, destinations) + end + end + + private def valid_token + { + access_token: "fake-valid.token_123", + token_type: "Bearer", + issued_token_type: ISSUED_TOKEN_TYPE, + expires_in: 120 + } + end + + private def token_response(body: valid_token) + OpenAI::HTTPClient::Response.new(status: 200, headers: {}, body: JSON.generate(body)) + end +end From cef885c4d107b9e050bb3b78930c19486ee65401 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 24 Aug 2026 21:09:16 -0700 Subject: [PATCH 04/10] fix(auth): retain safe X.509 issuer retry metadata --- lib/openai/auth/x509_token_exchange.rb | 4 ++ test/openai/auth/x509_token_exchange_test.rb | 55 ++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/lib/openai/auth/x509_token_exchange.rb b/lib/openai/auth/x509_token_exchange.rb index 5e9628289..13effc0df 100644 --- a/lib/openai/auth/x509_token_exchange.rb +++ b/lib/openai/auth/x509_token_exchange.rb @@ -146,6 +146,10 @@ def fetch(deadline: nil) raise error end + if [408, 409, 429].include?(response.status) || (500..599).cover?(response.status) + headers.merge!(response.headers.slice("retry-after", "retry-after-ms", "x-should-retry")) + end + raise( OpenAI::Errors::APIError.new( url: URI(TOKEN_URL), diff --git a/test/openai/auth/x509_token_exchange_test.rb b/test/openai/auth/x509_token_exchange_test.rb index 40aec2fdd..68189408f 100644 --- a/test/openai/auth/x509_token_exchange_test.rb +++ b/test/openai/auth/x509_token_exchange_test.rb @@ -378,6 +378,61 @@ def test_non_json_server_failures_preserve_status_and_safe_request_ids refute_includes(error.inspect, "fake-secret") end + def test_retryable_issuer_failures_preserve_only_safe_retry_metadata + statuses = [408, 409, 429, 500, 503] + safe_headers = { + "x-request-id" => "req_fake", + "retry-after" => "3", + "retry-after-ms" => "25", + "x-should-retry" => "true" + } + + statuses.each do |status| + response = OpenAI::HTTPClient::Response.new( + status: status, + headers: safe_headers.merge("set-cookie" => "session=fake-secret", "authorization" => "Bearer fake-secret"), + body: "fake-secret-body" + ) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::APIError) { @exchange.fetch } + end + + assert_equal(status, error.status) + assert_equal(safe_headers, error.headers) + assert_nil(error.body) + refute_match(/fake-secret/, error.inspect) + end + end + + def test_oauth_failures_do_not_expose_retry_metadata + response = OpenAI::HTTPClient::Response.new( + status: 401, + headers: {"x-request-id" => "req_fake", "retry-after" => "2", "set-cookie" => "fake-secret"}, + body: JSON.generate(error: "invalid_grant") + ) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::OAuthError) { @exchange.fetch } + end + + assert_equal({"x-request-id" => "req_fake"}, error.headers) + end + + def test_nonretryable_issuer_failures_do_not_expose_retry_metadata + response = OpenAI::HTTPClient::Response.new( + status: 404, + headers: {"x-request-id" => "req_fake", "retry-after" => "2", "x-should-retry" => "true"}, + body: "fake-secret-body" + ) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::APIError) { @exchange.fetch } + end + + assert_equal({"x-request-id" => "req_fake"}, error.headers) + end + def test_large_failure_bodies_preserve_status_without_exposing_content response = OpenAI::HTTPClient::Response.new( status: 503, From aa736d40b1929c2097dec920023c7f69acb0086d Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 24 Aug 2026 21:27:52 -0700 Subject: [PATCH 05/10] fix(auth): sanitize X.509 response cleanup and protocol causes --- lib/openai/auth/x509_transport.rb | 44 +++++++++- .../auth/x509_transport_security_test.rb | 85 +++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/lib/openai/auth/x509_transport.rb b/lib/openai/auth/x509_transport.rb index bb17d0ef6..3cf5109a4 100644 --- a/lib/openai/auth/x509_transport.rb +++ b/lib/openai/auth/x509_transport.rb @@ -108,7 +108,7 @@ def execute(request) raise sanitized, cause: nil end - body = OpenAI::Internal::Util.chain_fused(response.body) do |chunks| + stream = Enumerator.new do |chunks| response.body.each { |chunk| chunks << chunk } rescue OpenAI::Errors::APIConnectionError => error @@ -118,6 +118,16 @@ def execute(request) raise sanitized, cause: nil end + body = OpenAI::Internal::Util.fused_enum(stream) do + OpenAI::Internal::Util.close_fused!(response.body) + + rescue OpenAI::Errors::APIConnectionError => error + sanitized = sanitized_connection_error(error, url: url, headers: headers, body: safe_request.body) + raise unless sanitized + + raise sanitized, cause: nil + end + OpenAI::HTTPClient::Response.new(status: response.status, headers: response.headers, body: body) end @@ -284,11 +294,41 @@ def validate_api_request!(url:, headers:) private def sanitized_connection_error(error, url:, headers:, body:) sensitive_request = !body.nil? || headers.any? { |name, _value| OpenAI::Internal::Logging.credential_header?(name) } - return if !sensitive_request && error.url.query.nil? && error.url.fragment.nil? + if !sensitive_request && + !sensitive_protocol_error?(error) && + error.url.query.nil? && + error.url.fragment.nil? + return + end error.class.new(url: sanitized_url(url)) end + private def sensitive_protocol_error?(error) + seen = {} + cause = error + loop do + begin + cause = cause.cause + rescue StandardError + return true + end + + return false if cause.nil? + + identity = cause.object_id + return true if seen.key?(identity) + + seen[identity] = true + if cause.is_a?(Net::HTTPBadResponse) || + cause.is_a?(Net::HTTPHeaderSyntaxError) || + cause.is_a?(Net::ProtocolError) || + (defined?(Zlib::Error) && cause.is_a?(Zlib::Error)) + return true + end + end + end + private def reject_redirect!(response, url:) OpenAI::Internal::Util.close_fused!(response.body) raise( diff --git a/test/openai/auth/x509_transport_security_test.rb b/test/openai/auth/x509_transport_security_test.rb index 200be7565..585fc78e0 100644 --- a/test/openai/auth/x509_transport_security_test.rb +++ b/test/openai/auth/x509_transport_security_test.rb @@ -70,6 +70,91 @@ def test_redirect_cleanup_errors_never_retain_sensitive_request_data end end + def test_closing_never_started_bodies_sanitizes_cleanup_failures + destination = URI("https://mtls.api.openai.com/v1/models?signature=fake-query-secret#fake-fragment") + classes = [OpenAI::Errors::APIConnectionError, OpenAI::Errors::APITimeoutError] + + classes.each do |error_class| + retained = Net::HTTP::Get.new(destination) + retained["authorization"] = "Bearer fake-retained-secret" + original = error_class.new(url: destination, request: retained, message: "fake-error-secret") + started = false + source = OpenAI::Internal::Util.fused_enum(Enumerator.new { started = true }) do + raise original, cause: IOError.new("fake-cause-secret") + end + + native_response = OpenAI::HTTPClient::Response.new(status: 200, headers: {}, body: source) + response = @http_client.stub(:execute, -> (_request) { native_response }) do + @transport.execute(request(url: destination, headers: {"authorization" => "Bearer fake-retained-secret"})) + end + + error = assert_raises(error_class) { OpenAI::Internal::Util.close_fused!(response.body) } + + refute(started) + assert_equal("https://mtls.api.openai.com/v1/models", error.url.to_s) + assert_nil(error.instance_variable_get(:@request)) + assert_nil(error.cause) + refute_match(/fake-query-secret|fake-retained-secret|fake-error-secret|fake-cause-secret/, error.inspect) + end + end + + def test_bodyless_protocol_failures_never_expose_malformed_wire_data + destination = URI("https://mtls.api.openai.com/v1/models") + nested = begin + begin + raise Net::HTTPBadResponse, "fake-sensitive-wire-line" + rescue Net::HTTPBadResponse + raise IOError, "safe outer protocol wrapper" + end + + rescue IOError => error + error + end + + cyclic = Class.new(IOError) do + def cause = self + end + + inaccessible = Class.new(IOError) do + def cause + raise "fake-sensitive-cause-accessor" + end + end + + causes = [ + Net::HTTPBadResponse.new("fake-sensitive-wire-line"), + Net::HTTPHeaderSyntaxError.new("fake-sensitive-wire-line"), + Net::ProtocolError.new("fake-sensitive-wire-line"), + Zlib::DataError.new("fake-sensitive-wire-line"), + nested, + cyclic.new("safe cyclic cause"), + inaccessible.new("safe inaccessible cause") + ] + + causes.product([false, true]).each do |cause, deferred| + original = OpenAI::Errors::APIConnectionError.new(url: destination) + failure = -> { raise original, cause: cause } + dispatch = if deferred + -> (_request) { + OpenAI::HTTPClient::Response.new(status: 200, headers: {}, body: Enumerator.new { failure.call }) + } + else + -> (_request) { failure.call } + end + + error = @http_client.stub(:execute, dispatch) do + assert_raises(OpenAI::Errors::APIConnectionError) do + response = @transport.execute(request(url: destination)) + response.body.to_a + end + end + + assert_equal(destination.to_s, error.url.to_s) + assert_nil(error.cause) + refute_includes(error.full_message(highlight: false), "fake-sensitive-wire-line") + end + end + private def request(url: URI("https://mtls.api.openai.com/v1/models"), headers: {}) OpenAI::HTTPClient::Request.new( method: :get, From 5db43d8ccd53481fc2cdb161e64e9f855d5a47ce Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 24 Aug 2026 21:42:01 -0700 Subject: [PATCH 06/10] fix(auth): always sanitize X.509 transport failures --- lib/openai/auth/x509_transport.rb | 53 ++----------------- .../auth/x509_transport_security_test.rb | 33 ++++++++++++ test/openai/auth/x509_transport_test.rb | 12 ++--- 3 files changed, 42 insertions(+), 56 deletions(-) diff --git a/lib/openai/auth/x509_transport.rb b/lib/openai/auth/x509_transport.rb index 3cf5109a4..3e76c2dc8 100644 --- a/lib/openai/auth/x509_transport.rb +++ b/lib/openai/auth/x509_transport.rb @@ -102,30 +102,21 @@ def execute(request) native_response rescue OpenAI::Errors::APIConnectionError => error - sanitized = sanitized_connection_error(error, url: url, headers: headers, body: safe_request.body) - raise unless sanitized - - raise sanitized, cause: nil + raise error.class.new(url: sanitized_url(url)), cause: nil end stream = Enumerator.new do |chunks| response.body.each { |chunk| chunks << chunk } rescue OpenAI::Errors::APIConnectionError => error - sanitized = sanitized_connection_error(error, url: url, headers: headers, body: safe_request.body) - raise unless sanitized - - raise sanitized, cause: nil + raise error.class.new(url: sanitized_url(url)), cause: nil end body = OpenAI::Internal::Util.fused_enum(stream) do OpenAI::Internal::Util.close_fused!(response.body) rescue OpenAI::Errors::APIConnectionError => error - sanitized = sanitized_connection_error(error, url: url, headers: headers, body: safe_request.body) - raise unless sanitized - - raise sanitized, cause: nil + raise error.class.new(url: sanitized_url(url)), cause: nil end OpenAI::HTTPClient::Response.new(status: response.status, headers: response.headers, body: body) @@ -291,44 +282,6 @@ def validate_api_request!(url:, headers:) safe end - private def sanitized_connection_error(error, url:, headers:, body:) - sensitive_request = !body.nil? || - headers.any? { |name, _value| OpenAI::Internal::Logging.credential_header?(name) } - if !sensitive_request && - !sensitive_protocol_error?(error) && - error.url.query.nil? && - error.url.fragment.nil? - return - end - - error.class.new(url: sanitized_url(url)) - end - - private def sensitive_protocol_error?(error) - seen = {} - cause = error - loop do - begin - cause = cause.cause - rescue StandardError - return true - end - - return false if cause.nil? - - identity = cause.object_id - return true if seen.key?(identity) - - seen[identity] = true - if cause.is_a?(Net::HTTPBadResponse) || - cause.is_a?(Net::HTTPHeaderSyntaxError) || - cause.is_a?(Net::ProtocolError) || - (defined?(Zlib::Error) && cause.is_a?(Zlib::Error)) - return true - end - end - end - private def reject_redirect!(response, url:) OpenAI::Internal::Util.close_fused!(response.body) raise( diff --git a/test/openai/auth/x509_transport_security_test.rb b/test/openai/auth/x509_transport_security_test.rb index 585fc78e0..79b354c17 100644 --- a/test/openai/auth/x509_transport_security_test.rb +++ b/test/openai/auth/x509_transport_security_test.rb @@ -155,6 +155,39 @@ def cause end end + def test_unrecognized_custom_credential_headers_never_survive_transport_failures + destination = URI("https://mtls.api.openai.com/v1/models") + classes = [OpenAI::Errors::APIConnectionError, OpenAI::Errors::APITimeoutError] + + classes.product([false, true]).each do |error_class, deferred| + retained = Net::HTTP::Get.new(destination) + retained["x-client-assertion"] = "fake-custom-credential-secret" + original = error_class.new(url: destination, request: retained, message: "fake-error-secret") + failure = -> { raise original, cause: IOError.new("fake-cause-secret") } + dispatch = if deferred + -> (_request) { + OpenAI::HTTPClient::Response.new(status: 200, headers: {}, body: Enumerator.new { failure.call }) + } + else + -> (_request) { failure.call } + end + + error = @http_client.stub(:execute, dispatch) do + assert_raises(error_class) do + response = @transport.execute( + request(url: destination, headers: {"x-client-assertion" => "fake-custom-credential-secret"}) + ) + response.body.to_a + end + end + + assert_equal(destination.to_s, error.url.to_s) + assert_nil(error.instance_variable_get(:@request)) + assert_nil(error.cause) + refute_match(/fake-custom-credential-secret|fake-error-secret|fake-cause-secret/, error.inspect) + end + end + private def request(url: URI("https://mtls.api.openai.com/v1/models"), headers: {}) OpenAI::HTTPClient::Request.new( method: :get, diff --git a/test/openai/auth/x509_transport_test.rb b/test/openai/auth/x509_transport_test.rb index b013bea81..36623c711 100644 --- a/test/openai/auth/x509_transport_test.rb +++ b/test/openai/auth/x509_transport_test.rb @@ -353,7 +353,7 @@ def test_deferred_transport_errors_never_retain_signed_urls_or_bearer_requests end end - def test_deferred_credential_free_transport_errors_preserve_safe_causes + def test_deferred_credential_free_transport_errors_do_not_retain_causes capability = capability_for(@http_client) destination = URI("https://mtls.api.openai.com/v1/models") original = OpenAI::Errors::APIConnectionError.new(url: destination) @@ -366,8 +366,8 @@ def test_deferred_credential_free_transport_errors_preserve_safe_causes end error = assert_raises(OpenAI::Errors::APIConnectionError) { response.body.to_a } - assert_same(original, error) - assert_same(safe_cause, error.cause) + refute_same(original, error) + assert_nil(error.cause) end def test_closing_wrapped_response_closes_the_original_stream @@ -449,7 +449,7 @@ def test_connection_errors_before_url_or_header_validation_are_not_masked end end - def test_transport_errors_preserve_safe_causes_when_no_query_is_present + def test_transport_errors_do_not_retain_causes_when_no_query_is_present capability = capability_for(@http_client) destination = URI("https://mtls.api.openai.com/v1/models") original = OpenAI::Errors::APIConnectionError.new(url: destination) @@ -462,8 +462,8 @@ def test_transport_errors_preserve_safe_causes_when_no_query_is_present end end - assert_same(original, error) - assert_same(safe_cause, error.cause) + refute_same(original, error) + assert_nil(error.cause) end def test_real_wire_issuer_and_api_share_attested_client_identity From 10451bacd3b52573762da32bab74eda1b7146cbc Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 24 Aug 2026 22:10:05 -0700 Subject: [PATCH 07/10] fix(auth): preserve allowlisted nested x509 issuer error codes --- lib/openai/auth/x509_token_exchange.rb | 1 + test/openai/auth/x509_token_exchange_test.rb | 48 +++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/lib/openai/auth/x509_token_exchange.rb b/lib/openai/auth/x509_token_exchange.rb index 13effc0df..669255bdc 100644 --- a/lib/openai/auth/x509_token_exchange.rb +++ b/lib/openai/auth/x509_token_exchange.rb @@ -140,6 +140,7 @@ def fetch(deadline: nil) headers = response.headers.slice("x-request-id") if [400, 401, 403].include?(response.status) error_code = body[:error] if body.is_a?(Hash) + error_code = error_code[:code] if error_code.is_a?(Hash) safe_body = {error: error_code} if OAUTH_ERROR_CODES.include?(error_code) error = OpenAI::Errors::OAuthError.new(status: response.status, body: safe_body, headers: headers) error.url = URI(TOKEN_URL) diff --git a/test/openai/auth/x509_token_exchange_test.rb b/test/openai/auth/x509_token_exchange_test.rb index 68189408f..6cc0ec904 100644 --- a/test/openai/auth/x509_token_exchange_test.rb +++ b/test/openai/auth/x509_token_exchange_test.rb @@ -318,13 +318,58 @@ def test_oauth_failures_preserve_only_known_codes_and_safe_request_ids end assert_equal("https://mtls.auth.openai.com/oauth/token", error.url.to_s) + assert_equal(:invalid_grant, error.error_code) assert_equal({error: "invalid_grant"}, error.body) assert_equal({"x-request-id" => "req_fake"}, error.headers) refute_match(/secret|diagnostics/, error.inspect) end + def test_oauth_failures_preserve_only_allowlisted_nested_issuer_error_codes + codes = { + "invalid_grant" => :invalid_grant, + "invalid_subject_token" => :invalid_subject_token, + "token_exchange_server_error" => "token_exchange_server_error" + } + + codes.each do |code, expected_error_code| + response = OpenAI::HTTPClient::Response.new( + status: 401, + headers: {"x-request-id" => "req_fake", "set-cookie" => "session=fake-cookie-secret"}, + body: JSON.generate( + error: { + code: code, + message: "fake-sensitive-issuer-message", + description: "fake-sensitive-issuer-description" + }, + error_description: "fake-sensitive-top-level-description" + ) + ) + + error = @http_client.stub(:execute, -> (_request) { response }) do + assert_raises(OpenAI::Errors::OAuthError) { @exchange.fetch } + end + + assert_equal(expected_error_code, error.error_code) + assert_equal({error: code}, error.body) + assert_equal({"x-request-id" => "req_fake"}, error.headers) + assert_equal(code, error.message) + refute_includes(error.full_message(highlight: false), "fake-sensitive") + refute_includes(error.full_message(highlight: false), "fake-cookie-secret") + end + end + def test_oauth_failures_discard_nested_and_unknown_error_details - [{message: "fake-secret"}, ["fake-secret"], "fake-secret"].each do |error_value| + error_values = [ + {message: "fake-secret"}, + {code: "fake-secret", description: "fake-secret-details"}, + {code: nil, message: "fake-secret"}, + {code: {nested: "fake-secret"}}, + {code: ["invalid_grant", "fake-secret"]}, + ["fake-secret"], + "fake-secret" + ] + + error_values.each do |error_value| response = OpenAI::HTTPClient::Response.new( status: 400, headers: {}, @@ -336,6 +381,7 @@ def test_oauth_failures_discard_nested_and_unknown_error_details end assert_nil(error.body) + assert_nil(error.error_code) refute_match(/fake-secret/, error.inspect) end end From eeb8c4e3fb3f8cc8fa86ea4069fbe53a3ff82b6c Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 24 Aug 2026 22:10:14 -0700 Subject: [PATCH 08/10] fix(auth): reject caller-controlled x509 request framing --- lib/openai/auth/x509_transport.rb | 4 +-- .../auth/x509_transport_security_test.rb | 36 +++++++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/lib/openai/auth/x509_transport.rb b/lib/openai/auth/x509_transport.rb index 3e76c2dc8..6ea5a3977 100644 --- a/lib/openai/auth/x509_transport.rb +++ b/lib/openai/auth/x509_transport.rb @@ -15,7 +15,7 @@ class X509Transport ISSUER_PATH = "/oauth/token" API_HOSTS = %w[mtls.api.openai.com mtls-us.api.openai.com mtls-eu.api.openai.com].freeze PROXY_MODES = [:direct, :http_connect].freeze - FORBIDDEN_HEADERS = %w[api-key x-api-key proxy-authorization].freeze + FORBIDDEN_HEADERS = %w[api-key x-api-key proxy-authorization content-length transfer-encoding].freeze EXCHANGE_FORBIDDEN_HEADERS = %w[authorization cookie openai-organization openai-project].freeze HEADER_NAME_PATTERN = /\A[!#$%&'*+.^_`|~0-9A-Za-z-]+\z/ BEARER_PATTERN = /\ABearer [A-Za-z0-9\-._~+\/]+=*\z/ @@ -197,7 +197,7 @@ def validate_api_request!(url:, headers:) end if FORBIDDEN_HEADERS.include?(key) || (exchange && EXCHANGE_FORBIDDEN_HEADERS.include?(key)) - raise ArgumentError, "X.509 request contains an unsupported credential header" + raise ArgumentError, "X.509 request contains an unsupported credential or framing header" end if key == "host" && !allowed_authority?(safe_value, url) diff --git a/test/openai/auth/x509_transport_security_test.rb b/test/openai/auth/x509_transport_security_test.rb index 79b354c17..37ceb601e 100644 --- a/test/openai/auth/x509_transport_security_test.rb +++ b/test/openai/auth/x509_transport_security_test.rb @@ -188,12 +188,42 @@ def test_unrecognized_custom_credential_headers_never_survive_transport_failures end end - private def request(url: URI("https://mtls.api.openai.com/v1/models"), headers: {}) + def test_rejects_caller_controlled_http_framing_before_issuer_or_api_dispatch + framing_headers = %w[content-length Content-Length CONTENT_LENGTH transfer-encoding Transfer_Encoding] + destinations = ["https://mtls.auth.openai.com/oauth/token", "https://mtls.api.openai.com/v1/models"] + reader, writer = IO.pipe + writer.write("fake-smuggled-request") + writer.close + bodies = ["fake-smuggled-request", StringIO.new("fake-smuggled-request"), reader] + + @http_client.stub(:execute, -> (_request) { flunk("framing must be rejected before native dispatch") }) do + framing_headers.product(destinations, bodies).each do |name, destination, body| + error = assert_raises(ArgumentError) do + @transport.execute(request(url: URI(destination), headers: {name => "0"}, method: :post, body: body)) + end + + assert_match(/framing/, error.message) + end + end + + assert_raises(ArgumentError) do + @transport.validate_api_request!( + url: URI("https://mtls.api.openai.com/v1/models"), + headers: {"TRANSFER_ENCODING" => "chunked"} + ) + end + + ensure + reader&.close + writer&.close unless writer&.closed? + end + + private def request(url: URI("https://mtls.api.openai.com/v1/models"), headers: {}, method: :get, body: nil) OpenAI::HTTPClient::Request.new( - method: :get, + method: method, url: url, headers: headers, - body: nil, + body: body, timeout: 2.0 ) end From 5eb9eae682b495613823debb99dbc2fade62b5a9 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Mon, 24 Aug 2026 22:14:58 -0700 Subject: [PATCH 09/10] fix(auth): initialize sanitized x509 issuer error code --- lib/openai/auth/x509_token_exchange.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/openai/auth/x509_token_exchange.rb b/lib/openai/auth/x509_token_exchange.rb index 669255bdc..bbca509bc 100644 --- a/lib/openai/auth/x509_token_exchange.rb +++ b/lib/openai/auth/x509_token_exchange.rb @@ -139,7 +139,7 @@ def fetch(deadline: nil) private def raise_error(response, body) headers = response.headers.slice("x-request-id") if [400, 401, 403].include?(response.status) - error_code = body[:error] if body.is_a?(Hash) + error_code = body.is_a?(Hash) ? body[:error] : nil error_code = error_code[:code] if error_code.is_a?(Hash) safe_body = {error: error_code} if OAUTH_ERROR_CODES.include?(error_code) error = OpenAI::Errors::OAuthError.new(status: response.status, body: safe_body, headers: headers) From 69dc4af0e4de65cb4c81583eaa12281dfea9e5e8 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Tue, 25 Aug 2026 09:03:59 -0700 Subject: [PATCH 10/10] feat(auth): integrate X.509 workload identity with the Ruby client (#525) ## Summary Final open integration PR in the stacked X.509 workload-identity series. The lifecycle-hardening PR (#526) and documentation/live-smoke PR (#527) have already been merged into this branch, so this PR now contains their complete reviewed changes as well. - Accept `OpenAI::Auth::X509WorkloadIdentity` through the existing `workload_identity:` client option and require an attested `X509Transport` as `http_client:`. - Default only certificate-authenticated clients to their approved mTLS API origin and reject provider-owned, mismatched, or unsupported destinations before credentials are acquired. - Reuse the exact caller-owned transport for both the pinned issuer exchange and the API request; keep existing API-key, JWT/ID workload-identity, admin, and ordinary transport behavior. - Fail closed for unsupported Realtime WebSocket connections. - Add a public-client, real-wire end-to-end test with independently terminating issuer/API TLS servers, a real client certificate and chain, authenticated HTTP CONNECT, and observed bearer/header separation. - Include bounded refresh/retry/concurrency handling, one-time credential replay, custom mTLS base-path preservation, and final credential/header hardening from #526. - Include customer documentation, installed-gem verification, production-enrolled certificate examples, and the optional protected live-smoke workflow from #527. - Inherit request-smuggling prevention and sanitized nested issuer OAuth error codes from the transport/token-exchange base PR (#523). ## Verification - Full Ruby 4 suite: **1,340 tests, 12,115 assertions, zero failures/errors**. - Full Ruby 3.3 suite: **1,340 tests, 12,114 assertions, zero failures/errors**. - `bundle exec rake lint`: **2,765 files inspected, no offenses**. - Real production X.509 issuer exchange and mTLS API request succeeded with the enrolled restricted-project service account, including with ambient debug logging enabled. - Focused real-wire issuer/API, framing-security, OAuth-redaction, lifecycle, installed-gem, protected-workflow, and sanitized-example regression coverage all pass. ## Stack 1. **#523 (open):** attested transport, real-wire security boundary, and pinned token-exchange protocol; #524 has already merged into this base. 2. **#525 (this PR, open):** public-client integration, lifecycle/retry hardening, customer documentation, protected optional live smoke, and installed-gem end-to-end coverage; #526 and #527 have already merged into this branch. Review requested from `@openai/sdks-team` because this change affects client authentication, endpoints, TLS, and a protected CI workflow. --- .github/workflows/live-smoke.yml | 92 ++ CONTRIBUTING.md | 45 + README.md | 65 + Rakefile | 11 + examples/e2e.yml | 3 + examples/x509_workload_identity.rb | 86 ++ lib/openai/auth/workload_identity_auth.rb | 130 +- lib/openai/client.rb | 286 ++++- .../helpers/realtime/client_extension.rb | 4 + lib/openai/internal/transport/base_client.rb | 18 +- openai.gemspec | 1 + rbi/openai/auth.rbi | 8 +- rbi/openai/client.rbi | 16 +- rbi/openai/internal/transport/base_client.rbi | 12 + scripts/live-smoke.rb | 56 + sig/openai/auth.rbs | 4 +- sig/openai/client.rbs | 5 + sig/openai/internal/transport/base_client.rbs | 5 + test/openai/auth/x509_client_test.rb | 1064 +++++++++++++++++ test/openai/auth/x509_lifecycle_test.rb | 834 +++++++++++++ test/openai/gem_packaging_test.rb | 46 + .../support/x509_installed_gem_smoke.rb | 109 ++ .../x509_workload_identity_example_test.rb | 157 +++ test/scripts/live_smoke_test.rb | 265 ++++ 24 files changed, 3260 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/live-smoke.yml create mode 100755 examples/x509_workload_identity.rb create mode 100755 scripts/live-smoke.rb create mode 100644 test/openai/auth/x509_client_test.rb create mode 100644 test/openai/auth/x509_lifecycle_test.rb create mode 100644 test/openai/support/x509_installed_gem_smoke.rb create mode 100644 test/openai/x509_workload_identity_example_test.rb create mode 100644 test/scripts/live_smoke_test.rb diff --git a/.github/workflows/live-smoke.yml b/.github/workflows/live-smoke.yml new file mode 100644 index 000000000..914ad8cd8 --- /dev/null +++ b/.github/workflows/live-smoke.yml @@ -0,0 +1,92 @@ +name: Live Smoke + +on: + workflow_dispatch: + inputs: + include_x509: + description: Also verify enrolled X.509 workload identity + required: true + default: false + type: boolean + +permissions: {} + +jobs: + live-smoke: + name: live Ruby library smoke + if: >- + github.ref == 'refs/heads/main' && + github.repository == 'openai/openai-ruby' + runs-on: ubuntu-latest + environment: ci + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.sha }} + - name: Set up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 + with: + ruby-version: "4.0" + bundler-cache: true + - name: Smoke-test authenticated API requests and streaming + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: bundle exec rake test:live:smoke + + x509-live-smoke: + name: live X.509 workload identity smoke + needs: live-smoke + if: >- + github.ref == 'refs/heads/main' && + github.repository == 'openai/openai-ruby' && + inputs.include_x509 + runs-on: ubuntu-latest + environment: x509-live-smoke + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.sha }} + - name: Set up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 + with: + ruby-version: "4.0" + bundler-cache: true + - name: Smoke-test enrolled X.509 workload identity + env: + OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM: ${{ secrets.OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM }} + OPENAI_X509_CLIENT_PRIVATE_KEY_PEM: ${{ secrets.OPENAI_X509_CLIENT_PRIVATE_KEY_PEM }} + OPENAI_X509_IDENTITY_PROVIDER_ID: ${{ secrets.OPENAI_X509_IDENTITY_PROVIDER_ID }} + OPENAI_X509_SERVICE_ACCOUNT_ID: ${{ secrets.OPENAI_X509_SERVICE_ACCOUNT_ID }} + OPENAI_CLIENT_KEY_PASSPHRASE: ${{ secrets.OPENAI_X509_CLIENT_KEY_PASSPHRASE }} + OPENAI_X509_PROXY_MODE: direct + run: | + if [ -z "$OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM" ] || + [ -z "$OPENAI_X509_CLIENT_PRIVATE_KEY_PEM" ] || + [ -z "$OPENAI_X509_IDENTITY_PROVIDER_ID" ] || + [ -z "$OPENAI_X509_SERVICE_ACCOUNT_ID" ]; then + echo "The protected x509-live-smoke environment is missing required X.509 secrets." >&2 + exit 1 + fi + + umask 077 + chain_file="$(mktemp "$RUNNER_TEMP/openai-x509-chain.XXXXXX")" + key_file="$(mktemp "$RUNNER_TEMP/openai-x509-key.XXXXXX")" + trap 'rm -f "$chain_file" "$key_file"' EXIT + printf '%s' "$OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM" > "$chain_file" + printf '%s' "$OPENAI_X509_CLIENT_PRIVATE_KEY_PEM" > "$key_file" + + export OPENAI_CLIENT_CERTIFICATE_CHAIN="$chain_file" + export OPENAI_CLIENT_KEY="$key_file" + export IDENTITY_PROVIDER_ID="$OPENAI_X509_IDENTITY_PROVIDER_ID" + export SERVICE_ACCOUNT_ID="$OPENAI_X509_SERVICE_ACCOUNT_ID" + unset OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM OPENAI_X509_CLIENT_PRIVATE_KEY_PEM + unset OPENAI_X509_IDENTITY_PROVIDER_ID OPENAI_X509_SERVICE_ACCOUNT_ID + bundle exec ruby examples/x509_workload_identity.rb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d8cebb9b..3f0a795ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -171,6 +171,51 @@ Every `examples/**/*.rb` file must be classified as covered or explicitly excluded with a reason. In GitHub Actions, live execution is available only through the manually dispatched `Examples E2E` workflow. +### Optional live library smoke tests + +Run a short, optional smoke test against the real API with an API key in your +environment: + +```bash +$ OPENAI_API_KEY=sk-example bundle exec rake test:live:smoke +``` + +The test verifies model discovery, a normal Responses API request, and a +completed streaming response. Override the default `gpt-4o-mini` model with +`OPENAI_LIVE_SMOKE_MODEL` when needed. It emits only pass/fail diagnostics, +never API response content, request bodies, or credentials. + +To also verify a real X.509 issuer exchange and a certificate-authenticated API +request, keep `OPENAI_API_KEY` available for the standard smoke checks and +provide the enrolled certificate/key paths and mapped provider/account IDs +documented in the README, then run: + +```bash +$ OPENAI_LIVE_SMOKE_X509=1 bundle exec rake test:live:smoke +``` + +GitHub Actions exposes the same checks through the optional, manually dispatched +`Live Smoke` workflow. Its standard smoke runs in the existing `ci` environment. +The optional X.509 job runs only after the standard smoke succeeds, is disabled +by default, and requires the following secrets in the separate, protected +`x509-live-smoke` environment: + +- `OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM` +- `OPENAI_X509_CLIENT_PRIVATE_KEY_PEM` +- `OPENAI_X509_IDENTITY_PROVIDER_ID` +- `OPENAI_X509_SERVICE_ACCOUNT_ID` +- `OPENAI_X509_CLIENT_KEY_PASSPHRASE` when the private key is encrypted. + +The X.509 environment requires independent SDK-team approval, prevents +self-review, disables administrator bypasses, and runs only on the protected +default branch. X.509 secrets are available only to the explicitly selected +X.509 job. Certificate files are mode-restricted, short-lived runner files, raw +PEM variables are removed before the SDK starts, and credential files are never +uploaded as artifacts. The GitHub-hosted runner always uses a direct X.509 +connection. Local runs may set +`OPENAI_X509_PROXY_MODE=http_connect` when an explicitly approved HTTP CONNECT +proxy is configured. Live smoke tests are not required pull-request checks. + ## Linting and formatting [rubyfmt](https://github.com/fables-tales/rubyfmt) owns Ruby source and `*.rbi` signature layout. The `scripts/rubyfmt` launcher uses version 0.14.1 and downloads a checksum-verified release into your user cache when needed. To use an existing installation, set `RUBYFMT` to an executable of that exact version. diff --git a/README.md b/README.md index 1580ee690..710c2e808 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,71 @@ For secure, automated environments like cloud-managed Kubernetes, Azure, and GCP `client_id` remains available as an optional parameter for token exchange setups that require an explicit OAuth client ID. +### X.509 Workload Identity (Preview) + +Organizations enrolled in the X.509 workload identity preview can exchange a +client certificate for a short-lived OpenAI bearer credential. Both the token +exchange and subsequent API request use the same caller-attested mTLS transport. +The bearer is not cryptographically bound to the certificate; the API separately +requires an accepted client certificate. + +```ruby +native_http_client = OpenAI::NetHTTPClient.new do |connection| + connection.cert = client_certificate + connection.extra_chain_cert = intermediate_certificates + connection.key = client_private_key +end + +transport = OpenAI::Auth::X509Transport.new( + http_client: native_http_client, + certificate_identity: :static, + proxy: :direct +) + +identity = OpenAI::Auth::X509WorkloadIdentity.new( + identity_provider_id: ENV.fetch("IDENTITY_PROVIDER_ID"), + service_account_id: ENV.fetch("SERVICE_ACCOUNT_ID") +) + +client = OpenAI::Client.new( + api_key: nil, + workload_identity: identity, + http_client: transport, + base_url: "#{transport.api_origin}/v1" +) + +model = client.models.list.data.first +``` + +The application owns its certificate, key, trust settings, and native HTTP +client. Keep the selected certificate identity static, configure only the +approved issuer and API destinations, and create a fresh native client and +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. + +See the complete [X.509 workload identity live smoke +example](examples/x509_workload_identity.rb). It performs a real token exchange +and API request only when an enrolled certificate, private key, identity-provider +ID, and mapped service-account ID are explicitly supplied. + +Keep these values in a private environment file outside your checkout or in a +secret manager, then run the example without an API key: + +```sh +export OPENAI_CLIENT_CERTIFICATE_CHAIN=/secure/path/client-chain.pem +export OPENAI_CLIENT_KEY=/secure/path/client-key.pem +export IDENTITY_PROVIDER_ID=idp_example +export SERVICE_ACCOUNT_ID=svc_acct_example + +ruby examples/x509_workload_identity.rb +``` + +Set `OPENAI_X509_PROXY_MODE=http_connect` only when an approved HTTP CONNECT +proxy is configured. Encrypted keys can use `OPENAI_CLIENT_KEY_PASSPHRASE`. + ### Kubernetes Service Account ```ruby diff --git a/Rakefile b/Rakefile index 576783925..05f3b4a8e 100644 --- a/Rakefile +++ b/Rakefile @@ -156,6 +156,17 @@ task("test:examples:e2e") do ruby(*%w[scripts/examples-e2e.rb]) end +desc("Smoke-test live API authentication, responses, streaming, and optionally X.509") +task("test:live:smoke") do + x509 = ENV.fetch("OPENAI_LIVE_SMOKE_X509", "0") + unless %w[0 1].include?(x509) + abort("OPENAI_LIVE_SMOKE_X509 must be 0 or 1") + end + + ruby(*%w[scripts/live-smoke.rb]) + ruby(*%w[examples/x509_workload_identity.rb]) if x509 == "1" +end + desc("Lint and typecheck") multitask(lint: [:"lint:rubocop", :"lint:rubocop_directives", :typecheck]) diff --git a/examples/e2e.yml b/examples/e2e.yml index c7e0ae14c..6bf82b056 100644 --- a/examples/e2e.yml +++ b/examples/e2e.yml @@ -100,3 +100,6 @@ examples: examples/structured_outputs_responses_function_calling.rb: status: covered expected_output: GetWeather + examples/x509_workload_identity.rb: + status: excluded + reason: Requires an enrolled X.509 certificate and key, an enabled organization, and mapped identity-provider and service-account IDs. diff --git a/examples/x509_workload_identity.rb b/examples/x509_workload_identity.rb new file mode 100755 index 000000000..a1957873b --- /dev/null +++ b/examples/x509_workload_identity.rb @@ -0,0 +1,86 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# This enrolled-credential smoke test performs both a real X.509 token exchange +# and an actual OpenAI mTLS API request. It does not use or require an API key. +# Required: OPENAI_CLIENT_CERTIFICATE_CHAIN, OPENAI_CLIENT_KEY, +# IDENTITY_PROVIDER_ID, SERVICE_ACCOUNT_ID, and an organization enabled for +# certificate-authenticated workload identity. The optional key passphrase is +# OPENAI_CLIENT_KEY_PASSPHRASE. Set OPENAI_X509_PROXY_MODE=http_connect only for +# a caller-configured HTTP CONNECT proxy that keeps proxy credentials isolated. + +require_relative "../lib/openai" + +native_http_client = nil +failure = nil + +begin + chain = OpenSSL::X509::Certificate.load( + File.binread(ENV.fetch("OPENAI_CLIENT_CERTIFICATE_CHAIN")) + ) + raise ArgumentError, "Expected an enrolled client certificate" if chain.empty? + + leaf, *intermediates = chain + key = OpenSSL::PKey.read( + File.binread(ENV.fetch("OPENAI_CLIENT_KEY")), + ENV["OPENAI_CLIENT_KEY_PASSPHRASE"] + ) + unless leaf.check_private_key(key) + raise ArgumentError, "The enrolled certificate and private key do not match" + end + + now = Time.now + raise ArgumentError, "The enrolled certificate is not yet valid" if now < leaf.not_before + raise ArgumentError, "The enrolled certificate has expired" if now > leaf.not_after + + 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 ArgumentError, "Refusing to present the enrolled certificate to an unexpected destination" + end + + connection.cert = leaf + connection.extra_chain_cert = intermediates + connection.key = key + end + + transport = OpenAI::Auth::X509Transport.new( + http_client: native_http_client, + certificate_identity: :static, + proxy: ENV.fetch("OPENAI_X509_PROXY_MODE", "direct").to_sym, + api_origin: api_origin + ) + identity = OpenAI::Auth::X509WorkloadIdentity.new( + identity_provider_id: ENV.fetch("IDENTITY_PROVIDER_ID"), + service_account_id: ENV.fetch("SERVICE_ACCOUNT_ID") + ) + client = OpenAI::Client.new( + api_key: nil, + workload_identity: identity, + http_client: transport, + base_url: "#{transport.api_origin}/v1", + log_level: :off + ) + + model = client.models.list.data.first + raise "The enrolled service account cannot access any models" if model.nil? +rescue StandardError => error + failure = error +ensure + begin + native_http_client&.close + rescue StandardError => error + failure ||= error + end +end + +if failure + status = failure.respond_to?(:status) ? failure.status : nil + status_message = status.is_a?(Integer) ? " (HTTP #{status})" : "" + warn("[x509] #{failure.class}#{status_message}") + exit(1) +end + +puts("[x509] real issuer exchange and mTLS API request succeeded") diff --git a/lib/openai/auth/workload_identity_auth.rb b/lib/openai/auth/workload_identity_auth.rb index 831049a87..288c100e5 100644 --- a/lib/openai/auth/workload_identity_auth.rb +++ b/lib/openai/auth/workload_identity_auth.rb @@ -17,16 +17,20 @@ class WorkloadIdentityAuth def initialize( config, organization_id, - token_exchange_url: DEFAULT_TOKEN_EXCHANGE_URL + token_exchange_url: DEFAULT_TOKEN_EXCHANGE_URL, + token_exchange: nil ) @config = config @organization_id = organization_id @token_exchange_url = URI(token_exchange_url) + @token_exchange = token_exchange @cached_token = nil @cached_token_expires_at_monotonic = nil @cached_token_refresh_at_monotonic = nil @refreshing = false + @refresh_generation = nil + @refresh_error = nil @mutex = Mutex.new @cond_var = ConditionVariable.new end @@ -36,60 +40,93 @@ def initialize( # @param deadline [Float, nil] absolute monotonic deadline for this request # @return [String] def get_token(deadline: nil) - check_deadline!(deadline) - action = nil - token = nil - - # Installing refresh cleanup is part of the state transition. No async - # exception may observe @refreshing after it changes but before the ensure. - Thread.handle_interrupt(Exception => :never) do - @mutex.synchronize do - if @refreshing - if token_unusable? - action = :wait + loop do + check_deadline!(deadline) + action = nil + token = nil + generation = nil + + # Installing refresh cleanup is part of the state transition. No async + # exception may observe @refreshing after it changes but before the ensure. + Thread.handle_interrupt(Exception => :never) do + @mutex.synchronize do + if @refreshing + if token_unusable? + action = :wait + generation = @refresh_generation + else + token = @cached_token + action = :return + end + elsif token_unusable? || needs_refresh? + @refreshing = true + generation = {complete: false, error: nil, token: nil, expires_at: nil} + @refresh_generation = generation + action = :refresh else token = @cached_token action = :return end - elsif token_unusable? || needs_refresh? - @refreshing = true - action = :refresh - else - token = @cached_token - action = :return end - end - - if action == :refresh - begin - Thread.handle_interrupt(Exception => :immediate) do - perform_refresh(deadline: deadline) - end - ensure - @mutex.synchronize do - @refreshing = false - @cond_var.broadcast + if action == :refresh + begin + Thread.handle_interrupt(Exception => :immediate) do + perform_refresh(deadline: deadline) + end + + rescue StandardError => error + @mutex.synchronize do + @refresh_error = error unless @token_exchange.nil? + generation[:error] = error + end + + raise + ensure + @mutex.synchronize do + if generation[:error].nil? + generation[:token] = @cached_token + generation[:expires_at] = @cached_token_expires_at_monotonic + end + + generation[:complete] = true + @refreshing = false + @cond_var.broadcast + end end end end - end - return token if action == :return - return wait_for_refresh(deadline) if action == :wait + return token if action == :return + if action == :wait + token = wait_for_refresh(deadline, generation) + return token unless token.nil? + + next + end - current_token(deadline) + return current_token(deadline) + end end # @api private - def invalidate_token + def invalidate_token(rejected_token = nil) @mutex.synchronize do + return nil unless rejected_token.nil? || rejected_token == @cached_token + @cached_token = nil @cached_token_expires_at_monotonic = nil @cached_token_refresh_at_monotonic = nil end end + # Avoid exposing cached access tokens or identity configuration in diagnostics. + # + # @return [String] + def inspect + "#<#{self.class.name}:0x#{object_id.to_s(16)}>" + end + private def current_token(deadline) check_deadline!(deadline) @mutex.synchronize do @@ -99,9 +136,9 @@ def invalidate_token end end - private def wait_for_refresh(deadline) + private def wait_for_refresh(deadline, generation) @mutex.synchronize do - while @refreshing + until generation.fetch(:complete) remaining = remaining_timeout(deadline) if remaining.nil? @cond_var.wait(@mutex) @@ -111,6 +148,24 @@ def invalidate_token end check_deadline!(deadline) + if @token_exchange + raise generation.fetch(:error) if generation[:error] + if generation[:token] + unless generation[:token] == @cached_token + return nil if @cached_token.nil? + + raise_refresh_error! if token_unusable? + + return @cached_token + end + + expires_at = generation.fetch(:expires_at) + raise_refresh_error! if expires_at.nil? || OpenAI::Internal::Util.monotonic_secs >= expires_at + + return generation.fetch(:token) + end + end + raise_refresh_error! if token_unusable? @cached_token @@ -137,6 +192,7 @@ def invalidate_token expires_in = token_data.fetch(:expires_in) @mutex.synchronize do + @refresh_error = nil @cached_token = token_data.fetch(:id) @cached_token_expires_at_monotonic = now + expires_in @cached_token_refresh_at_monotonic = now + refresh_delay_seconds(expires_in) @@ -144,6 +200,8 @@ def invalidate_token end private def fetch_token_from_exchange(deadline:) + return @token_exchange.fetch(deadline: deadline) unless @token_exchange.nil? + subject_token = @config.provider.get_token check_deadline!(deadline) diff --git a/lib/openai/client.rb b/lib/openai/client.rb index d27d4bdff..0d77395ef 100644 --- a/lib/openai/client.rb +++ b/lib/openai/client.rb @@ -174,20 +174,114 @@ class Client < OpenAI::Internal::Transport::BaseClient # Mutually exclusive with `workload_identity`. # + # @api private + private def build_request(request, options) + unless @workload_identity_auth && @requester.instance_of?(OpenAI::Auth::X509Transport) + return super + end + + selected = {}.merge(request.fetch(:security, {bearer_auth: true, admin_api_key_auth: true})).freeze + if selected.any? { |scheme, enabled| enabled && scheme != :bearer_auth && scheme != :admin_api_key_auth } + raise ArgumentError, "Unsupported authentication security scheme for X.509 workload identity" + end + + security = { + bearer_auth: selected[:bearer_auth] == true, + admin_api_key_auth: selected[:admin_api_key_auth] == true + }.freeze + expected = auth_headers(security: security)["authorization"] + built = super(request.merge(security: security), options) + canonical_headers = @requester.validate_api_request!( + url: built.fetch(:url), + headers: built.fetch(:headers) + ) + unless expected == canonical_headers["authorization"] + raise OpenAI::Errors::Error, "X.509 requests cannot override the selected authorization credential" + end + + auth_max_retries = options.fetch(:max_retries, @max_retries) + built.merge(headers: canonical_headers, x509_auth_max_retries: auth_max_retries) + end + # @api private private def prepare_request(request, redirect_count:, retry_count:) - request = prepare_workload_identity_request(request) if workload_identity_request?(request) + if workload_identity_request?(request) + request = prepare_workload_identity_request(request, retry_count: retry_count) + end + preparer = @provider_runtime&.prepare_request return super(request, redirect_count: redirect_count, retry_count: retry_count) unless preparer preparer.call(request) end + # @api private + private def validate_prepared_request(request, original_request:) + prepared_request = super + return prepared_request unless @workload_identity_auth && @requester.instance_of?(OpenAI::Auth::X509Transport) + + canonical_headers = @requester.validate_api_request!( + url: prepared_request.fetch(:url), + headers: prepared_request.fetch(:headers) + ) + expected = if (context = original_request[:x509_request_context]) + "Bearer #{context.fetch(:token)}" + else + original_request.fetch(:headers)["authorization"] + end + + unless canonical_headers["authorization"] == expected + raise OpenAI::Errors::Error, "X.509 requests cannot override the selected authorization credential" + end + + prepared_request.merge(headers: canonical_headers) + end + # @api private private def send_request(request, redirect_count:, retry_count:, send_retry_header:) + if @workload_identity_auth && @requester.instance_of?(OpenAI::Auth::X509Transport) + canonical_headers = @requester.validate_api_request!( + url: request.fetch(:url), + headers: request.fetch(:headers) + ) + request = request.merge(headers: canonical_headers) + authorization = canonical_headers["authorization"] + expected = "Bearer #{WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER}" + admin_authorization = @admin_api_key && "Bearer #{@admin_api_key}" + unless authorization.nil? || authorization == expected || authorization == admin_authorization + raise OpenAI::Errors::Error, "X.509 requests cannot override the selected authorization credential" + end + end + return super unless workload_identity_request?(request) - deadline = request[:timeout]&.then { OpenAI::Internal::Util.monotonic_secs + _1 } + x509_request = @requester.instance_of?(OpenAI::Auth::X509Transport) + previous_context = request[:x509_request_context] + deadline = if x509_request + previous_context&.fetch(:deadline) || + request[:workload_identity_deadline] || + request[:timeout]&.then { OpenAI::Internal::Util.monotonic_secs + _1 } + else + request[:timeout]&.then { OpenAI::Internal::Util.monotonic_secs + _1 } + end + request = request.merge(workload_identity_deadline: deadline) + if x509_request + replay_state = previous_context&.fetch(:replay_state) || [] + issuer_retries = previous_context&.fetch(:issuer_retries, 0) || 0 + auth_max_retries = previous_context&.fetch(:auth_max_retries) || request.fetch(:x509_auth_max_retries) + api_max_retries = previous_context&.fetch(:api_max_retries) || request.fetch(:max_retries) + log_context = previous_context&.fetch(:log_context) || yield + context = { + deadline: deadline, + replay_state: replay_state, + issuer_retries: issuer_retries, + auth_max_retries: auth_max_retries, + api_max_retries: api_max_retries, + log_context: log_context, + token: nil + } + request = request.merge(x509_request_context: context, max_retries: 0) + end begin super( @@ -197,16 +291,49 @@ class Client < OpenAI::Internal::Transport::BaseClient send_retry_header: send_retry_header ) rescue OpenAI::Errors::AuthenticationError - raise unless retry_count.zero? && request_replayable?(request) - @workload_identity_auth.invalidate_token + @workload_identity_auth.invalidate_token(context.fetch(:token)) if x509_request + replay_allowed = request_replayable?(request) + replay_allowed &&= x509_request ? replay_state.empty? : retry_count.zero? + raise unless replay_allowed + + if x509_request + replay_state << true + replay_state.freeze + issuer_retries = context.fetch(:issuer_retries) + auth_max_retries = context.fetch(:auth_max_retries) + api_max_retries = context.fetch(:api_max_retries) + log_context = context.fetch(:log_context) + context = { + deadline: deadline, + replay_state: replay_state, + issuer_retries: issuer_retries, + auth_max_retries: auth_max_retries, + api_max_retries: api_max_retries, + log_context: log_context, + token: nil + } + request = request.merge(x509_request_context: context) + else + @workload_identity_auth.invalidate_token + end - super( - request, - redirect_count: redirect_count, - retry_count: retry_count + 1, - send_retry_header: send_retry_header - ) + begin + super( + request, + redirect_count: redirect_count, + retry_count: retry_count + 1, + send_retry_header: send_retry_header + ) + rescue OpenAI::Errors::AuthenticationError + @workload_identity_auth.invalidate_token(context.fetch(:token)) if x509_request + raise + end end + + rescue Timeout::Error => error + raise unless x509_request + + raise OpenAI::Errors::APITimeoutError.new(url: request.fetch(:url), message: error.message), cause: nil end private def workload_identity_request?(request) @@ -216,15 +343,73 @@ class Client < OpenAI::Internal::Transport::BaseClient request[:headers]["authorization"] == expected end - private def prepare_workload_identity_request(request) + private def prepare_workload_identity_request(request, retry_count:) deadline = request[:workload_identity_deadline] - token = @workload_identity_auth.get_token(deadline: deadline) + previous_issuer_retries = request[:x509_request_context]&.fetch(:issuer_retries, 0) || 0 + attempts = 0 + begin + token = @workload_identity_auth.get_token(deadline: deadline) + rescue OpenAI::Errors::APIError => error + status = error.status + connection_failure = error.is_a?(OpenAI::Errors::APIConnectionError) + retryable_status = connection_failure || + [408, 409, 429].include?(status) || + (status.is_a?(Integer) && (500..599).cover?(status)) + consumed_retries = attempts + previous_issuer_retries + retry_count + unless @requester.instance_of?(OpenAI::Auth::X509Transport) && + retryable_status && + consumed_retries < request.fetch(:x509_request_context).fetch(:auth_max_retries) && + (connection_failure || self.class.should_retry?(status, headers: error.headers || {})) + raise + end + + delay = retry_delay(error.headers || {}, retry_count: attempts) + if deadline && delay >= deadline - OpenAI::Internal::Util.monotonic_secs + raise Timeout::Error, "request timed out during workload identity authentication" + end + + context = request.fetch(:x509_request_context) + response = if connection_failure + nil + else + OpenAI::ResponseMetadata.new(status: status, headers: error.headers || {}) + end + + context.fetch(:log_context).retry_scheduled( + connection_failure ? error : status, + delay: delay, + response: response, + retry_count: consumed_retries, + max_retries: context.fetch(:auth_max_retries) + ) + sleep(delay) + attempts += 1 + retry + end + + if (context = request[:x509_request_context]) + context[:issuer_retries] = previous_issuer_retries + attempts + context[:token] = token.dup.freeze + context.freeze + end + updated_headers = request[:headers].merge("authorization" => "Bearer #{token}") + updated_request = request + .except(:workload_identity_deadline, :x509_request_context, :x509_auth_max_retries) + .merge(headers: updated_headers) + if context + updated_request = updated_request.merge( + max_retries: [context.fetch(:api_max_retries) - context.fetch(:issuer_retries), 0].max + ) + end + request_with_remaining_timeout( - request.except(:workload_identity_deadline).merge(headers: updated_headers), + updated_request, deadline ) rescue Timeout::Error => e + raise if @requester.instance_of?(OpenAI::Auth::X509Transport) + raise OpenAI::Errors::APITimeoutError.new(url: request.fetch(:url), message: e.message) end @@ -252,7 +437,32 @@ class Client < OpenAI::Internal::Transport::BaseClient # @param overrides [Hash{Symbol=>Object}] Options accepted by {#initialize}. # @return [self] def with_options(**overrides) - self.class.new(**OpenAI::Internal::ClientOptions.copy(@copy_options, overrides)) + previous_transport = @copy_options.fetch(:http_client) + transport = overrides.fetch(:http_client, previous_transport) + if overrides[:data_residency] && transport.instance_of?(OpenAI::Auth::X509Transport) + residency = overrides.fetch(:data_residency) + options = OpenAI::Internal::ClientOptions.copy(@copy_options, overrides.except(:data_residency)) + options.delete(:base_url) unless overrides.key?(:base_url) + return self.class.new(**options, data_residency: residency) + end + + options = OpenAI::Internal::ClientOptions.copy(@copy_options, overrides) + adopted_identity = options.fetch(:workload_identity).instance_of?(OpenAI::Auth::X509WorkloadIdentity) && + !@copy_options.fetch(:workload_identity).instance_of?(OpenAI::Auth::X509WorkloadIdentity) + previous_origin = previous_transport.api_origin if previous_transport.instance_of?(OpenAI::Auth::X509Transport) + selected_origin = transport.api_origin if transport.instance_of?(OpenAI::Auth::X509Transport) + if adopted_identity && selected_origin + inherited_origin = OpenAI::Internal::Util.uri_origin(URI(options.fetch(:base_url).to_s)) + adopted_identity = !inherited_origin.casecmp?(selected_origin) + end + + if (adopted_identity || previous_origin != selected_origin) && + !overrides.key?(:base_url) && + !overrides[:data_residency] + options.delete(:base_url) + end + + self.class.new(**options) end # Creates and returns a new client for interacting with the API. @@ -262,7 +472,7 @@ def with_options(**overrides) # # @param admin_api_key [String, nil] Defaults to `ENV["OPENAI_ADMIN_KEY"]` # - # @param workload_identity [OpenAI::Auth::WorkloadIdentity, nil] + # @param workload_identity [OpenAI::Auth::WorkloadIdentity, OpenAI::Auth::X509WorkloadIdentity, nil] # OAuth2 workload identity configuration for token exchange authentication. # Mutually exclusive with `api_key`. # @@ -323,11 +533,29 @@ def initialize( log_level: nil, on_retry: nil ) + x509_identity = workload_identity.instance_of?(OpenAI::Auth::X509WorkloadIdentity) + if x509_identity && !http_client.instance_of?(OpenAI::Auth::X509Transport) + raise ArgumentError, "X.509 workload identity requires an attested X509Transport" + end + base_url = OpenAI::Internal::ClientOptions.resolve_data_residency( data_residency, base_url: base_url, provider: provider ) + if http_client.instance_of?(OpenAI::Auth::X509Transport) && !data_residency.nil? + regional_origins = { + "global" => "https://mtls.api.openai.com", + "us" => "https://mtls-us.api.openai.com", + "eu" => "https://mtls-eu.api.openai.com" + } + unless regional_origins[data_residency.to_s] == http_client.api_origin + raise ArgumentError, "X.509 data residency must match its attested OpenAI mTLS API origin" + end + + base_url = "#{http_client.api_origin}/v1" + end + provider_runtime = nil unless provider.nil? provider_name = OpenAI::Internal::Provider.name(provider) @@ -370,7 +598,21 @@ def initialize( webhook_secret = nil if webhook_secret.equal?(OpenAI::Internal::OMIT) base_url = provider_runtime.base_url if provider_runtime base_url = nil if base_url.equal?(OpenAI::Internal::OMIT) - base_url ||= "https://api.openai.com/v1" + base_url ||= if http_client.instance_of?(OpenAI::Auth::X509Transport) + "#{http_client.api_origin}/v1" + else + "https://api.openai.com/v1" + end + + if x509_identity + configured_uri = URI(base_url.to_s) + unless configured_uri.is_a?(URI::HTTPS) && + configured_uri.userinfo.nil? && + configured_uri.port == URI::HTTPS::DEFAULT_PORT && + OpenAI::Internal::Util.uri_origin(configured_uri).casecmp?(http_client.api_origin) + raise ArgumentError, "X.509 workload identity requires its attested OpenAI mTLS API origin" + end + end if !api_key.nil? && !workload_identity.nil? raise ArgumentError, "`api_key` and `workload_identity` are mutually exclusive" @@ -411,9 +653,19 @@ def initialize( @workload_identity_auth = nil else @api_key = WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER + token_exchange = if x509_identity + OpenAI::Auth::X509TokenExchange.new(workload_identity, transport: http_client) + end + @workload_identity_auth = OpenAI::Auth::WorkloadIdentityAuth.new( workload_identity, - organization&.to_s + organization&.to_s, + token_exchange_url: if x509_identity + "#{OpenAI::Auth::X509Transport::ISSUER_ORIGIN}/oauth/token" + else + OpenAI::Auth::WorkloadIdentityAuth::DEFAULT_TOKEN_EXCHANGE_URL + end, + token_exchange: token_exchange ) end diff --git a/lib/openai/helpers/realtime/client_extension.rb b/lib/openai/helpers/realtime/client_extension.rb index 7cfd644db..81cbc8bdc 100644 --- a/lib/openai/helpers/realtime/client_extension.rb +++ b/lib/openai/helpers/realtime/client_extension.rb @@ -64,6 +64,10 @@ def with_realtime_connection_request(path:, query:, websocket_base_url: nil, opt options:, deadline: nil ) + if @copy_options.fetch(:workload_identity).instance_of?(OpenAI::Auth::X509WorkloadIdentity) + raise OpenAI::Errors::Error, "X.509 workload identity does not support Realtime WebSocket connections" + end + if @provider_runtime && @provider_runtime.name != "azure" message = "Realtime WebSocket connections are not supported by the " \ "#{@provider_runtime.name} provider." diff --git a/lib/openai/internal/transport/base_client.rb b/lib/openai/internal/transport/base_client.rb index dc0cd6239..e2c09947a 100644 --- a/lib/openai/internal/transport/base_client.rb +++ b/lib/openai/internal/transport/base_client.rb @@ -352,6 +352,13 @@ def initialize( request end + # Validate the effective request after all per-attempt preparation hooks. + # + # @api private + private def validate_prepared_request(request, **_context) + request + end + # @api private # # @return [String] @@ -588,10 +595,13 @@ def send_request(request, redirect_count:, retry_count:, send_retry_header:, &co headers: encoded_headers.transform_values(&:dup), body: encoded_body ) - prepared_request = prepare_request( - attempt_request, - redirect_count: redirect_count, - retry_count: retry_count + prepared_request = validate_prepared_request( + prepare_request( + attempt_request, + redirect_count: redirect_count, + retry_count: retry_count + ), + original_request: request ) prepared_url = prepared_request.fetch(:url) diff --git a/openai.gemspec b/openai.gemspec index 967ea33b4..44ef85d16 100644 --- a/openai.gemspec +++ b/openai.gemspec @@ -26,6 +26,7 @@ Gem::Specification.new do |s| ] + [ "examples/mtls_custom_http_client.rb", + "examples/x509_workload_identity.rb", "examples/realtime/README.md", "examples/realtime/function_calling.rb", "examples/realtime/image_input.rb", diff --git a/rbi/openai/auth.rbi b/rbi/openai/auth.rbi index d55994b45..f35aff3db 100644 --- a/rbi/openai/auth.rbi +++ b/rbi/openai/auth.rbi @@ -58,8 +58,12 @@ module OpenAI def get_token(deadline: nil) end - sig { void } - def invalidate_token + sig { params(rejected_token: T.nilable(String)).void } + def invalidate_token(rejected_token = nil) + end + + sig { returns(String) } + def inspect end end end diff --git a/rbi/openai/client.rbi b/rbi/openai/client.rbi index efdf1f3c4..2c8b047f9 100644 --- a/rbi/openai/client.rbi +++ b/rbi/openai/client.rbi @@ -148,12 +148,24 @@ module OpenAI private def prepare_request(request, redirect_count:, retry_count:) end + # @api private + sig do + override + .params( + request: OpenAI::Internal::Transport::BaseClient::RequestInput, + original_request: OpenAI::Internal::Transport::BaseClient::RequestInput + ) + .returns(OpenAI::Internal::Transport::BaseClient::RequestInput) + end + private def validate_prepared_request(request, original_request:) + end + # Returns a new client with the supplied options overridden. sig do params( api_key: T.nilable(String), admin_api_key: T.nilable(String), - workload_identity: T.nilable(OpenAI::Auth::WorkloadIdentity), + workload_identity: T.nilable(T.any(OpenAI::Auth::WorkloadIdentity, OpenAI::Auth::X509WorkloadIdentity)), organization: T.nilable(String), project: T.nilable(String), webhook_secret: T.nilable(String), @@ -201,7 +213,7 @@ module OpenAI api_key: T.nilable(String), admin_api_key: T.nilable(String), - workload_identity: T.nilable(OpenAI::Auth::WorkloadIdentity), + workload_identity: T.nilable(T.any(OpenAI::Auth::WorkloadIdentity, OpenAI::Auth::X509WorkloadIdentity)), organization: T.nilable(String), project: T.nilable(String), diff --git a/rbi/openai/internal/transport/base_client.rbi b/rbi/openai/internal/transport/base_client.rbi index 7dd9baf7f..cf2f9ce97 100644 --- a/rbi/openai/internal/transport/base_client.rbi +++ b/rbi/openai/internal/transport/base_client.rbi @@ -221,6 +221,18 @@ module OpenAI private def prepare_request(request, redirect_count:, retry_count:) end + # @api private + sig do + overridable + .params( + request: OpenAI::Internal::Transport::BaseClient::RequestInput, + original_request: OpenAI::Internal::Transport::BaseClient::RequestInput + ) + .returns(OpenAI::Internal::Transport::BaseClient::RequestInput) + end + private def validate_prepared_request(request, original_request:) + end + # @api private sig { returns(String) } private def user_agent diff --git a/scripts/live-smoke.rb b/scripts/live-smoke.rb new file mode 100755 index 000000000..b6a95e94c --- /dev/null +++ b/scripts/live-smoke.rb @@ -0,0 +1,56 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative "../lib/openai" + +module OpenAILiveSmoke + class Failure < StandardError + end + + class Runner + PROMPT = "Reply with exactly OK." + + def initialize(client:, model:, output:) + @client = client + @model = model + @output = output + end + + def run + raise Failure, "model listing returned no accessible models" if @client.models.list.data.empty? + @output.puts("[live-smoke] authenticated model listing succeeded") + + response = @client.responses.create(model: @model, input: PROMPT, max_output_tokens: 32) + raise Failure, "response creation returned no output text" if response.output_text.to_s.strip.empty? + @output.puts("[live-smoke] non-streaming response succeeded") + + stream = @client.responses.stream(model: @model, input: PROMPT, max_output_tokens: 32) + raise Failure, "response stream returned no completed output text" if stream.get_output_text.to_s.strip.empty? + @output.puts("[live-smoke] streaming response completed") + + nil + end + end + + def self.run_cli(model:, output:, error_output:, client: nil) + client ||= OpenAI::Client.new(log_level: :off) + Runner.new(client: client, model: model, output: output).run + true + rescue Failure => error + 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}") + false + end +end + +if $PROGRAM_NAME == __FILE__ + success = OpenAILiveSmoke.run_cli( + model: ENV.fetch("OPENAI_LIVE_SMOKE_MODEL", "gpt-4o-mini"), + output: $stdout, + error_output: $stderr + ) + exit(1) unless success +end diff --git a/sig/openai/auth.rbs b/sig/openai/auth.rbs index 0a8f1e7ac..564f4d2a5 100644 --- a/sig/openai/auth.rbs +++ b/sig/openai/auth.rbs @@ -3,7 +3,9 @@ module OpenAI class WorkloadIdentityAuth def get_token: (?deadline: Float?) -> String - def invalidate_token: -> void + def invalidate_token: (?String? rejected_token) -> void + + def inspect: -> String end end end diff --git a/sig/openai/client.rbs b/sig/openai/client.rbs index af637df93..dca6f7fb5 100644 --- a/sig/openai/client.rbs +++ b/sig/openai/client.rbs @@ -82,6 +82,11 @@ module OpenAI retry_count: Integer ) -> OpenAI::Internal::Transport::BaseClient::request_input + private def validate_prepared_request: ( + OpenAI::Internal::Transport::BaseClient::request_input request, + original_request: OpenAI::Internal::Transport::BaseClient::request_input + ) -> OpenAI::Internal::Transport::BaseClient::request_input + def with_options: ( ?api_key: String?, ?admin_api_key: String?, diff --git a/sig/openai/internal/transport/base_client.rbs b/sig/openai/internal/transport/base_client.rbs index 1b5517583..58b8abda1 100644 --- a/sig/openai/internal/transport/base_client.rbs +++ b/sig/openai/internal/transport/base_client.rbs @@ -108,6 +108,11 @@ module OpenAI retry_count: Integer ) -> OpenAI::Internal::Transport::BaseClient::request_input + private def validate_prepared_request: ( + OpenAI::Internal::Transport::BaseClient::request_input request, + original_request: OpenAI::Internal::Transport::BaseClient::request_input + ) -> OpenAI::Internal::Transport::BaseClient::request_input + private def user_agent: -> String private def generate_idempotency_key: -> String diff --git a/test/openai/auth/x509_client_test.rb b/test/openai/auth/x509_client_test.rb new file mode 100644 index 000000000..d976ed2b8 --- /dev/null +++ b/test/openai/auth/x509_client_test.rb @@ -0,0 +1,1064 @@ +# frozen_string_literal: true + +require_relative "../test_helper" +require_relative "../support/mtls_wire_harness" +require "open3" +require "rbconfig" + +class OpenAI::Test::X509ClientTest < Minitest::Test + extend Minitest::Serial + + Harness = OpenAI::Test::MTLSWireHarness + WIRE_SUBPROCESS = "OPENAI_RUBY_X509_CLIENT_WIRE_SUBPROCESS" + + 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" + ) + end + + def teardown + @native.close + super + end + + def test_x509_client_defaults_to_its_attested_mtls_origin + client = OpenAI::Client.new(api_key: nil, workload_identity: @identity, http_client: @transport) + + assert_equal("https://mtls.api.openai.com/v1", client.base_url.to_s) + assert_same(@transport, client.requester) + refute_nil(client.workload_identity_auth) + end + + def test_api_key_clients_default_to_their_attested_mtls_transport_origin + origins = ["https://mtls.api.openai.com", "https://mtls-eu.api.openai.com"] + + origins.each do |origin| + transport = OpenAI::Auth::X509Transport.new( + http_client: @native, + certificate_identity: :static, + api_origin: origin + ) + client = OpenAI::Client.new(api_key: "fake-api-key", http_client: transport) + observed = nil + 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")] + ) + + @native.stub( + :execute, + -> (request) { + observed = request + response + } + ) do + assert_equal("fake-model", client.models.retrieve("fake-model").id) + end + + assert_equal("#{origin}/v1", client.base_url.to_s) + assert_equal("#{origin}/v1/models/fake-model", observed.url.to_s) + assert_equal("Bearer fake-api-key", observed.headers.fetch("authorization")) + end + end + + def test_x509_client_requires_the_explicit_transport_capability + [nil, @native, Object.new].each do |http_client| + error = assert_raises(ArgumentError) do + OpenAI::Client.new(api_key: nil, workload_identity: @identity, http_client: http_client) + end + + assert_match(/attested X509Transport/, error.message) + end + end + + def test_x509_client_rejects_unapproved_endpoints_before_exchange + endpoints = [ + "https://api.openai.com/v1", + "https://tenant.openai.azure.com/openai/v1", + "https://mtls-eu.api.openai.com/v1", + "http://mtls.api.openai.com/v1", + "https://mtls.api.openai.com:8443/v1", + "https://user:password@mtls.api.openai.com/v1", + "https://user@mtls.api.openai.com/v1" + ] + + @native.stub(:execute, -> (_request) { flunk("invalid origin must fail before exchange") }) do + endpoints.each do |endpoint| + assert_raises(ArgumentError) do + OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + base_url: endpoint + ) + end + end + end + end + + def test_x509_client_copies_preserve_capability_and_reject_origin_substitution + client = OpenAI::Client.new(api_key: nil, workload_identity: @identity, http_client: @transport) + copy = client.with_options(timeout: 3.0) + + assert_same(@transport, copy.requester) + assert_equal("https://mtls.api.openai.com/v1", copy.base_url.to_s) + assert_raises(ArgumentError) { client.with_options(base_url: "https://attacker.invalid/v1") } + end + + def test_replacing_transport_recomputes_the_matching_api_origin + global = OpenAI::Client.new(api_key: nil, workload_identity: @identity, http_client: @transport) + european_transport = OpenAI::Auth::X509Transport.new( + http_client: @native, + certificate_identity: :static, + api_origin: "https://mtls-eu.api.openai.com" + ) + + european = global.with_options(http_client: european_transport) + assert_equal("https://mtls-eu.api.openai.com/v1", european.base_url.to_s) + assert_same(european_transport, european.requester) + assert_equal("https://mtls.api.openai.com/v1", european.with_options(http_client: @transport).base_url.to_s) + + ordinary = global.with_options(api_key: "fake-api-key", http_client: @native) + assert_equal("https://api.openai.com/v1", ordinary.base_url.to_s) + assert_same(@native, ordinary.requester) + + reattested = ordinary.with_options(http_client: european_transport) + assert_equal("https://mtls-eu.api.openai.com/v1", reattested.base_url.to_s) + assert_same(european_transport, reattested.requester) + assert_equal("fake-api-key", reattested.api_key) + + explicit = global.with_options(http_client: european_transport, base_url: "https://mtls-eu.api.openai.com/v2") + assert_equal("https://mtls-eu.api.openai.com/v2", explicit.base_url.to_s) + assert_raises(ArgumentError) do + global.with_options(http_client: european_transport, base_url: "https://mtls.api.openai.com/v1") + end + end + + def test_switching_to_an_api_key_preserves_the_attested_regional_transport + origin = "https://mtls-eu.api.openai.com" + transport = OpenAI::Auth::X509Transport.new( + http_client: @native, + certificate_identity: :static, + api_origin: origin + ) + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: transport, + data_residency: :eu + ) + + copied = client.with_options(api_key: "fake-api-key", data_residency: :eu) + + assert_equal("#{origin}/v1", copied.base_url.to_s) + assert_equal("fake-api-key", copied.api_key) + assert_nil(copied.workload_identity_auth) + assert_same(transport, copied.requester) + assert_raises(ArgumentError) { client.with_options(api_key: "fake-api-key", data_residency: :us) } + + replaced = client.with_options(api_key: "fake-api-key", data_residency: :eu, http_client: @native) + assert_equal("https://eu.api.openai.com/v1", replaced.base_url.to_s) + assert_same(@native, replaced.requester) + end + + def test_api_key_client_can_adopt_x509_identity_without_inheriting_its_endpoint + ordinary = OpenAI::Client.new( + api_key: "fake-api-key", + base_url: "https://ordinary.example.invalid/v1", + http_client: @native + ) + + copied = ordinary.with_options(workload_identity: @identity, http_client: @transport) + + assert_equal("https://mtls.api.openai.com/v1", copied.base_url.to_s) + assert_same(@transport, copied.requester) + refute_nil(copied.workload_identity_auth) + assert_equal("https://ordinary.example.invalid/v1", ordinary.base_url.to_s) + assert_raises(ArgumentError) do + ordinary.with_options( + workload_identity: @identity, + http_client: @transport, + base_url: "https://ordinary.example.invalid/v1" + ) + end + end + + def test_adopting_x509_identity_preserves_a_matching_custom_mtls_base_path + custom_base_url = "https://mtls.api.openai.com/v2/custom" + api_key_client = OpenAI::Client.new( + api_key: "fake-api-key", + base_url: custom_base_url, + http_client: @transport + ) + + copied = api_key_client.with_options(workload_identity: @identity) + + assert_equal(custom_base_url, copied.base_url.to_s) + assert_same(@transport, copied.requester) + refute_nil(copied.workload_identity_auth) + assert_equal(custom_base_url, api_key_client.base_url.to_s) + end + + def test_adopting_x509_identity_discards_a_mismatched_base_on_the_existing_transport + api_key_client = OpenAI::Client.new( + api_key: "fake-api-key", + base_url: "https://ordinary.example.invalid/v2/custom", + http_client: @transport + ) + + copied = api_key_client.with_options(workload_identity: @identity) + + assert_equal("https://mtls.api.openai.com/v1", copied.base_url.to_s) + assert_same(@transport, copied.requester) + refute_nil(copied.workload_identity_auth) + end + + def test_x509_data_residency_uses_only_the_matching_attested_mtls_origin + origins = { + global: "https://mtls.api.openai.com", + us: "https://mtls-us.api.openai.com", + eu: "https://mtls-eu.api.openai.com" + } + + origins.each do |region, origin| + transport = OpenAI::Auth::X509Transport.new( + http_client: @native, + certificate_identity: :static, + api_origin: origin + ) + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: transport, + data_residency: region + ) + + assert_equal("#{origin}/v1", client.base_url.to_s) + assert_equal("#{origin}/v1", client.with_options(data_residency: region).base_url.to_s) + assert_equal("#{origin}/v1", client.with_options(data_residency: region, api_key: nil).base_url.to_s) + assert_raises(ArgumentError) do + client.with_options(data_residency: region, base_url: "#{origin}/v1") + end + + mismatched = (origins.keys - [region]).first + assert_raises(ArgumentError) { client.with_options(data_residency: mismatched) } + end + + assert_raises(ArgumentError) do + OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + data_residency: :ae + ) + end + end + + def test_x509_bearer_overrides_are_rejected_before_any_token_exchange + client = OpenAI::Client.new(api_key: nil, workload_identity: @identity, http_client: @transport) + malicious_headers = [ + {"authorization" => "Bearer fake-attacker-token"}, + {"Authorization" => "Bearer fake-attacker-token"} + ] + + @native.stub(:execute, -> (_request) { flunk("bearer override must fail before token exchange") }) do + malicious_headers.each do |headers| + error = assert_raises(OpenAI::Errors::Error) do + client.models.retrieve("fake-model", request_options: {extra_headers: headers}) + end + + assert_match(/cannot override the selected authorization credential/, error.message) + end + end + end + + def test_x509_selected_bearer_cannot_be_removed_before_token_exchange + client = OpenAI::Client.new(api_key: nil, workload_identity: @identity, http_client: @transport) + + @native.stub(:execute, -> (_request) { flunk("missing bearer must fail before token exchange") }) do + [nil, "", " \t"].each do |value| + error = assert_raises(OpenAI::Errors::Error) do + client.models.retrieve( + "fake-model", + request_options: {extra_headers: {"authorization" => value}} + ) + end + + assert_match(/cannot override the selected authorization credential/, error.message) + end + end + end + + def test_x509_effective_credential_headers_are_rejected_before_any_token_exchange + client = OpenAI::Client.new(api_key: nil, workload_identity: @identity, http_client: @transport) + malicious_headers = [ + {"x-api-key" => "fake-provider-key"}, + {"X_API_KEY" => "fake-provider-key"}, + {"proxy-authorization" => "Basic fake-proxy-secret"}, + {"Proxy_Authorization" => "Basic fake-proxy-secret"}, + {"host" => "attacker.invalid"} + ] + + @native.stub(:execute, -> (_request) { flunk("unsafe headers must fail before token exchange") }) do + malicious_headers.each do |headers| + assert_raises(ArgumentError) do + client.models.retrieve("fake-model", request_options: {extra_headers: headers}) + end + + configured = client.with_options(default_headers: headers) + assert_raises(ArgumentError) { configured.models.retrieve("fake-model") } + end + end + end + + def test_prepared_x509_requests_dispatch_the_immutable_validated_header_snapshot + mutable_header = Class.new(String) do + attr_reader(:comparisons) + + def to_s = self + + def ==(other) + @comparisons = (@comparisons || 0) + 1 + matches = super + replace("Bearer fake-mutated-after-validation") if matches + matches + end + end + + retained_headers = [] + client_class = Class.new(OpenAI::Client) do + define_method(:prepare_request) do |request, redirect_count:, retry_count:| + prepared = super(request, redirect_count: redirect_count, retry_count: retry_count) + retained = mutable_header.new(prepared.fetch(:headers).fetch("authorization")) + retained_headers << retained + prepared.merge(headers: prepared.fetch(:headers).merge("authorization" => retained)) + end + + private(:prepare_request) + end + + client = client_class.new(api_key: nil, workload_identity: @identity, http_client: @transport) + observed = nil + dispatch = lambda do |request| + payload = if request.url.host == "mtls.auth.openai.com" + { + access_token: "fake-issued-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 120 + } + else + observed = request + {id: "fake-model", created: 1, object: "model", owned_by: "openai"} + end + + OpenAI::HTTPClient::Response.new( + status: 200, + headers: {"content-type" => "application/json"}, + body: [JSON.generate(payload)] + ) + end + + @native.stub(:execute, dispatch) do + assert_equal("fake-model", client.models.retrieve("fake-model").id) + end + + assert_equal("Bearer fake-issued-token", observed.headers.fetch("authorization")) + assert_instance_of(String, observed.headers.fetch("authorization")) + assert_predicate(observed.headers, :frozen?) + assert_nil(retained_headers.fetch(0).comparisons) + end + + def test_realtime_is_rejected_before_any_x509_exchange + client = OpenAI::Client.new(api_key: nil, workload_identity: @identity, http_client: @transport) + + @native.stub(:execute, -> (_request) { flunk("Realtime must not exchange an X.509 credential") }) do + error = assert_raises(OpenAI::Errors::Error) do + client.realtime_connection_request(path: "/v1/realtime", query: {}) + end + + assert_match(/X\.509.*Realtime/, error.message) + end + end + + def test_api_key_mtls_transport_can_prepare_a_separate_realtime_connection + client = OpenAI::Client.new(api_key: "fake-realtime-key", http_client: @transport) + + @native.stub(:execute, -> (_request) { flunk("Realtime request preparation must not use HTTP transport") }) do + request = client.realtime_connection_request(path: "/v1/realtime", query: {}) + + assert_equal("Bearer fake-realtime-key", request.fetch(:headers).fetch("authorization")) + assert_equal("wss", request.fetch(:url).scheme) + end + end + + def test_x509_client_retries_transient_issuer_statuses_before_dispatching_the_api_request + [408, 409, 429, 500, 503].each do |status| + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 2, + initial_retry_delay: 0, + max_retry_delay: 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 ? x509_issuer_failure(status) : x509_issuer_success + else + api_attempts += 1 + x509_model_response + end + end + + @native.stub(:execute, dispatch) do + assert_equal("fake-model", client.models.retrieve("fake-model").id) + end + + assert_equal(2, issuer_attempts) + assert_equal(1, api_attempts) + end + end + + def test_x509_issuer_retries_honor_configured_budget_and_explicit_server_opt_out + cases = [ + [503, {}, 2, 3, OpenAI::Errors::APIError], + [503, {"x-should-retry" => "false"}, 2, 1, OpenAI::Errors::APIError], + [400, {"retry-after" => "0"}, 2, 1, OpenAI::Errors::OAuthError], + [401, {"retry-after" => "0"}, 2, 1, OpenAI::Errors::OAuthError], + [403, {"retry-after" => "0"}, 2, 1, OpenAI::Errors::OAuthError], + [404, {"x-should-retry" => "true"}, 2, 1, OpenAI::Errors::APIError] + ] + + cases.each do |status, headers, retries, expected_attempts, expected_error| + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: retries, + initial_retry_delay: 0, + max_retry_delay: 0 + ) + attempts = 0 + dispatch = lambda do |_request| + attempts += 1 + x509_issuer_failure(status, headers: headers) + end + + @native.stub(:execute, dispatch) do + error = assert_raises(expected_error) { client.models.retrieve("fake-model") } + assert_equal(status, error.status) + end + + assert_equal(expected_attempts, attempts) + end + end + + def test_x509_issuer_retries_honor_per_request_retry_budget + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 0, + initial_retry_delay: 0, + max_retry_delay: 0 + ) + issuer_attempts = 0 + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + issuer_attempts += 1 + issuer_attempts == 1 ? x509_issuer_failure(503) : x509_issuer_success + else + x509_model_response + end + end + + @native.stub(:execute, dispatch) do + result = client.models.retrieve("fake-model", request_options: {max_retries: 1}) + assert_equal("fake-model", result.id) + end + + assert_equal(2, issuer_attempts) + end + + def test_x509_issuer_retries_connection_failures_without_exceeding_its_budget + reader = Class.new do + def initialize(source) = @source = source + def read(*) = @source.read(*) + end + + [OpenAI::Errors::APIConnectionError, OpenAI::Errors::APITimeoutError].each do |error_class| + events = [] + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 1, + initial_retry_delay: 0, + max_retry_delay: 0, + on_retry: -> (event) { + events << event + raise "fake retry observer failure" + } + ) + issuer_attempts = 0 + api_attempts = 0 + source = StringIO.new("fake-sensitive-nonreplayable-upload") + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + issuer_attempts += 1 + if issuer_attempts == 1 + raise error_class.new(url: request.url, message: "fake-sensitive-network-secret") + end + + x509_issuer_success + else + api_attempts += 1 + x509_model_response + end + end + + @native.stub(:execute, dispatch) do + result = client.request(method: :post, path: "/v1/upload", body: reader.new(source)) + assert_equal("fake-model", result.fetch(:id)) + end + + assert_equal(2, issuer_attempts) + assert_equal(1, api_attempts) + assert_equal(0, source.pos) + assert_equal(1, events.length) + event = events.fetch(0) + assert_instance_of(error_class, event.error) + assert_nil(event.response) + assert_nil(event.status) + assert_equal(2, event.attempt) + assert_equal(2, event.max_attempts) + assert_equal("https://mtls.auth.openai.com/oauth/token", event.error.url.to_s) + refute_includes(event.error.message, "fake-sensitive-network-secret") + assert_nil(event.error.cause) + end + end + + def test_x509_terminal_issuer_connection_failure_is_not_retried_again_by_api_transport + events = [] + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 1, + initial_retry_delay: 0, + max_retry_delay: 0, + on_retry: -> (event) { events << event } + ) + attempts = 0 + dispatch = lambda do |request| + attempts += 1 + raise OpenAI::Errors::APIConnectionError.new(url: request.url) + end + + @native.stub(:execute, dispatch) do + assert_raises(OpenAI::Errors::APIConnectionError) { client.models.retrieve("fake-model") } + end + + assert_equal(2, attempts) + assert_equal(1, events.length) + assert_instance_of(OpenAI::Errors::APIConnectionError, events.fetch(0).error) + end + + def test_x509_issuer_connection_retry_consumes_the_shared_api_retry_budget + events = [] + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 1, + initial_retry_delay: 0, + max_retry_delay: 0, + on_retry: -> (event) { events << event } + ) + 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 + raise OpenAI::Errors::APIConnectionError.new(url: request.url) + end + + x509_issuer_success + else + api_attempts += 1 + x509_issuer_failure(503) + end + end + + @native.stub(:execute, dispatch) do + error = assert_raises(OpenAI::Errors::APIError) { client.models.retrieve("fake-model") } + assert_equal(503, error.status) + end + + assert_equal(2, issuer_attempts) + assert_equal(1, api_attempts) + assert_equal(1, events.length) + assert_instance_of(OpenAI::Errors::APIConnectionError, events.fetch(0).error) + end + + def test_x509_issuer_connection_retry_cannot_exceed_the_absolute_request_deadline + events = [] + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 1, + timeout: 0.05, + initial_retry_delay: 1, + max_retry_delay: 1, + on_retry: -> (event) { events << event } + ) + attempts = 0 + dispatch = lambda do |request| + attempts += 1 + raise OpenAI::Errors::APIConnectionError.new(url: request.url) + end + + @native.stub(:execute, dispatch) do + assert_raises(OpenAI::Errors::APITimeoutError) { client.models.retrieve("fake-model") } + end + + assert_equal(1, attempts) + assert_empty(events) + end + + def test_x509_issuer_status_retries_emit_safe_standard_callback_and_logging_events + events = [] + log_output = StringIO.new + logger = Logger.new(log_output) + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 2, + initial_retry_delay: 0, + max_retry_delay: 0, + logger: logger, + log_level: :debug, + on_retry: -> (event) { events << event } + ) + issuer_attempts = 0 + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + issuer_attempts += 1 + if issuer_attempts == 1 + x509_issuer_failure( + 429, + headers: { + "x-request-id" => "req_fake_issuer", + "retry-after" => "0", + "authorization" => "Bearer fake-sensitive-header", + "set-cookie" => "fake-sensitive-cookie" + } + ) + else + x509_issuer_success + end + else + x509_model_response + end + end + + @native.stub(:execute, dispatch) do + assert_equal("fake-model", client.models.retrieve("fake-model").id) + end + + assert_equal(1, events.length) + event = events.fetch(0) + assert_equal(2, event.attempt) + assert_equal(3, event.max_attempts) + assert_equal(0.0, event.delay) + assert_equal(429, event.status) + assert_equal("req_fake_issuer", event.request_id) + assert_equal({"x-request-id" => "req_fake_issuer", "retry-after" => "0"}, event.response.headers) + assert_nil(event.error) + assert_predicate(event, :frozen?) + log = log_output.string + assert_includes(log, "request retry") + assert_includes(log, "status=429") + refute_includes(log, "fake-sensitive-header") + refute_includes(log, "fake-sensitive-cookie") + refute_includes(log, "idp_fake") + refute_includes(log, "svc_acct_fake") + end + + def test_x509_issuer_and_api_share_one_request_retry_budget + cases = [ + [1, 1, 1, false, 2, 1], + [2, 1, 1, true, 2, 2], + [2, 1, 2, false, 2, 2], + [1, 0, 1, true, 1, 2], + [2, 2, 1, false, 3, 1] + ] + + cases.each do |max_retries, issuer_failures, api_failures, succeeds, expected_issuer, expected_api| + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: max_retries, + initial_retry_delay: 0, + max_retry_delay: 0 + ) + issuer_attempts = 0 + api_attempts = 0 + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + issuer_attempts += 1 + issuer_attempts <= issuer_failures ? x509_issuer_failure(503) : x509_issuer_success + else + api_attempts += 1 + api_attempts <= api_failures ? x509_issuer_failure(503) : x509_model_response + end + end + + @native.stub(:execute, dispatch) do + if succeeds + assert_equal("fake-model", client.models.retrieve("fake-model").id) + else + error = assert_raises(OpenAI::Errors::APIError) { client.models.retrieve("fake-model") } + assert_equal(503, error.status) + end + end + + assert_equal(expected_issuer, issuer_attempts) + assert_equal(expected_api, api_attempts) + end + end + + def test_x509_refreshes_rejected_credentials_once_after_an_api_retry + cases = [ + [1, [503, 401, 200], nil], + [1, [503, 401, 401], 401] + ] + + cases.each do |max_retries, statuses, expected_error_status| + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: max_retries, + initial_retry_delay: 0, + max_retry_delay: 0 + ) + issuer_attempts = 0 + api_authorizations = [] + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + issuer_attempts += 1 + x509_issuer_success(token: "fake-issued-token-#{issuer_attempts}") + else + api_authorizations << request.headers.fetch("authorization") + status = statuses.fetch(api_authorizations.length - 1) + status == 200 ? x509_model_response : x509_issuer_failure(status) + end + end + + @native.stub(:execute, dispatch) do + if expected_error_status + error = assert_raises(OpenAI::Errors::APIError) { client.models.retrieve("fake-model") } + assert_equal(expected_error_status, error.status) + else + assert_equal("fake-model", client.models.retrieve("fake-model").id) + end + end + + assert_equal(2, issuer_attempts) + assert_equal(statuses.length, api_authorizations.length) + assert_equal("Bearer fake-issued-token-1", api_authorizations.first) + assert_equal("Bearer fake-issued-token-2", api_authorizations.last) + end + end + + def test_nonreplayable_request_retries_issuer_without_replaying_or_reading_its_body + cases = [ + [2, nil, 408, true, 2, 1], + [2, nil, 429, true, 2, 1], + [2, nil, 503, true, 2, 1], + [2, 0, 503, false, 1, 0], + [0, 1, 503, true, 2, 1] + ] + reader_class = Class.new do + def initialize(source) = @source = source + def read(*) = @source.read(*) + end + + cases.each do |configured_retries, request_retries, status, succeeds, expected_issuer, expected_api| + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: configured_retries, + initial_retry_delay: 0, + max_retry_delay: 0 + ) + source = StringIO.new("fake-nonreplayable-upload") + issuer_attempts = 0 + api_attempts = 0 + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + issuer_attempts += 1 + issuer_attempts == 1 ? x509_issuer_failure(status) : x509_issuer_success + else + api_attempts += 1 + x509_model_response + end + end + + options = {} + options[:max_retries] = request_retries unless request_retries.nil? + + @native.stub(:execute, dispatch) do + if succeeds + result = client.request(method: :post, path: "/v1/upload", body: reader_class.new(source), options: options) + assert_equal("fake-model", result.fetch(:id)) + else + error = assert_raises(OpenAI::Errors::APIError) do + client.request(method: :post, path: "/v1/upload", body: reader_class.new(source), options: options) + end + + assert_equal(status, error.status) + end + end + + assert_equal(expected_issuer, issuer_attempts) + assert_equal(expected_api, api_attempts) + assert_equal(0, source.pos) + end + end + + def test_nonreplayable_request_never_retries_api_after_safe_issuer_retry + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 2, + initial_retry_delay: 0, + max_retry_delay: 0 + ) + reader = Class.new do + def initialize(source) = @source = source + def read(*) = @source.read(*) + end + + source = StringIO.new("fake-nonreplayable-upload") + issuer_attempts = 0 + api_attempts = 0 + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + issuer_attempts += 1 + issuer_attempts == 1 ? x509_issuer_failure(503) : x509_issuer_success + else + api_attempts += 1 + x509_issuer_failure(503) + end + end + + @native.stub(:execute, dispatch) do + error = assert_raises(OpenAI::Errors::APIError) do + client.request(method: :post, path: "/v1/upload", body: reader.new(source)) + end + + assert_equal(503, error.status) + end + + assert_equal(2, issuer_attempts) + assert_equal(1, api_attempts) + assert_equal(0, source.pos) + end + + def test_x509_issuer_retries_honor_safe_seconds_and_millisecond_headers + delays = [ + [{"retry-after" => "0.25"}, 0.25], + [{"retry-after-ms" => "125", "retry-after" => "2"}, 0.125], + [{"retry-after" => "NaN"}, 0.0], + [{"retry-after" => "-10"}, 0.0], + [{"retry-after" => "999999999999999999999"}, 5.0], + [{"retry-after-ms" => "Infinity", "retry-after" => "0.2"}, 0.2] + ] + + delays.each do |headers, expected_delay| + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 1, + initial_retry_delay: 0, + max_retry_delay: 5 + ) + issuer_attempts = 0 + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + issuer_attempts += 1 + issuer_attempts == 1 ? x509_issuer_failure(429, headers: headers) : x509_issuer_success + else + x509_model_response + end + end + + sleeps = [] + previous_sleep = Thread.current.thread_variable_get(:mock_sleep) + Thread.current.thread_variable_set(:mock_sleep, sleeps) + + @native.stub(:execute, dispatch) do + assert_equal("fake-model", client.models.retrieve("fake-model").id) + end + + assert_equal([expected_delay], sleeps) + assert_equal(2, issuer_attempts) + ensure + Thread.current.thread_variable_set(:mock_sleep, previous_sleep) + end + end + + def test_x509_issuer_retry_after_cannot_exceed_the_original_request_deadline + client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 2, + timeout: 0.05, + initial_retry_delay: 0, + max_retry_delay: 5 + ) + attempts = 0 + sleeps = [] + previous_sleep = Thread.current.thread_variable_get(:mock_sleep) + Thread.current.thread_variable_set(:mock_sleep, sleeps) + dispatch = lambda do |_request| + attempts += 1 + x509_issuer_failure(429, headers: {"retry-after" => "1"}) + end + + @native.stub(:execute, dispatch) do + error = assert_raises(OpenAI::Errors::APITimeoutError) { client.models.retrieve("fake-model") } + assert_match(/timed out during workload identity authentication/, error.message) + end + + assert_equal(1, attempts) + assert_empty(sleeps) + ensure + Thread.current.thread_variable_set(:mock_sleep, previous_sleep) + end + + def test_public_client_completes_real_mtls_exchange_and_model_request + unless ENV[WIRE_SUBPROCESS] == "1" + output, status = Open3.capture2e( + {WIRE_SUBPROCESS => "1"}, + RbConfig.ruby, + File.expand_path(__FILE__), + "--name", + name + ) + return assert_predicate(status, :success?, output) + end + + hostnames = %w[mtls.auth.openai.com mtls.api.openai.com] + pki = Harness::PKI.new(hostnames: hostnames) + issuer = Harness::MTLSServer.new( + hostname: hostnames.fetch(0), + pki: pki, + body: { + access_token: "fake-real-wire-bearer", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 120 + } + ) + api = Harness::MTLSServer.new( + hostname: hostnames.fetch(1), + pki: pki, + body: {id: "fake-model", created: 1, object: "model", owned_by: "openai"} + ) + proxy = Harness::ConnectProxy.new( + authority_ports: { + "#{issuer.hostname}:443" => issuer.local_port, + "#{api.hostname}:443" => api.local_port + }, + expected_connections: 2 + ) + identity = pki.client_identity + configured_client = OpenAI::NetHTTPClient.new(size: 1) do |connection| + Harness.configure_http_connect_proxy(connection, proxy.uri) + connection.cert_store = pki.trust_store + connection.cert = identity.certificate + connection.extra_chain_cert = [pki.intermediate_certificate] + connection.key = identity.key + end + + transport = OpenAI::Auth::X509Transport.new( + http_client: configured_client, + certificate_identity: :static, + proxy: :http_connect + ) + client = OpenAI::Client.new(api_key: nil, workload_identity: @identity, http_client: transport) + + Harness.with_proxy_environment(proxy.uri) do + model = client.models.retrieve("fake-model") + assert_equal("fake-model", model.id) + end + + configured_client.close + issuer_record = issuer.finish.fetch(0) + api_record = api.finish.fetch(0) + proxy_records = proxy.finish + + assert_equal("POST /oauth/token HTTP/1.1", issuer_record.request_line) + assert_equal("GET /v1/models/fake-model HTTP/1.1", api_record.request_line) + assert_equal(identity.certificate.to_der, issuer_record.peer_certificate.to_der) + assert_equal(identity.certificate.to_der, api_record.peer_certificate.to_der) + refute_includes(issuer_record.headers, "authorization") + assert_equal("Bearer fake-real-wire-bearer", api_record.headers.fetch("authorization")) + proxy_records.each { refute_includes(_1.headers, "authorization") } + ensure + configured_client&.close + proxy&.close + issuer&.close + api&.close + end + + private def x509_issuer_success(token: "fake-issued-token") + OpenAI::HTTPClient::Response.new( + status: 200, + headers: {"content-type" => "application/json"}, + body: JSON.generate( + access_token: token, + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 120 + ) + ) + end + + private def x509_issuer_failure(status, headers: {}) + OpenAI::HTTPClient::Response.new( + status: status, + headers: {"x-request-id" => "req_fake"}.merge(headers), + body: "" + ) + end + + private def x509_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/openai/auth/x509_lifecycle_test.rb b/test/openai/auth/x509_lifecycle_test.rb new file mode 100644 index 000000000..b11a593fd --- /dev/null +++ b/test/openai/auth/x509_lifecycle_test.rb @@ -0,0 +1,834 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +class OpenAI::Test::X509LifecycleTest < 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: "idp_fake", + service_account_id: "svc_acct_fake" + ) + @client = OpenAI::Client.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 0 + ) + end + + def teardown + @native.close + super + end + + def test_successful_requests_share_one_cached_exchange + exchanges = 0 + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + exchanges += 1 + token_response("fake-token-#{exchanges}") + else + model_response + end + end + + @native.stub(:execute, dispatch) do + assert_equal("fake-model", @client.models.retrieve("first").id) + assert_equal("fake-model", @client.models.retrieve("second").id) + end + + assert_equal(1, exchanges) + end + + def test_401_invalidates_the_actual_bearer_and_replays_only_once + exchanges = 0 + api_headers = [] + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + exchanges += 1 + token_response("fake-token-#{exchanges}") + else + api_headers << request.headers.fetch("authorization") + api_headers.one? ? unauthorized_response : model_response + end + end + + @native.stub(:execute, dispatch) do + assert_equal("fake-model", @client.models.retrieve("fake-model").id) + end + + assert_equal(["Bearer fake-token-1", "Bearer fake-token-2"], api_headers) + assert_equal(2, exchanges) + end + + def test_failed_replay_invalidates_the_second_rejected_bearer + exchanges = 0 + api_headers = [] + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + exchanges += 1 + token_response("fake-token-#{exchanges}") + else + api_headers << request.headers.fetch("authorization") + api_headers.length <= 2 ? unauthorized_response : model_response + end + end + + @native.stub(:execute, dispatch) do + assert_raises(OpenAI::Errors::AuthenticationError) { @client.models.retrieve("first") } + assert_equal("fake-model", @client.models.retrieve("second").id) + end + + assert_equal(["Bearer fake-token-1", "Bearer fake-token-2", "Bearer fake-token-3"], api_headers) + assert_equal(3, exchanges) + end + + def test_nonreplayable_request_invalidates_without_resending_its_body + exchanges = 0 + requests = [] + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + exchanges += 1 + token_response("fake-token-#{exchanges}") + else + requests << request + requests.one? ? unauthorized_response : model_response + end + end + + io = StringIO.new("fake-file") + body = Class + .new do + def initialize(source) = @source = source + def read(*) = @source.read(*) + end + .new(io) + + @native.stub(:execute, dispatch) do + assert_raises(OpenAI::Errors::AuthenticationError) do + @client.request(method: :post, path: "/v1/upload", body: body) + end + + assert_equal("fake-model", @client.models.retrieve("next").id) + end + + assert_equal(2, requests.length) + assert_equal(["Bearer fake-token-1", "Bearer fake-token-2"], requests.map { _1.headers["authorization"] }) + end + + def test_stale_invalidation_does_not_remove_a_newer_cached_generation + exchanges = 0 + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + exchanges += 1 + token_response("fake-token-#{exchanges}") + else + model_response + end + end + + @native.stub(:execute, dispatch) do + auth = @client.workload_identity_auth + assert_equal("fake-token-1", auth.get_token) + auth.invalidate_token("fake-token-1") + assert_equal("fake-token-2", auth.get_token) + auth.invalidate_token("fake-token-1") + assert_equal("fake-token-2", auth.get_token) + end + + assert_equal(2, exchanges) + end + + def test_concurrent_waiters_receive_the_real_exchange_failure + auth = @client.workload_identity_auth + refresh_started = Queue.new + release_refresh = Queue.new + leader = nil + waiter = nil + failure = OpenAI::Errors::APIError.new( + url: URI("https://mtls.auth.openai.com/oauth/token"), + status: 503, + message: "identity service unavailable" + ) + fetch = lambda do |deadline:| + refresh_started << deadline + release_refresh.pop + raise failure + end + + auth.stub(:fetch_token_from_exchange, fetch) do + leader = Thread.new { auth.get_token } + leader.report_on_exception = false + Timeout.timeout(1) { refresh_started.pop } + waiter = Thread.new { auth.get_token } + waiter.report_on_exception = false + Timeout.timeout(1) { Thread.pass until waiter.status == "sleep" } + release_refresh << true + + assert_same(failure, assert_raises(OpenAI::Errors::APIError) { leader.value }) + assert_same(failure, assert_raises(OpenAI::Errors::APIError) { waiter.value }) + end + + ensure + release_refresh&.push(true) if leader&.alive? + leader&.kill&.join if leader&.alive? + waiter&.kill&.join if waiter&.alive? + end + + def test_waiter_observes_its_joined_refresh_when_a_new_generation_starts + auth = @client.workload_identity_auth + condition = auth.instance_variable_get(:@cond_var) + original_wait = condition.method(:wait) + first_started = Queue.new + release_first = Queue.new + waiter_awakened = Queue.new + release_waiter = Queue.new + second_started = Queue.new + release_second = Queue.new + failure = OpenAI::Errors::APIError.new( + url: URI("https://mtls.auth.openai.com/oauth/token"), + status: 503, + message: "first refresh failed" + ) + leader = nil + waiter = nil + successor = nil + attempts = 0 + fetch = lambda do |deadline:| + attempts += 1 + if attempts == 1 + first_started << deadline + release_first.pop + raise failure + end + + second_started << deadline + release_second.pop + {id: "fake-second-generation-token", expires_in: 120} + end + + wait = lambda do |mutex, *arguments| + result = original_wait.call(mutex, *arguments) + if Thread.current == waiter + mutex.unlock + begin + waiter_awakened << true + release_waiter.pop + ensure + mutex.lock + end + end + + result + end + + auth.stub(:fetch_token_from_exchange, fetch) do + condition.stub(:wait, wait) do + leader = Thread.new { auth.get_token } + leader.report_on_exception = false + Timeout.timeout(2) { first_started.pop } + + waiter = Thread.new { auth.get_token } + waiter.report_on_exception = false + Timeout.timeout(2) { Thread.pass until waiter.status == "sleep" } + release_first << true + Timeout.timeout(2) { waiter_awakened.pop } + assert_same(failure, assert_raises(OpenAI::Errors::APIError) { leader.value }) + + successor = Thread.new { auth.get_token } + successor.report_on_exception = false + Timeout.timeout(2) { second_started.pop } + release_waiter << true + + joined_failure = nil + Timeout.timeout(2) do + joined_failure = assert_raises(OpenAI::Errors::APIError) { waiter.value } + end + + assert_same(failure, joined_failure) + assert(successor.alive?, "the joined waiter must not wait for the next refresh") + + release_second << true + assert_equal("fake-second-generation-token", Timeout.timeout(2) { successor.value }) + end + end + + ensure + release_first&.push(true) if leader&.alive? + release_waiter&.push(true) if waiter&.alive? + release_second&.push(true) if successor&.alive? + [leader, waiter, successor].compact.each { _1.kill.join if _1.alive? } + end + + def test_waiter_never_returns_an_expired_joined_generation_token + auth = @client.workload_identity_auth + condition = auth.instance_variable_get(:@cond_var) + original_broadcast = condition.method(:broadcast) + refresh_started = Queue.new + release_refresh = Queue.new + leader = nil + waiter = nil + fetch = lambda do |deadline:| + refresh_started << deadline + release_refresh.pop + {id: "fake-expired-generation-token", expires_in: 0.01} + end + + broadcast = lambda do + result = original_broadcast.call + sleep(0.05) + result + end + + auth.stub(:fetch_token_from_exchange, fetch) do + condition.stub(:broadcast, broadcast) do + leader = Thread.new { auth.get_token } + leader.report_on_exception = false + Timeout.timeout(2) { refresh_started.pop } + + waiter = Thread.new { auth.get_token } + waiter.report_on_exception = false + Timeout.timeout(2) { Thread.pass until waiter.status == "sleep" } + release_refresh << true + + leader_error = assert_raises(OpenAI::Errors::AuthenticationError) { leader.value } + waiter_error = assert_raises(OpenAI::Errors::AuthenticationError) { waiter.value } + assert_equal(401, leader_error.status) + assert_equal(401, waiter_error.status) + assert_equal("https://mtls.auth.openai.com/oauth/token", waiter_error.url.to_s) + end + end + + ensure + release_refresh&.push(true) if leader&.alive? + leader&.kill&.join if leader&.alive? + waiter&.kill&.join if waiter&.alive? + end + + def test_waiter_never_returns_a_rejected_and_replaced_generation_token + auth = @client.workload_identity_auth + condition = auth.instance_variable_get(:@cond_var) + original_wait = condition.method(:wait) + first_started = Queue.new + release_first = Queue.new + waiter_awakened = Queue.new + release_waiter = Queue.new + leader = nil + waiter = nil + attempts = 0 + fetch = lambda do |deadline:| + attempts += 1 + if attempts == 1 + first_started << deadline + release_first.pop + end + + {id: "fake-generation-#{attempts}", expires_in: 120} + end + + wait = lambda do |mutex, *arguments| + result = original_wait.call(mutex, *arguments) + if Thread.current == waiter + mutex.unlock + begin + waiter_awakened << true + release_waiter.pop + ensure + mutex.lock + end + end + + result + end + + auth.stub(:fetch_token_from_exchange, fetch) do + condition.stub(:wait, wait) do + leader = Thread.new { auth.get_token } + leader.report_on_exception = false + Timeout.timeout(2) { first_started.pop } + + waiter = Thread.new { auth.get_token } + waiter.report_on_exception = false + Timeout.timeout(2) { Thread.pass until waiter.status == "sleep" } + release_first << true + Timeout.timeout(2) { waiter_awakened.pop } + + rejected = Timeout.timeout(2) { leader.value } + assert_equal("fake-generation-1", rejected) + auth.invalidate_token(rejected) + replacement = auth.get_token + assert_equal("fake-generation-2", replacement) + + release_waiter << true + assert_equal(replacement, Timeout.timeout(2) { waiter.value }) + end + end + + ensure + release_first&.push(true) if leader&.alive? + release_waiter&.push(true) if waiter&.alive? + leader&.kill&.join if leader&.alive? + waiter&.kill&.join if waiter&.alive? + end + + def test_unsent_nonreplayable_waiter_reacquires_a_concurrently_invalidated_token + auth = @client.workload_identity_auth + condition = auth.instance_variable_get(:@cond_var) + original_wait = condition.method(:wait) + first_exchange_started = Queue.new + release_first_exchange = Queue.new + waiter_joined = Queue.new + waiter_awakened = Queue.new + release_waiter = Queue.new + issuer_attempts = 0 + api_requests = [] + leader = nil + waiter = nil + reader = Class.new do + def initialize(source) = @source = source + def read(*) = @source.read(*) + end + + first_source = StringIO.new("fake-first-upload") + second_source = StringIO.new("fake-second-upload") + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + issuer_attempts += 1 + if issuer_attempts == 1 + first_exchange_started << true + release_first_exchange.pop + end + + token_response("fake-token-#{issuer_attempts}") + else + api_requests << request + api_requests.length == 1 ? unauthorized_response : model_response + end + end + + wait = lambda do |mutex, *arguments| + is_waiter = Thread.current[:x509_unsent_waiter] + waiter_joined << true if is_waiter + result = original_wait.call(mutex, *arguments) + if is_waiter + mutex.unlock + begin + waiter_awakened << true + release_waiter.pop + ensure + mutex.lock + end + end + + result + end + + @native.stub(:execute, dispatch) do + condition.stub(:wait, wait) do + leader = Thread.new do + @client.request(method: :post, path: "/v1/upload-first", body: reader.new(first_source)) + end + + leader.report_on_exception = false + Timeout.timeout(2) { first_exchange_started.pop } + + waiter = Thread.new do + Thread.current[:x509_unsent_waiter] = true + @client.request(method: :post, path: "/v1/upload-second", body: reader.new(second_source)) + end + + waiter.report_on_exception = false + Timeout.timeout(2) { waiter_joined.pop } + release_first_exchange << true + Timeout.timeout(2) { waiter_awakened.pop } + + assert_raises(OpenAI::Errors::AuthenticationError) { leader.value } + release_waiter << true + assert_equal("fake-model", Timeout.timeout(2) { waiter.value }.fetch(:id)) + end + end + + assert_equal(2, issuer_attempts) + assert_equal(2, api_requests.length) + assert_equal(["Bearer fake-token-1", "Bearer fake-token-2"], api_requests.map { _1.headers["authorization"] }) + assert_equal(0, first_source.pos) + assert_equal(0, second_source.pos) + + ensure + release_first_exchange&.push(true) if leader&.alive? + release_waiter&.push(true) if waiter&.alive? + leader&.kill&.join if leader&.alive? + waiter&.kill&.join if waiter&.alive? + end + + def test_aborted_refresh_attributes_waiter_authentication_failure_to_mtls_issuer + auth = @client.workload_identity_auth + refresh_started = Queue.new + release_refresh = Queue.new + leader = nil + waiter = nil + fatal_error = Interrupt + fetch = lambda do |deadline:| + refresh_started << deadline + release_refresh.pop + raise fatal_error, "fake interrupted refresh" + end + + auth.stub(:fetch_token_from_exchange, fetch) do + leader = Thread.new { auth.get_token } + leader.report_on_exception = false + Timeout.timeout(2) { refresh_started.pop } + + waiter = Thread.new { auth.get_token } + waiter.report_on_exception = false + Timeout.timeout(2) { Thread.pass until waiter.status == "sleep" } + release_refresh << true + + assert_raises(fatal_error) { leader.value } + error = assert_raises(OpenAI::Errors::AuthenticationError) { waiter.value } + assert_equal("https://mtls.auth.openai.com/oauth/token", error.url.to_s) + assert_equal(401, error.status) + end + + ensure + release_refresh&.push(true) if leader&.alive? + leader&.kill&.join if leader&.alive? + waiter&.kill&.join if waiter&.alive? + end + + def test_request_credential_overrides_are_rejected_before_any_exchange + hostile_headers = [ + {"authorization" => "Bearer attacker-token"}, + {"x_api_key" => "fake-provider-key"}, + {"proxy_authorization" => "Basic fake-proxy"}, + {"host" => "attacker.invalid"} + ] + + @native.stub(:execute, -> (_request) { flunk("hostile headers must fail before token exchange") }) do + hostile_headers.each do |headers| + assert_raises(OpenAI::Errors::Error, ArgumentError) do + @client.models.retrieve("fake-model", request_options: {extra_headers: headers}) + end + end + end + end + + def test_post_preparation_hooks_cannot_replace_the_exchanged_bearer + client_class = Class.new(OpenAI::Client) do + private def prepare_request(request, redirect_count:, retry_count:) + prepared = super + prepared.merge(headers: prepared.fetch(:headers).merge("authorization" => "Bearer fake-replacement")) + end + end + + client = client_class.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 0 + ) + destinations = [] + dispatch = lambda do |request| + destinations << request.url.host + token_response("fake-issued-token") + end + + @native.stub(:execute, dispatch) do + error = assert_raises(OpenAI::Errors::Error) { client.models.retrieve("fake-model") } + assert_match(/cannot override the selected authorization credential/, error.message) + end + + assert_equal(["mtls.auth.openai.com"], destinations) + end + + def test_safe_post_preparation_hooks_remain_supported + client_class = Class.new(OpenAI::Client) do + private def prepare_request(request, redirect_count:, retry_count:) + prepared = super + prepared.merge(headers: prepared.fetch(:headers).merge("x-fake-trace" => "fake-value")) + end + end + + client = client_class.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 0 + ) + api_request = nil + dispatch = lambda do |request| + if request.url.host == "mtls.auth.openai.com" + token_response("fake-issued-token") + else + api_request = request + model_response + end + end + + @native.stub(:execute, dispatch) do + assert_equal("fake-model", client.models.retrieve("fake-model").id) + end + + assert_equal("Bearer fake-issued-token", api_request.headers.fetch("authorization")) + assert_equal("fake-value", api_request.headers.fetch("x-fake-trace")) + end + + def test_post_preparation_hooks_cannot_mutate_the_trusted_original_credential + client_class = Class.new(OpenAI::Client) do + private def prepare_request(request, redirect_count:, retry_count:) + prepared = super + replacement = "Bearer fake-mutated-original" + request.delete(:x509_request_context) + request.fetch(:headers)["authorization"] = replacement + prepared.merge(headers: prepared.fetch(:headers).merge("authorization" => replacement)) + end + end + + client = client_class.new( + api_key: nil, + workload_identity: @identity, + http_client: @transport, + max_retries: 0 + ) + destinations = [] + dispatch = lambda do |request| + destinations << request.url.host + token_response("fake-issued-token") + end + + @native.stub(:execute, dispatch) do + error = assert_raises(OpenAI::Errors::Error) { client.models.retrieve("fake-model") } + assert_match(/cannot override the selected authorization credential/, error.message) + end + + assert_equal(["mtls.auth.openai.com"], destinations) + end + + def test_cached_bearer_is_redacted_from_authenticator_inspection + dispatch = lambda do |request| + request.url.host == "mtls.auth.openai.com" ? token_response("fake-sensitive-token") : model_response + end + + @native.stub(:execute, dispatch) do + assert_equal("fake-model", @client.models.retrieve("fake-model").id) + end + + inspected = @client.workload_identity_auth.inspect + refute_includes(inspected, "fake-sensitive-token") + refute_includes(inspected, "idp_fake") + refute_includes(inspected, "svc_acct_fake") + end + + def test_disabled_security_schemes_dispatch_without_exchanging_or_sending_credentials + client = OpenAI::Client.new( + api_key: nil, + admin_api_key: "fake-admin-key", + workload_identity: @identity, + http_client: @transport, + max_retries: 0 + ) + observed = [] + response = model_response + dispatch = lambda do |request| + observed << request + response + end + + @native.stub(:execute, dispatch) do + client.request( + method: :get, + path: "/v1/models/fake-model", + security: {bearer_auth: false, admin_api_key_auth: false} + ) + end + + assert_equal(1, observed.length) + request = observed.fetch(0) + assert_equal("mtls.api.openai.com", request.url.host) + refute_includes(request.headers, "authorization") + end + + def test_unknown_enabled_security_schemes_are_rejected_before_issuer_or_api_dispatch + selections = [ + {bearer_auth_typo: true}, + {bearer_auth: true, bearer_auth_typo: true}, + {bearer_auth: false, admin_api_key_auth: false, bearer_auth_typo: true}, + {"bearer_auth_typo" => true}, + {"bearer_auth" => true} + ] + + @native.stub(:execute, -> (_request) { flunk("unknown security schemes must not dispatch") }) do + selections.each do |security| + error = assert_raises(ArgumentError) do + @client.request(method: :get, path: "/v1/models/fake-model", security: security) + end + + assert_match(/unsupported authentication security scheme/i, error.message) + end + end + end + + def test_unknown_disabled_security_schemes_do_not_change_unauthenticated_requests + observed = [] + dispatch = lambda do |request| + observed << request + model_response + end + + @native.stub(:execute, dispatch) do + @client.request( + method: :get, + path: "/v1/models/fake-model", + security: {bearer_auth: false, admin_api_key_auth: false, bearer_auth_typo: false} + ) + end + + assert_equal(1, observed.length) + refute_includes(observed.fetch(0).headers, "authorization") + end + + def test_disabled_security_schemes_cannot_inject_an_authorization_override + client = OpenAI::Client.new( + api_key: nil, + admin_api_key: "fake-admin-key", + workload_identity: @identity, + http_client: @transport, + max_retries: 0 + ) + + @native.stub(:execute, -> (_request) { flunk("unauthenticated requests cannot inject credentials") }) do + error = assert_raises(OpenAI::Errors::Error) do + client.request( + method: :get, + path: "/v1/models/fake-model", + headers: {"authorization" => "Bearer fake-injected-token"}, + security: {bearer_auth: false, admin_api_key_auth: false} + ) + end + + assert_match(/cannot override the selected authorization credential/, error.message) + end + end + + def test_selected_security_schemes_cannot_be_replaced_by_another_configured_credential + client = OpenAI::Client.new( + api_key: nil, + admin_api_key: "fake-admin-key", + workload_identity: @identity, + http_client: @transport, + max_retries: 0 + ) + disabled = {bearer_auth: false, admin_api_key_auth: false} + bearer_only = {bearer_auth: true, admin_api_key_auth: false} + admin_only = {bearer_auth: false, admin_api_key_auth: true} + both = {bearer_auth: true, admin_api_key_auth: true} + overrides = [ + [disabled, "Bearer fake-admin-key"], + [disabled, "Bearer workload-identity-auth"], + [bearer_only, "Bearer fake-admin-key"], + [admin_only, "Bearer workload-identity-auth"], + [both, "Bearer fake-admin-key"], + [nil, "Bearer fake-admin-key"] + ] + + @native.stub(:execute, -> (_request) { flunk("incorrect security scheme must fail before token exchange") }) do + overrides.each do |security, authorization| + error = assert_raises(OpenAI::Errors::Error) do + options = { + method: :get, + path: "/v1/models/fake-model", + headers: {"authorization" => authorization} + } + options[:security] = security unless security.nil? + client.request(**options) + end + + assert_match(/cannot override the selected authorization credential/, error.message) + end + end + end + + def test_explicit_bearer_security_uses_x509_instead_of_an_available_admin_credential + client = OpenAI::Client.new( + api_key: nil, + admin_api_key: "fake-admin-key", + workload_identity: @identity, + http_client: @transport, + max_retries: 0 + ) + destinations = [] + dispatch = lambda do |request| + destinations << request + request.url.host == "mtls.auth.openai.com" ? token_response("fake-issued-token") : model_response + end + + @native.stub(:execute, dispatch) do + client.request( + method: :get, + path: "/v1/models/fake-model", + security: {bearer_auth: true, admin_api_key_auth: false} + ) + end + + assert_equal(["mtls.auth.openai.com", "mtls.api.openai.com"], destinations.map { _1.url.host }) + assert_equal("Bearer fake-issued-token", destinations.fetch(1).headers.fetch("authorization")) + end + + def test_separate_admin_bearer_is_preserved_without_token_exchange + client = OpenAI::Client.new( + api_key: nil, + admin_api_key: "fake-admin-key", + workload_identity: @identity, + http_client: @transport + ) + observed = nil + response = model_response + dispatch = lambda do |request| + observed = request + response + end + + @native.stub(:execute, dispatch) do + client.request( + method: :get, + path: "/v1/models/fake-model", + security: {admin_api_key_auth: true} + ) + end + + assert_equal("mtls.api.openai.com", observed.url.host) + assert_equal("Bearer fake-admin-key", observed.headers.fetch("authorization")) + end + + private def token_response(token) + OpenAI::HTTPClient::Response.new( + status: 200, + headers: {}, + body: JSON.generate( + access_token: token, + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 120 + ) + ) + end + + private def unauthorized_response + OpenAI::HTTPClient::Response.new( + status: 401, + headers: {"content-type" => "application/json"}, + body: JSON.generate(error: {message: "invalid authentication"}) + ) + 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/openai/gem_packaging_test.rb b/test/openai/gem_packaging_test.rb index 42e8bdef5..ec62b95e1 100644 --- a/test/openai/gem_packaging_test.rb +++ b/test/openai/gem_packaging_test.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true require "rubygems/package" +require "open3" +require "rbconfig" require "tmpdir" require "uri" @@ -37,4 +39,48 @@ def test_packages_every_relative_readme_link assert_empty(linked_guides - package.spec.extra_rdoc_files, "README guides are missing from RDoc") end end + + def test_installed_gem_completes_real_x509_issuer_and_api_handshakes + specification = Gem::Specification.load(File.expand_path("../../openai.gemspec", __dir__)) + smoke_script = File.expand_path("support/x509_installed_gem_smoke.rb", __dir__) + + Dir.mktmpdir("openai-x509-installed-gem") do |directory| + gem_file = File.join(directory, "openai.gem") + install_directory = File.join(directory, "install") + Gem::Package.build(specification, false, false, gem_file) + install_output, install_status = Open3.capture2e( + RbConfig.ruby, + "-S", + "gem", + "install", + "--local", + "--ignore-dependencies", + "--no-document", + "--install-dir", + install_directory, + gem_file + ) + assert_predicate(install_status, :success?, install_output) + + environment = { + "GEM_HOME" => install_directory, + "GEM_PATH" => ([install_directory] + Gem.path).join(File::PATH_SEPARATOR), + "OPENAI_X509_EXPECTED_GEM_ROOT" => install_directory, + "OPENAI_API_KEY" => nil, + "OPENAI_ADMIN_KEY" => nil, + "OPENAI_BASE_URL" => nil, + "OPENAI_CUSTOM_HEADERS" => nil, + "OPENAI_LOG" => nil, + "OPENAI_ORG_ID" => nil, + "OPENAI_PROJECT_ID" => nil, + "OPENAI_WEBHOOK_SECRET" => nil, + "BUNDLE_GEMFILE" => nil, + "RUBYOPT" => nil, + "RUBYLIB" => nil + } + output, status = Open3.capture2e(environment, RbConfig.ruby, smoke_script, chdir: directory) + assert_predicate(status, :success?, output) + assert_includes(output, "installed gem X.509 issuer/API mTLS verification passed") + end + end end diff --git a/test/openai/support/x509_installed_gem_smoke.rb b/test/openai/support/x509_installed_gem_smoke.rb new file mode 100644 index 000000000..3bb9e13a7 --- /dev/null +++ b/test/openai/support/x509_installed_gem_smoke.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +# This runs in an isolated Ruby process with only the built gem on its SDK load +# path. The source checkout supplies ephemeral test PKI, never SDK classes. +expected_root = File.realpath(ENV.fetch("OPENAI_X509_EXPECTED_GEM_ROOT")) +installed_specifications = Gem::Specification.find_all_by_name("openai").select do |specification| + File.realpath(specification.base_dir) == expected_root +end + +unless installed_specifications.one? + raise "Expected exactly one OpenAI gem in the isolated installation" +end + +installed_specifications.fetch(0).activate +require "openai" + +loaded_root = File.realpath(Gem.loaded_specs.fetch("openai").full_gem_path) +unless loaded_root.start_with?("#{expected_root}#{File::SEPARATOR}") + raise "The smoke process loaded an openai gem outside its isolated installation" +end + +module OpenAI + module Test + end +end + +require_relative "mtls_wire_harness" + +harness = OpenAI::Test::MTLSWireHarness +hostnames = %w[mtls.auth.openai.com mtls.api.openai.com] +pki = harness::PKI.new(hostnames: hostnames) +issuer = harness::MTLSServer.new( + hostname: hostnames.fetch(0), + pki: pki, + body: { + access_token: "fake-installed-gem-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 120 + } +) +api = harness::MTLSServer.new( + hostname: hostnames.fetch(1), + pki: pki, + body: { + object: "list", + data: [{id: "fake-packaged-model", created: 1, object: "model", owned_by: "openai"}] + } +) +proxy = harness::ConnectProxy.new( + authority_ports: { + "#{issuer.hostname}:443" => issuer.local_port, + "#{api.hostname}:443" => api.local_port + }, + expected_connections: 2 +) +certificate = pki.client_identity +native = OpenAI::NetHTTPClient.new(size: 1) do |connection| + harness.configure_http_connect_proxy(connection, proxy.uri) + connection.cert_store = pki.trust_store + connection.cert = certificate.certificate + connection.extra_chain_cert = [pki.intermediate_certificate] + connection.key = certificate.key +end + +transport = OpenAI::Auth::X509Transport.new( + http_client: native, + certificate_identity: :static, + proxy: :http_connect +) +identity = OpenAI::Auth::X509WorkloadIdentity.new( + identity_provider_id: "idp_fake_packaged", + service_account_id: "svc_acct_fake_packaged" +) +client = OpenAI::Client.new(api_key: nil, workload_identity: identity, http_client: transport) + +begin + harness.with_proxy_environment(proxy.uri) do + result = client.models.list.data.first + raise "Unexpected installed-gem API result" unless result.id == "fake-packaged-model" + end + + native.close + issuer_record = issuer.finish.fetch(0) + api_record = api.finish.fetch(0) + proxy_records = proxy.finish + + expected_identity = certificate.certificate.to_der + unless issuer_record.peer_certificate.to_der == expected_identity && + api_record.peer_certificate.to_der == expected_identity + raise "The installed gem did not present the expected certificate on both TLS legs" + end + + raise "Issuer received an API credential" if issuer_record.headers.key?("authorization") + unless api_record.headers["authorization"] == "Bearer fake-installed-gem-token" + raise "The installed gem did not present the exchanged bearer at the API" + end + + if proxy_records.any? { _1.headers.key?("authorization") } + raise "The installed gem leaked an API bearer to the CONNECT proxy" + end + + puts("installed gem X.509 issuer/API mTLS verification passed") +ensure + native&.close + proxy&.close + issuer&.close + api&.close +end diff --git a/test/openai/x509_workload_identity_example_test.rb b/test/openai/x509_workload_identity_example_test.rb new file mode 100644 index 000000000..0f424d705 --- /dev/null +++ b/test/openai/x509_workload_identity_example_test.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require "open3" +require "rbconfig" + +require_relative "test_helper" + +class OpenAI::Test::X509WorkloadIdentityExampleTest < Minitest::Test + extend Minitest::Serial + + EXAMPLE_PATH = File.expand_path("../../examples/x509_workload_identity.rb", __dir__) + SENSITIVE_VALUES = %w[ + fake-sensitive-certificate-path + fake-sensitive-key-path + fake-sensitive-provider + fake-sensitive-service-account + fake-sensitive-access-token + fake-sensitive-api-response + ] + .freeze + + def test_certificate_setup_failures_do_not_expose_secret_paths_or_backtraces + stdout, stderr, status = Open3.capture3( + {"OPENAI_CLIENT_CERTIFICATE_CHAIN" => "/missing/fake-sensitive-certificate-path"}, + RbConfig.ruby, + EXAMPLE_PATH + ) + + refute(status.success?) + assert_empty(stdout) + assert_equal("[x509] Errno::ENOENT\n", stderr) + assert_redacted(stderr) + end + + def test_malformed_origins_are_sanitized_after_certificate_setup + origin = "https://[fake-sensitive-api-origin-token" + stdout, stderr, status, client = run_example(origin: origin) + + assert_equal(1, status) + assert_empty(stdout) + assert_nil(client) + assert_equal("[x509] URI::InvalidURIError\n", stderr) + assert_redacted(stderr, origin) + end + + def test_ambient_debug_logging_is_disabled_for_the_real_x509_client + stdout, stderr, status, client = run_example + + assert_equal(0, status) + assert_equal("[x509] real issuer exchange and mTLS API request succeeded\n", stdout) + assert_empty(stderr) + assert_equal(:off, client.log_level) + assert_nil(client.logger) + end + + def test_api_failures_preserve_status_without_leaking_response_or_cleanup_errors + stdout, stderr, status, client = run_example(api_status: 400, fail_cleanup: true) + + assert_equal(1, status) + assert_empty(stdout) + assert_equal(:off, client.log_level) + assert_equal("[x509] OpenAI::Errors::BadRequestError (HTTP 400)\n", stderr) + assert_redacted(stderr, "fake-sensitive-cleanup-error") + end + + private + + def run_example(origin: "https://mtls.api.openai.com", api_status: 200, fail_cleanup: false) + certificate = Minitest::Mock.new + certificate.expect(:check_private_key, true, [:fake_private_key]) + certificate.expect(:not_before, Time.now - 60) + certificate.expect(:not_after, Time.now + 60) + + native = OpenAI::NetHTTPClient.new + client_constructor = OpenAI::Client.method(:new) + constructed_client = nil + create_client = -> (**options) { constructed_client = client_constructor.call(**options) } + create_native = -> (*_arguments, &_configuration) { native } + execute = -> (request) { response_for(request, api_status) } + close = -> { + raise IOError, "fake-sensitive-cleanup-error" if fail_cleanup + } + environment = { + "OPENAI_CLIENT_CERTIFICATE_CHAIN" => "fake-sensitive-certificate-path", + "OPENAI_CLIENT_KEY" => "fake-sensitive-key-path", + "IDENTITY_PROVIDER_ID" => "fake-sensitive-provider", + "SERVICE_ACCOUNT_ID" => "fake-sensitive-service-account", + "OPENAI_X509_API_ORIGIN" => origin, + "OPENAI_LOG" => "debug" + } + previous_environment = environment.keys.to_h { |name| [name, ENV[name]] } + environment.each { |name, value| ENV[name] = value } + + exit_status = 0 + stdout, stderr = capture_io do + File.stub(:binread, "fake-certificate-or-key-pem") do + OpenSSL::X509::Certificate.stub(:load, [certificate]) do + OpenSSL::PKey.stub(:read, :fake_private_key) do + native.stub(:execute, execute) do + native.stub(:close, close) do + OpenAI::NetHTTPClient.stub(:new, create_native) do + OpenAI::Client.stub(:new, create_client) do + load(EXAMPLE_PATH, true) + rescue SystemExit => error + exit_status = error.status + end + end + end + end + end + end + end + end + + certificate.verify + [stdout, stderr, exit_status, constructed_client] + ensure + previous_environment&.each do |name, value| + value.nil? ? ENV.delete(name) : ENV[name] = value + end + + native&.close + end + + def response_for(request, api_status) + if request.url.host == "mtls.auth.openai.com" + body = { + access_token: "fake-sensitive-access-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "Bearer", + expires_in: 120 + } + status = 200 + elsif api_status == 200 + body = { + object: "list", + data: [{id: "fake-model", object: "model", created: 1, owned_by: "openai"}] + } + status = 200 + else + body = {error: {message: "fake-sensitive-api-response", type: "invalid_request_error"}} + status = api_status + end + + OpenAI::HTTPClient::Response.new( + status: status, + headers: {"content-type" => "application/json"}, + body: JSON.generate(body) + ) + end + + def assert_redacted(output, *additional_values) + (SENSITIVE_VALUES + additional_values).each do |value| + refute_includes(output, value) + end + end +end diff --git a/test/scripts/live_smoke_test.rb b/test/scripts/live_smoke_test.rb new file mode 100644 index 000000000..8ef2cad21 --- /dev/null +++ b/test/scripts/live_smoke_test.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "minitest/mock" +require "open3" +require "rbconfig" +require "stringio" +require "yaml" + +require_relative "../../scripts/live-smoke" + +class LiveSmokeTest < Minitest::Test + Page = Data.define(:data) + Response = Data.define(:output_text) + + MODEL = "fake-smoke-model" + PARAMETERS = {model: MODEL, input: "Reply with exactly OK.", max_output_tokens: 32}.freeze + + def test_exercises_model_listing_regular_responses_and_completed_streaming + stream = Minitest::Mock.new + stream.expect(:get_output_text, "fake streamed text") + responses = Minitest::Mock.new + responses.expect(:create, Response.new("fake response text"), [], **PARAMETERS) + responses.expect(:stream, stream, [], **PARAMETERS) + models = Minitest::Mock.new + models.expect(:list, Page.new([Object.new])) + client = Minitest::Mock.new + client.expect(:models, models) + client.expect(:responses, responses) + client.expect(:responses, responses) + output = StringIO.new + + OpenAILiveSmoke::Runner.new(client: client, model: MODEL, output: output).run + + [client, models, responses, stream].each(&:verify) + assert_includes(output.string, "authenticated model listing succeeded") + assert_includes(output.string, "non-streaming response succeeded") + assert_includes(output.string, "streaming response completed") + refute_includes(output.string, "fake response text") + refute_includes(output.string, "fake streamed text") + end + + def test_rejects_an_empty_model_listing_before_model_requests + models = Minitest::Mock.new + models.expect(:list, Page.new([])) + client = Minitest::Mock.new + client.expect(:models, models) + + error = assert_raises(OpenAILiveSmoke::Failure) do + OpenAILiveSmoke::Runner.new(client: client, model: MODEL, output: StringIO.new).run + end + + assert_equal("model listing returned no accessible models", error.message) + [client, models].each(&:verify) + end + + def test_rejects_an_empty_non_streaming_response + models = Minitest::Mock.new + models.expect(:list, Page.new([Object.new])) + responses = Minitest::Mock.new + responses.expect(:create, Response.new(" \n"), [], **PARAMETERS) + client = Minitest::Mock.new + client.expect(:models, models) + client.expect(:responses, responses) + + error = assert_raises(OpenAILiveSmoke::Failure) do + OpenAILiveSmoke::Runner.new(client: client, model: MODEL, output: StringIO.new).run + end + + assert_equal("response creation returned no output text", error.message) + [client, models, responses].each(&:verify) + end + + def test_rejects_an_empty_completed_response_stream + stream = Minitest::Mock.new + stream.expect(:get_output_text, "") + responses = Minitest::Mock.new + responses.expect(:create, Response.new("fake response text"), [], **PARAMETERS) + responses.expect(:stream, stream, [], **PARAMETERS) + models = Minitest::Mock.new + models.expect(:list, Page.new([Object.new])) + client = Minitest::Mock.new + client.expect(:models, models) + client.expect(:responses, responses) + client.expect(:responses, responses) + + error = assert_raises(OpenAILiveSmoke::Failure) do + OpenAILiveSmoke::Runner.new(client: client, model: MODEL, output: StringIO.new).run + end + + assert_equal("response stream returned no completed output text", error.message) + [client, models, responses, stream].each(&:verify) + end + + def test_unexpected_errors_never_print_sensitive_exception_messages + sensitive_message = "FAKE_PRIVATE_RESPONSE sk-fake-test-token" + models = Minitest::Mock.new + models.expect(:list, nil) { raise StandardError, sensitive_message } + client = Minitest::Mock.new + client.expect(:models, models) + output = StringIO.new + error_output = StringIO.new + + success = OpenAILiveSmoke.run_cli( + client: client, + model: MODEL, + output: output, + error_output: error_output + ) + + refute(success) + assert_equal("[live-smoke] StandardError\n", error_output.string) + refute_includes(output.string, sensitive_message) + refute_includes(error_output.string, sensitive_message) + [client, models].each(&:verify) + end + + def test_api_errors_report_only_the_status_and_exception_class + sensitive_message = "FAKE_PRIVATE_RESPONSE fake-bearer-token" + failure = OpenAI::Errors::APIError.new( + url: URI("https://api.openai.com/v1/models?secret=fake-secret"), + status: 403, + message: sensitive_message + ) + 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] OpenAI::Errors::APIError (HTTP 403)\n", error_output.string) + refute_includes(error_output.string, sensitive_message) + refute_includes(error_output.string, "fake-secret") + [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 = { + "OPENAI_API_KEY" => "sk-fake-live-smoke-test", + "OPENAI_BASE_URL" => sensitive_base_url + } + path = File.expand_path("../../scripts/live-smoke.rb", __dir__) + + output, error_output, status = Open3.capture3(environment, RbConfig.ruby, path) + + refute(status.success?) + assert_empty(output) + assert_equal("[live-smoke] URI::InvalidURIError\n", error_output) + refute_includes(error_output, sensitive_base_url) + refute_includes(error_output, environment.fetch("OPENAI_API_KEY")) + end + + def test_cli_disables_sdk_debug_logs_even_when_enabled_in_the_environment + sensitive_body = "fake-sensitive-response-body" + response = OpenAI::HTTPClient::Response.new( + status: 400, + headers: {"content-type" => "application/json"}, + body: JSON.generate(error: {message: sensitive_body}) + ) + transport = OpenAI::NetHTTPClient.new + requests = [] + dispatch = lambda do |request| + requests << request + response + end + + constructor = OpenAI::Client.method(:new) + constructed = nil + factory = lambda do |**options| + constructed = constructor.call( + api_key: "sk-fake-live-smoke-test", + http_client: transport, + max_retries: 0, + **options + ) + end + + previous_log_level = ENV["OPENAI_LOG"] + ENV["OPENAI_LOG"] = "debug" + + output, error_output = capture_io do + transport.stub(:execute, dispatch) do + OpenAI::Client.stub(:new, factory) do + refute(OpenAILiveSmoke.run_cli(model: MODEL, output: $stdout, error_output: $stderr)) + end + end + end + + assert_equal("", output) + assert_equal("[live-smoke] OpenAI::Errors::BadRequestError (HTTP 400)\n", error_output) + refute_includes(error_output, sensitive_body) + assert_equal(:off, constructed.log_level) + assert_nil(constructed.logger) + assert_instance_of(OpenAI::HTTPClient::Request, requests.fetch(0)) + ensure + transport&.close + previous_log_level.nil? ? ENV.delete("OPENAI_LOG") : ENV["OPENAI_LOG"] = previous_log_level + end + + def test_manual_workflow_preserves_protected_environment_and_secret_isolation + path = File.expand_path("../../.github/workflows/live-smoke.yml", __dir__) + workflow = YAML.safe_load_file(path, aliases: false) + trigger = workflow.fetch("on", workflow[true]) + jobs = workflow.fetch("jobs") + api_job = jobs.fetch("live-smoke") + x509_job = jobs.fetch("x509-live-smoke") + api_steps = api_job.fetch("steps") + x509_steps = x509_job.fetch("steps") + api_step = api_steps.find { _1["name"] == "Smoke-test authenticated API requests and streaming" } + x509_step = x509_steps.find { _1["name"] == "Smoke-test enrolled X.509 workload identity" } + + assert_equal(["workflow_dispatch"], trigger.keys) + inputs = trigger.fetch("workflow_dispatch").fetch("inputs") + assert_equal(["include_x509"], inputs.keys) + assert_equal(false, inputs.fetch("include_x509").fetch("default")) + assert_equal({}, workflow.fetch("permissions")) + assert_equal(%w[live-smoke x509-live-smoke], jobs.keys) + assert_equal("ci", api_job.fetch("environment")) + assert_equal("x509-live-smoke", x509_job.fetch("environment")) + assert_equal("live-smoke", x509_job.fetch("needs")) + assert_includes(x509_job.fetch("if"), "inputs.include_x509") + + [api_job, x509_job].each do |job| + assert_equal({"contents" => "read"}, job.fetch("permissions")) + assert_includes(job.fetch("if"), "github.ref == 'refs/heads/main'") + assert_includes(job.fetch("if"), "github.repository == 'openai/openai-ruby'") + steps = job.fetch("steps") + assert_equal(false, steps.fetch(0).fetch("with").fetch("persist-credentials")) + assert_equal("${{ github.sha }}", steps.fetch(0).fetch("with").fetch("ref")) + assert(steps.none? { _1["uses"].to_s.include?("upload-artifact") }) + steps.filter_map { _1["uses"] }.each { assert_match(%r{@[0-9a-f]{40}\z}, _1) } + end + + assert_equal(["OPENAI_API_KEY"], api_step.fetch("env").keys) + refute(x509_step.fetch("env").key?("OPENAI_API_KEY")) + assert_equal( + %w[ + OPENAI_CLIENT_KEY_PASSPHRASE + OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM + OPENAI_X509_CLIENT_PRIVATE_KEY_PEM + OPENAI_X509_IDENTITY_PROVIDER_ID + OPENAI_X509_PROXY_MODE + OPENAI_X509_SERVICE_ACCOUNT_ID + ], + x509_step.fetch("env").keys.sort + ) + assert_equal("direct", x509_step.fetch("env").fetch("OPENAI_X509_PROXY_MODE")) + assert_includes(x509_step.fetch("run"), "umask 077") + assert_includes(x509_step.fetch("run"), "trap 'rm -f") + assert_includes( + x509_step.fetch("run"), + "unset OPENAI_X509_CLIENT_CERTIFICATE_CHAIN_PEM OPENAI_X509_CLIENT_PRIVATE_KEY_PEM" + ) + end +end