diff --git a/README.md b/README.md index 00013c00a5..b5774f4c94 100644 --- a/README.md +++ b/README.md @@ -899,6 +899,128 @@ You can also customize the client on a per-request basis by using `with_options( client.with_options(http_client=DefaultHttpxClient(...)) ``` +#### Mutual TLS + +Before configuring a client, review the +[OpenAI Mutual TLS Beta Program](https://help.openai.com/en/articles/10876024-openai-mutual-tls-beta-program) +for enrollment, currently supported endpoints, and certificate requirements. + +For API-key authenticated HTTP requests that require mutual TLS (mTLS), configure +a native [`ssl.SSLContext`](https://docs.python.org/3/library/ssl.html#ssl.SSLContext) +and pass it through the custom HTTP client: + +```python +import os +import ssl + +from openai import OpenAI, DefaultHttpxClient + +# Server trust is configured independently. Without `cafile`, this uses the +# operating system's normal trusted certificate authorities. +ssl_context = ssl.create_default_context( + cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), +) +ssl_context.load_cert_chain( + # This PEM must contain the leaf certificate first, followed by every + # intermediate certificate needed to reach the server's trust anchor. + certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], + keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], + password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), +) + +client = OpenAI( + api_key=os.environ["OPENAI_API_KEY"], + # A custom HTTP client does not tell the SDK that mTLS is configured, so + # select the mTLS endpoint explicitly. Preserve an EU or custom override. + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + # A client certificate belongs to the HTTP client, not the base URL. + # Disable redirects so it cannot follow a response to another origin. + http_client=DefaultHttpxClient( + verify=ssl_context, + follow_redirects=False, + ), +) +``` + +The async configuration is equivalent: + +```python +import os +import ssl + +from openai import AsyncOpenAI, DefaultAsyncHttpxClient + +ssl_context = ssl.create_default_context( + cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), +) +ssl_context.load_cert_chain( + certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], + keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], + password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), +) + +client = AsyncOpenAI( + api_key=os.environ["OPENAI_API_KEY"], + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + http_client=DefaultAsyncHttpxClient( + verify=ssl_context, + follow_redirects=False, + ), +) +``` + +Experimental HTTPX2 uses the same native `SSLContext`. Install the optional +extra with `pip install 'openai[httpx2]'`, then use `DefaultHttpx2Client` or +`DefaultAsyncHttpx2Client` in place of the corresponding HTTPX client above: + +```python +from openai import OpenAI, DefaultHttpx2Client + +client = OpenAI( + api_key=os.environ["OPENAI_API_KEY"], + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + http_client=DefaultHttpx2Client( + verify=ssl_context, + follow_redirects=False, + ), +) +``` + +See the complete [sync HTTPX2](examples/mtls_httpx2.py) and +[async HTTPX2](examples/mtls_httpx2_async.py) examples. + +The certificate-bearing HTTP client is transport-wide. Dedicate it to the +selected mTLS origin; do not reuse it for other services or pass it through +`with_options()` with a different `base_url`. If redirects are required, add an +HTTPX request hook that rejects requests whose scheme, host, or port differs +from the configured mTLS origin before enabling `follow_redirects`. + +`SSLContext.load_cert_chain()` raises during setup for unreadable or malformed +files and for a private key that does not match the leaf certificate. Certificate +expiry, key usage, extended key usage, SAN, and trust policy remain TLS server +decisions. OpenAI does not fetch missing intermediates through AIA, so provide a +complete, leaf-first client-chain PEM. Intermediate-chain support is currently +enabled by request. Until it is enabled for your organization, use a client leaf +certificate directly signed by the uploaded CA. + +For certificate rotation, build a new `SSLContext`, HTTP client, and `OpenAI` or +`AsyncOpenAI` client. This creates a fresh connection pool; close the old SDK +client after its in-flight requests finish. Do not assume existing TLS +connections will renegotiate. + +This recipe applies to ordinary API-key HTTP traffic. It does not implement +certificate-only X.509 workload identity, token exchange, or Realtime WebSocket +mTLS. + ### Managing HTTP resources By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. diff --git a/examples/mtls_httpx.py b/examples/mtls_httpx.py new file mode 100644 index 0000000000..591f6dd25f --- /dev/null +++ b/examples/mtls_httpx.py @@ -0,0 +1,30 @@ +#!/usr/bin/env -S rye run python + +import os +import ssl + +from openai import OpenAI, DefaultHttpxClient + +ssl_context = ssl.create_default_context( + cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), +) +ssl_context.load_cert_chain( + # Leaf certificate first; if intermediate-chain support is enabled, follow + # it with all required intermediates. + certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], + keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], + password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), +) + +with OpenAI( + api_key=os.environ["OPENAI_API_KEY"], + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + http_client=DefaultHttpxClient( + verify=ssl_context, + follow_redirects=False, + ), +) as client: + print(client.files.list()) diff --git a/examples/mtls_httpx2.py b/examples/mtls_httpx2.py new file mode 100644 index 0000000000..0b192d8644 --- /dev/null +++ b/examples/mtls_httpx2.py @@ -0,0 +1,30 @@ +#!/usr/bin/env -S rye run python + +import os +import ssl + +from openai import OpenAI, DefaultHttpx2Client + +ssl_context = ssl.create_default_context( + cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), +) +ssl_context.load_cert_chain( + # Leaf certificate first; if intermediate-chain support is enabled, follow + # it with all required intermediates. + certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], + keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], + password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), +) + +with OpenAI( + api_key=os.environ["OPENAI_API_KEY"], + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + http_client=DefaultHttpx2Client( + verify=ssl_context, + follow_redirects=False, + ), +) as client: + print(client.files.list()) diff --git a/examples/mtls_httpx2_async.py b/examples/mtls_httpx2_async.py new file mode 100644 index 0000000000..d35db5be66 --- /dev/null +++ b/examples/mtls_httpx2_async.py @@ -0,0 +1,36 @@ +#!/usr/bin/env -S rye run python + +import os +import ssl +import asyncio + +from openai import AsyncOpenAI, DefaultAsyncHttpx2Client + + +async def main() -> None: + ssl_context = ssl.create_default_context( + cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), + ) + ssl_context.load_cert_chain( + # Leaf certificate first; if intermediate-chain support is enabled, + # follow it with all required intermediates. + certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], + keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], + password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), + ) + + async with AsyncOpenAI( + api_key=os.environ["OPENAI_API_KEY"], + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + http_client=DefaultAsyncHttpx2Client( + verify=ssl_context, + follow_redirects=False, + ), + ) as client: + print(await client.files.list()) + + +asyncio.run(main()) diff --git a/examples/mtls_httpx_async.py b/examples/mtls_httpx_async.py new file mode 100644 index 0000000000..e4d262c23e --- /dev/null +++ b/examples/mtls_httpx_async.py @@ -0,0 +1,36 @@ +#!/usr/bin/env -S rye run python + +import os +import ssl +import asyncio + +from openai import AsyncOpenAI, DefaultAsyncHttpxClient + + +async def main() -> None: + ssl_context = ssl.create_default_context( + cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), + ) + ssl_context.load_cert_chain( + # Leaf certificate first; if intermediate-chain support is enabled, + # follow it with all required intermediates. + certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], + keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], + password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), + ) + + async with AsyncOpenAI( + api_key=os.environ["OPENAI_API_KEY"], + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + http_client=DefaultAsyncHttpxClient( + verify=ssl_context, + follow_redirects=False, + ), + ) as client: + print(await client.files.list()) + + +asyncio.run(main()) diff --git a/tests/fixtures/mtls/client-chain.pem b/tests/fixtures/mtls/client-chain.pem new file mode 100644 index 0000000000..ae81b000c5 --- /dev/null +++ b/tests/fixtures/mtls/client-chain.pem @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIUYfX+FG1H1GIF9JGcTGENkRmmO0AwDQYJKoZIhvcNAQEL +BQAwLzEtMCsGA1UEAwwkb3BlbmFpLXB5dGhvbi1tdGxzLXRlc3QtaW50ZXJtZWRp +YXRlMCAXDTI2MDcyODE5Mjc1MloYDzIxMjYwNzA0MTkyNzUyWjApMScwJQYDVQQD +DB5vcGVuYWktcHl0aG9uLW10bHMtdGVzdC1jbGllbnQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDKfqXwVVGGA+o6tk1pJi8yyYpApRadLwTO643ETasj +7x41w9MH1jznEaZB+UZkm8IO/tcRF11+YiKqY2gFS0bthafjDCwB4+Ts6v3/GLfz +kCi6FgMFoOhNs5QvQiM5hpSfUOyHNf0yPsMxxqb8K0+YF0yMmryc+cVTwD31ZyKw +gQ24zWNR9HOsHAqHCo0+SDzcBufo6Ps+BstJvxkp1DDRFTvvSqhVZ7w5WrP/CfzP +qvKXTbuv0fGrFCakhNeyRN9y7ygP0YOODglnduIV9al4N9k/8axAZsQjjnxMV+5l +T/FLlqSQTmH9HHsjTQoOKyqOhHk8JSP+QctAotLWYK+dAgMBAAGjgY4wgYswDAYD +VR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwIw +FgYDVR0RBA8wDYILY2xpZW50LnRlc3QwHQYDVR0OBBYEFJELELi4PoIeS0pnxF9s +EoToL3etMB8GA1UdIwQYMBaAFLZ4Gtv+mnYi/DpIUXkfprFU6HbEMA0GCSqGSIb3 +DQEBCwUAA4IBAQAp7S4SWpxs6GPmBBT8Nu6bXmlSdjtLNZ2C4sG9BBY3uACseN2B +6G149VLxLMaWPHd/L46SYAhAkN/zj7LnIrygiFW5eDZCxvYCBEk75U5zTpk9SQL5 +bQqsR0RH9NxJkoKIUkQjrnxD+u7C2RBF3sE5KZmTGWYbvJXIOVrDHHWrb+z07bzQ +T8XRa4qdBGuV8OYQBzYR6EIb/DkEgBmfC/uTu7XFpQbmfw7Dnh0JcoJ9Dx9St7Xl +0HBb5gY0thL2cCrwe+PTSLV7S0nZLxp/azz/ROuzYWFSsSWMC4/hqdsoaa7PoVC6 +Lr66LVF6hzL/TtACRCS4X7nu/7GzXNwE1BTa +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIUDYU6Ki4xOPXM8RawT32qDO00934wDQYJKoZIhvcNAQEL +BQAwJzElMCMGA1UEAwwcb3BlbmFpLXB5dGhvbi1tdGxzLXRlc3Qtcm9vdDAgFw0y +NjA3MjgxOTI3NTJaGA8yMTI2MDcwNDE5Mjc1MlowLzEtMCsGA1UEAwwkb3BlbmFp +LXB5dGhvbi1tdGxzLXRlc3QtaW50ZXJtZWRpYXRlMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAmye/0UvrNwfMZRx53QYM1W47rTtCQlp4Yo7L6EmceAzE +9Njf7IW6NPDqfH35zFOtHlD4rFaU6elkwNO0dyG37u3haaRUcUGhpDerI0RebZkH +XFwXVB9nbDI/5+7wtpqdKM10Mn88UtHG/akTpoqjZnlBfYBoNaTzb8UgVCQxsXuH +qxoFzxr1zjBl+wCm0WidzhsLCCBogH3h9RZ/kKrWTWkoVx6Wky7gUut04g3CEQtW +6XGV3edVHEBUAnXsx32ktpFiZwL1MKo68Lebb4PrXrAHuVyjYBFGXwSXluxSDKsH +44q82YMtwge1uhwdFg9Tiz7U5LeEcTTSp48aCeymnwIDAQABo2YwZDASBgNVHRMB +Af8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUtnga2/6adiL8 +OkhReR+msVTodsQwHwYDVR0jBBgwFoAUHDFyfiCXhqFiwcl9ZAWApe40+m0wDQYJ +KoZIhvcNAQELBQADggEBACaN1i97TDdZUB0I2p1yBOnbGDVNdmJYwkrWU+CR3StT +dz84/QAWztvKSwtPzuzk4RRpE6EzOPbYmEqTxRdKB08t1aBMZTasdkKjl8WmRoAE +XOYtPE/d53dnC+T3qfyJhq882H95kHBBsvPG8kWEhX5BN30qET2FP6xi6cTAXtR0 +py3AV9qZqx9gxWIC2O1sh9nHmkvhk7WKxlhjkn5EEpDcLX9QfWy8iGrhoN+5RMR7 +1/T6JyP58q1DR8SZ5RaFBO7e5QPYNj8mFf2/wMJZN8kMZZgrXQmX3W9GkIq48evk +ACDggWyBKx+I44bcCRuSOfDTiGLRY6AmEGx7uQBHDT4= +-----END CERTIFICATE----- diff --git a/tests/fixtures/mtls/client.key b/tests/fixtures/mtls/client.key new file mode 100644 index 0000000000..a60815950b --- /dev/null +++ b/tests/fixtures/mtls/client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDKfqXwVVGGA+o6 +tk1pJi8yyYpApRadLwTO643ETasj7x41w9MH1jznEaZB+UZkm8IO/tcRF11+YiKq +Y2gFS0bthafjDCwB4+Ts6v3/GLfzkCi6FgMFoOhNs5QvQiM5hpSfUOyHNf0yPsMx +xqb8K0+YF0yMmryc+cVTwD31ZyKwgQ24zWNR9HOsHAqHCo0+SDzcBufo6Ps+BstJ +vxkp1DDRFTvvSqhVZ7w5WrP/CfzPqvKXTbuv0fGrFCakhNeyRN9y7ygP0YOODgln +duIV9al4N9k/8axAZsQjjnxMV+5lT/FLlqSQTmH9HHsjTQoOKyqOhHk8JSP+QctA +otLWYK+dAgMBAAECggEAR/hdiB7627P2gymaN94fdmCVZ8aFVBaLEfQ8reGhCyOI +zDkufyGRAduPCPHNKCMIBQZkcCmqzCmbAo5UQVVw/yi69AK2fXF/Qwl+fzVM5B9/ +qiv6pPx8tGk4KNfL5z1DA6DigGga9snB3KYrYYMPRhI53dt9YBmSHeM84kTm2m5S +DNfK0kzedahJ38FihqeHShSL/vsxqX2zU9MWhT/zkyw4ca3NVJ1Bvio/d+Ds0e3o +LEM2K7t2Oe1ALhn7RPG2yqF1Jts6CfVgNzMDLwlv/oOkckqwMbvsWTThixJsqRdL +A/nYWf6mraXQSOLvtntwYxG38s7VGWLwbWqumBLMLQKBgQDorRkw0VlIneldnpnF +KiTlCVxnG1mtGOYRHee5UgN2AwKIursLtuEv7oSy1bHh5rRX0udSGPFrBr7TaPH2 +/fY4Le5hVUD7SLCl7pS4gpfdCeg5IYibSU6GxvRiXAKTGAUO4Pcu9xXNNDG3B4Vx +jOMkkUrSIm1yisTZhhA5MZyQywKBgQDeywm3rB7hTc1kjrkNL5/gTmuG86lYck9D +P8MrkaATt1YK5icPTL1GwyMjrliR7UvpP3pEdA1sG1UX2PoS4y58wTmfBFQCWb9a +9ZQMmrZQqxHyl3fuVGnBOTMDjUQdmJPTpHS/OaMjEEiR8MWOT/GS+tEwRCSb/AK+ +Zkv/X6o8NwKBgQCqjnhwuITiHh76aU/+ny38VihNzFan9CBxW6KIzf2LfBlXcMm7 +hIr9P7I2BT8ngJ2h4w99tpsBASjQf5UeoHrkI4ciAgRoLpiOiZyqw8/eT2zStCoW +6l2Nnjl2AExC1tCeX3nSC30HtsLaj8DZw5SdMYPPFT11QRObABLUWfGSkQKBgQCq +DoDUWeUYRLLKVsaZcgiuxiz9TW+tu1MVGc53qyhs5DwhBZw66XBwWvKvgZzJhj+z +QmipZ4v3QMWq9kurrw0E3NiGsF8PjEGrxFfFZzJSUMHaUhORL42pl2eBBos/q/7q +RVV3wR7s3LkH7KhfAFZ8wkZ6eQkYpzvQ6XSI8RSX8QKBgQDcWG98InGPTmUTxKqH +41BLurASrV9tgQ4IBWUETh6kDiAFf4pqprnWU2WWWXW1Gvh53SCWLpR+ZoCF7ChE +xxFEbdKHQXF9YF9i6B6tLtfZAuHa2rP/oW7pJOPMZGgdP0fqxajS9q75bbkIkHVX +Z5IDyucHjXINQKwwOpSXGC/uQw== +-----END PRIVATE KEY----- diff --git a/tests/fixtures/mtls/root.pem b/tests/fixtures/mtls/root.pem new file mode 100644 index 0000000000..2b3d24dee9 --- /dev/null +++ b/tests/fixtures/mtls/root.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgIUF3vN0MB349w6hj4TzZ8M1leIxqowDQYJKoZIhvcNAQEL +BQAwJzElMCMGA1UEAwwcb3BlbmFpLXB5dGhvbi1tdGxzLXRlc3Qtcm9vdDAgFw0y +NjA3MjgxOTI3NTJaGA8yMTI2MDcwNDE5Mjc1MlowJzElMCMGA1UEAwwcb3BlbmFp +LXB5dGhvbi1tdGxzLXRlc3Qtcm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAMDtWi85fXPWQGPnIh6bgRk2/Yht4Y9Rw4Cfads/gMbXWwY6uj9wPpyu +00nC56+hseL0DucIX7eKBWnmhHkJEkZERZL7QADlmeB3OzAnG+rUYq/YU/vQJ8ZW +GdIeJKDFoGy9gDCj37QXfvzQ9j02jWHzBhMyIvdyxfTskKgWsmUVQXj7Bl+2F6yC +g8dqNfY8bFbEab+QkxziMGuCuJ4Xy5A+Gsh7/XnxZouGqUZ48mhJ7BkPfJuK+lqY +sWF5symLegLlHgcVr5sDZNCVLXbeQoPjaXafL4SYpyCLBKR7sgJg36rzKtatlviz +wHByx1nh/leCydR3cWzfKqXZz9Qfe50CAwEAAaNjMGEwHQYDVR0OBBYEFBwxcn4g +l4ahYsHJfWQFgKXuNPptMB8GA1UdIwQYMBaAFBwxcn4gl4ahYsHJfWQFgKXuNPpt +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUA +A4IBAQBrn+gwfOuJgLFNp1Ss7u67QlAT0CdQnF+WntTEbucOEXFkPU8hSA9GYy84 +rCAGeBjruNwkOGXiXUdCL3Ijz0Vde96C6UdSUWoRePsQfztrD9FxXJjQCEUFUHOl +AjMP/oGxY44t9WlxDJ8vk0O7klAliOSGRCSFPe6RoAM7Zq6G7psxLUBsNStKn154 +GWLhAo2GlPZqRhIdjpdsyznP60bRGURhsr/tLUrecVSWIh3PeDLdTNNoQZypZr1s +ewHx5+bFAWhndWf7aDLmFHEThgABYJ8Pim1BrGM1TbPPw+UJi4mKcx6LP8bcP/Je +W8L2YCAvvQEYWWrDOehzotLOH59N +-----END CERTIFICATE----- diff --git a/tests/fixtures/mtls/server-chain.pem b/tests/fixtures/mtls/server-chain.pem new file mode 100644 index 0000000000..8aa2c92efc --- /dev/null +++ b/tests/fixtures/mtls/server-chain.pem @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE----- +MIIDZjCCAk6gAwIBAgIUYfX+FG1H1GIF9JGcTGENkRmmO0EwDQYJKoZIhvcNAQEL +BQAwLzEtMCsGA1UEAwwkb3BlbmFpLXB5dGhvbi1tdGxzLXRlc3QtaW50ZXJtZWRp +YXRlMCAXDTI2MDcyODE5Mjc1MloYDzIxMjYwNzA0MTkyNzUyWjAUMRIwEAYDVQQD +DAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQClNMQb +1AZcVw7yeRjW7KkzsIZoZcYTIccE4+XmxQbPtsSL97DeIQWs7q2338w8bUkhUNnd +72xzGQsHXmUHbg7uzXopOoUmEq5oRFM8DFBm0CfzhjtYYCAvAV42SAEEw9Tjpuln +OxdL5ETRCl9MrAeRpObwsiDwgjfHGelqvUFQCzEU3Wwfg3ASbHJi9e68aI3eE8Bu +ztNudDNVD5QGBWf90Sfka6z5ZNE/F2Bw7oawPuaUEYDppxyAZj9qMjXwfCYeAFuK +pBNbbXg63HX0+OI40t3JcBMJ2Gq2sPHk4tk0t9qOHMw8vxlH6aDJr2Glef0iHNA2 +VatMf+wINC9mhDizAgMBAAGjgZIwgY8wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8E +BAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwGgYDVR0RBBMwEYIJbG9jYWxob3N0 +hwR/AAABMB0GA1UdDgQWBBQ+QAQjOrnj9tFsg0MLhXDBw+U0rjAfBgNVHSMEGDAW +gBS2eBrb/pp2Ivw6SFF5H6axVOh2xDANBgkqhkiG9w0BAQsFAAOCAQEAf0O9Gz87 +mJojijghkDJZiWEy9vTHW2+OYGtDzzfdSgpRfNDMrhmFs+BINLNfZoS9D+XmUffM +J5MWAOTbD4XYgjSwbEkTP+1piz4ZZ5t3SjCDlxYm8znip0FGyeOOs4sCxVH9FJSK +AlEkyoN1jjSyKYAtOWlGZ7QafPuqKJZpozCMR0XOoNzh9VX+IzsfJVRrQbKNWqTV +1o+6aEop+9ooOgQRhrpGzHFLPhkjtPef+fhIcpyIyoOPiPxVoin+wRM2mzuezR1q +6hPU6CA/4mfGk2hO1vkNPbjCcnwfE0qK4VSmafD/TubrF9ABHrMud4wAhvwLV36z +KSpRj8j4j/7L6w== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIUDYU6Ki4xOPXM8RawT32qDO00934wDQYJKoZIhvcNAQEL +BQAwJzElMCMGA1UEAwwcb3BlbmFpLXB5dGhvbi1tdGxzLXRlc3Qtcm9vdDAgFw0y +NjA3MjgxOTI3NTJaGA8yMTI2MDcwNDE5Mjc1MlowLzEtMCsGA1UEAwwkb3BlbmFp +LXB5dGhvbi1tdGxzLXRlc3QtaW50ZXJtZWRpYXRlMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAmye/0UvrNwfMZRx53QYM1W47rTtCQlp4Yo7L6EmceAzE +9Njf7IW6NPDqfH35zFOtHlD4rFaU6elkwNO0dyG37u3haaRUcUGhpDerI0RebZkH +XFwXVB9nbDI/5+7wtpqdKM10Mn88UtHG/akTpoqjZnlBfYBoNaTzb8UgVCQxsXuH +qxoFzxr1zjBl+wCm0WidzhsLCCBogH3h9RZ/kKrWTWkoVx6Wky7gUut04g3CEQtW +6XGV3edVHEBUAnXsx32ktpFiZwL1MKo68Lebb4PrXrAHuVyjYBFGXwSXluxSDKsH +44q82YMtwge1uhwdFg9Tiz7U5LeEcTTSp48aCeymnwIDAQABo2YwZDASBgNVHRMB +Af8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUtnga2/6adiL8 +OkhReR+msVTodsQwHwYDVR0jBBgwFoAUHDFyfiCXhqFiwcl9ZAWApe40+m0wDQYJ +KoZIhvcNAQELBQADggEBACaN1i97TDdZUB0I2p1yBOnbGDVNdmJYwkrWU+CR3StT +dz84/QAWztvKSwtPzuzk4RRpE6EzOPbYmEqTxRdKB08t1aBMZTasdkKjl8WmRoAE +XOYtPE/d53dnC+T3qfyJhq882H95kHBBsvPG8kWEhX5BN30qET2FP6xi6cTAXtR0 +py3AV9qZqx9gxWIC2O1sh9nHmkvhk7WKxlhjkn5EEpDcLX9QfWy8iGrhoN+5RMR7 +1/T6JyP58q1DR8SZ5RaFBO7e5QPYNj8mFf2/wMJZN8kMZZgrXQmX3W9GkIq48evk +ACDggWyBKx+I44bcCRuSOfDTiGLRY6AmEGx7uQBHDT4= +-----END CERTIFICATE----- diff --git a/tests/fixtures/mtls/server.key b/tests/fixtures/mtls/server.key new file mode 100644 index 0000000000..d6f8ea3087 --- /dev/null +++ b/tests/fixtures/mtls/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQClNMQb1AZcVw7y +eRjW7KkzsIZoZcYTIccE4+XmxQbPtsSL97DeIQWs7q2338w8bUkhUNnd72xzGQsH +XmUHbg7uzXopOoUmEq5oRFM8DFBm0CfzhjtYYCAvAV42SAEEw9TjpulnOxdL5ETR +Cl9MrAeRpObwsiDwgjfHGelqvUFQCzEU3Wwfg3ASbHJi9e68aI3eE8BuztNudDNV +D5QGBWf90Sfka6z5ZNE/F2Bw7oawPuaUEYDppxyAZj9qMjXwfCYeAFuKpBNbbXg6 +3HX0+OI40t3JcBMJ2Gq2sPHk4tk0t9qOHMw8vxlH6aDJr2Glef0iHNA2VatMf+wI +NC9mhDizAgMBAAECggEANXUeEh0pE78t/IL36S/6TloMHAL2taEj666s6WAO5K6w +6dOz3STVV9CB0PJvfYwlckzdusVrE9FiMre2PFG+LkK6CVZA2IGKAv486rzXVXV8 +v/3K/T1ZnKw2Jp1lCvwtSp7rfrZtwuZx6CyRitdNubCg8/jH1NtmHhyB3cKwvCvl +ubKH4kS8P4u9dbwhtOrbxeTJAxlaYuV6jRu2YTQkcueL1f1PqvTEiLZD79GUgRxH +WG3wRdAdCICGBFoRDyknhO77mzDpKalzlZ4JDaW4pDegatLKFEGVqhvDZ9FFSW9m +MdEsuIXT/ClztVO/fr/H006JjMI+kqY02ydTownONQKBgQDWLuvPSZAiR4HxfZoT +1WX2ameJIwEMEM9JITHhf+fsJhZ08iP7Hq41/kYZqc8kyFOhFiNYFcrlf89Tkand +CXGY/tMKGVu1C3XtQE5Dcoe6qmH2AfJ6xV1PacZPcC++Tv7baCTG61XQHXjW7gbQ +m9qvdW+U1O0IXwjx3PQLdix/zwKBgQDFdeuz2NJxvrlDPuZqYwjifjLXbfZoD+v2 +wZXdS5Ea91bbRY+5oBZofFDNBVu/3UANUcfJc5bXGk7JQv+is1XSlnVKH/qm8PkK ++AZk1BkiwKmTHtuITt3d45dZ7XzVzVXeJcPjbJQFECGDxy3iH6sEpr0xTAg4ow6Z +PYfyIFWt3QKBgQCt1BTz/gsplwmCOeMDt8zx6bev2CXwafAhtPwrvMg4o0zUivTi +ySqwjXbNO0Dv5FnjQflbcwxhqJJWi8DlsNVuS1pyNtR0IiIKdIdQPDKmL8Qjib8H +Hwk0+27EaBOHi8tRvLskajkSF+lL3pDPW75napMtooXhpme3DBFRAA7rhwKBgQCq +4Vif5DSCQOY8vpNSX/ARadr/ueayuYyfl3nk739ckc21pmYx4sthkquuMUPsL0E+ +BZbazFAuSFMEMxndKEtOGezYwAH/NKyhBHEsEqzJ+WcGrX6YYH/6hPm21iHhOHhl +7dKu3ojeNM58JwObG4K5XL5/iefXc6yviqM6MydSdQKBgQDGlZQBO1Zsg6wsgCzG +GUoDUkvdaaghh9krTvbXH3a+CoNMC70w3h6yQFUssai2dmO4T6FcH3djdCoQ3YBW +s6WY/FEXd/lIeQJvZ6OEVQbZuYLgTdG4WEQCk5+3So3VNWnUpEfgo47DRLWZHnIG +SZ+akQs30o7bUEUSbzmyhkqimA== +-----END PRIVATE KEY----- diff --git a/tests/test_mtls_http_client.py b/tests/test_mtls_http_client.py new file mode 100644 index 0000000000..ae3318c741 --- /dev/null +++ b/tests/test_mtls_http_client.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import os +import ssl +import sys +import threading +import subprocess +from typing import Any, cast +from pathlib import Path +from contextlib import contextmanager +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from collections.abc import Iterator +from typing_extensions import override + +import pytest + +ROOT = Path(__file__).parent.parent +FIXTURES = Path(__file__).parent / "fixtures" / "mtls" +CERTIFICATE_CHAIN = FIXTURES / "client-chain.pem" +PRIVATE_KEY = FIXTURES / "client.key" +ROOT_CERTIFICATE = FIXTURES / "root.pem" +SERVER_CERTIFICATE_CHAIN = FIXTURES / "server-chain.pem" +SERVER_PRIVATE_KEY = FIXTURES / "server.key" + + +class _Handler(BaseHTTPRequestHandler): + peer_certificates: list[dict[str, Any]] = [] + request_paths: list[str] = [] + + def do_GET(self) -> None: + peer_certificate = cast(ssl.SSLSocket, self.connection).getpeercert() + assert peer_certificate is not None + self.peer_certificates.append(cast(dict[str, Any], peer_certificate)) + self.request_paths.append(self.path) + body = b'{"object":"list","data":[]}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + @override + def log_message(self, format: str, *args: object) -> None: + pass + + +@contextmanager +def _mtls_server() -> Iterator[ThreadingHTTPServer]: + _Handler.peer_certificates = [] + _Handler.request_paths = [] + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.minimum_version = ssl.TLSVersion.TLSv1_2 + server_context.load_cert_chain( + certfile=SERVER_CERTIFICATE_CHAIN, + keyfile=SERVER_PRIVATE_KEY, + ) + server_context.load_verify_locations(cafile=ROOT_CERTIFICATE) + server_context.verify_mode = ssl.CERT_REQUIRED + + server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + server.socket = server_context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join() + + +@pytest.mark.parametrize( + "example", + [ + "mtls_httpx.py", + "mtls_httpx_async.py", + "mtls_httpx2.py", + "mtls_httpx2_async.py", + ], +) +def test_mtls_example_presents_full_client_chain(example: str) -> None: + assert CERTIFICATE_CHAIN.read_text().count("-----BEGIN CERTIFICATE-----") == 2 + + with _mtls_server() as server: + proxy_variables = { + "ALL_PROXY", + "HTTPS_PROXY", + "HTTP_PROXY", + "NO_PROXY", + "all_proxy", + "https_proxy", + "http_proxy", + "no_proxy", + } + environment = { + **{name: value for name, value in os.environ.items() if name not in proxy_variables}, + "OPENAI_API_KEY": "test-api-key", + "OPENAI_BASE_URL": f"https://127.0.0.1:{server.server_port}/v1", + "OPENAI_MTLS_CA_BUNDLE": str(ROOT_CERTIFICATE), + "OPENAI_MTLS_CERTIFICATE_CHAIN": str(CERTIFICATE_CHAIN), + "OPENAI_MTLS_PRIVATE_KEY": str(PRIVATE_KEY), + } + result = subprocess.run( + [sys.executable, str(ROOT / "examples" / example)], + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "data=[]" in result.stdout + assert _Handler.request_paths == ["/v1/files"] + assert _Handler.peer_certificates[0]["subject"] == ((("commonName", "openai-python-mtls-test-client"),),)