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