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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions .github/workflows/live-smoke.yml
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,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])

Expand Down
3 changes: 3 additions & 0 deletions examples/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
86 changes: 86 additions & 0 deletions examples/x509_workload_identity.rb
Original file line number Diff line number Diff line change
@@ -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")
3 changes: 3 additions & 0 deletions lib/openai.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@
require_relative "openai/internal/request_options_scope"
require_relative "openai/internal/vector_store_file_uploader"
require_relative "openai/net_http_client"
require_relative "openai/auth/x509_transport"
require_relative "openai/auth/x509_workload_identity"
require_relative "openai/auth/x509_token_exchange"
require_relative "openai/provider"
require_relative "openai/internal/provider"
require_relative "openai/providers/azure"
Expand Down
Loading