Skip to content

Commit a2ec0f4

Browse files
committed
Add TransportOptions for configuring TLS, proxy, and default headers
Introduce a TransportOptions dataclass that encapsulates transport-level configuration (default_headers, ca_cert_path, insecure, proxy_url). Factory methods now accept an optional transport_options parameter alongside the existing individual parameters for backward compatibility. Internal auth plumbing (OpenId, builders) refactored to use TransportOptions throughout.
1 parent c95b88d commit a2ec0f4

13 files changed

Lines changed: 499 additions & 26 deletions

README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,97 @@ Choose the authentication method that best suits your needs based on your
196196
environment and security requirements. For more details, please refer to the
197197
[Zitadel documentation on authenticating service users](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users).
198198

199+
## Advanced Configuration
200+
201+
The SDK factory methods (`with_client_credentials`, `with_private_key`,
202+
`with_access_token`) accept additional keyword arguments for advanced
203+
transport configuration.
204+
205+
### Disabling TLS Verification
206+
207+
To disable TLS certificate verification (useful for development with
208+
self-signed certificates), pass `insecure=True`:
209+
210+
```python
211+
import zitadel_client as zitadel
212+
213+
client = zitadel.Zitadel.with_client_credentials(
214+
"https://example.us1.zitadel.cloud",
215+
"client-id",
216+
"client-secret",
217+
insecure=True,
218+
)
219+
```
220+
221+
### Using a Custom CA Certificate
222+
223+
To use a custom CA certificate for TLS verification, pass
224+
`ca_cert_path` with the path to your CA certificate file:
225+
226+
```python
227+
import zitadel_client as zitadel
228+
229+
client = zitadel.Zitadel.with_client_credentials(
230+
"https://example.us1.zitadel.cloud",
231+
"client-id",
232+
"client-secret",
233+
ca_cert_path="/path/to/ca.pem",
234+
)
235+
```
236+
237+
### Custom Default Headers
238+
239+
To send custom headers with every request, pass a `default_headers`
240+
dictionary:
241+
242+
```python
243+
import zitadel_client as zitadel
244+
245+
client = zitadel.Zitadel.with_client_credentials(
246+
"https://example.us1.zitadel.cloud",
247+
"client-id",
248+
"client-secret",
249+
default_headers={"Proxy-Authorization": "Basic ..."},
250+
)
251+
```
252+
253+
### Proxy Configuration
254+
255+
To route all SDK traffic through an HTTP proxy, pass a `proxy_url`:
256+
257+
```python
258+
import zitadel_client as zitadel
259+
260+
client = zitadel.Zitadel.with_client_credentials(
261+
"https://example.us1.zitadel.cloud",
262+
"client-id",
263+
"client-secret",
264+
proxy_url="http://proxy:8080",
265+
)
266+
```
267+
268+
### Using TransportOptions
269+
270+
All transport settings can be combined into a single `TransportOptions` object:
271+
272+
```python
273+
from zitadel_client import Zitadel, TransportOptions
274+
275+
options = TransportOptions(
276+
insecure=True,
277+
ca_cert_path="/path/to/ca.pem",
278+
default_headers={"Proxy-Authorization": "Basic dXNlcjpwYXNz"},
279+
proxy_url="http://proxy:8080",
280+
)
281+
282+
zitadel = Zitadel.with_client_credentials(
283+
"https://my-instance.zitadel.cloud",
284+
"client-id",
285+
"client-secret",
286+
transport_options=options,
287+
)
288+
```
289+
199290
## Design and Dependencies
200291

201292
This SDK is designed to be lean and efficient, focusing on providing a

test/test_transport_options.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import json
2+
import os
3+
import ssl
4+
import tempfile
5+
import time
6+
import unittest
7+
import urllib.request
8+
from typing import Optional
9+
10+
from testcontainers.core.container import DockerContainer
11+
12+
from zitadel_client.transport_options import TransportOptions
13+
from zitadel_client.zitadel import Zitadel
14+
15+
16+
class TransportOptionsTest(unittest.TestCase):
17+
"""
18+
Test class for verifying transport options (default_headers, ca_cert_path, insecure)
19+
on the Zitadel factory methods.
20+
21+
This class starts a Docker container running WireMock with HTTPS support before any
22+
tests run and stops it after all tests. It registers stubs for OpenID Configuration
23+
discovery and the token endpoint so that Zitadel.with_client_credentials() can
24+
complete its initialization flow.
25+
"""
26+
27+
host: Optional[str] = None
28+
http_port: Optional[str] = None
29+
https_port: Optional[str] = None
30+
ca_cert_path: Optional[str] = None
31+
wiremock: DockerContainer = None
32+
33+
@classmethod
34+
def setup_class(cls) -> None:
35+
cls.wiremock = (
36+
DockerContainer("wiremock/wiremock:3.3.1")
37+
.with_exposed_ports(8080, 8443)
38+
.with_command("--https-port 8443 --global-response-templating")
39+
)
40+
cls.wiremock.start()
41+
42+
cls.host = cls.wiremock.get_container_host_ip()
43+
cls.http_port = cls.wiremock.get_exposed_port(8080)
44+
cls.https_port = cls.wiremock.get_exposed_port(8443)
45+
46+
# Wait for WireMock to be ready by polling the admin API
47+
admin_url = f"http://{cls.host}:{cls.http_port}/__admin/mappings"
48+
for _ in range(30):
49+
try:
50+
with urllib.request.urlopen(admin_url, timeout=2) as resp: # noqa: S310
51+
if resp.status == 200:
52+
break
53+
except Exception: # noqa: S110, BLE001
54+
pass
55+
time.sleep(1)
56+
57+
# Register stub for OpenID Configuration discovery
58+
oidc_stub = json.dumps(
59+
{
60+
"request": {"method": "GET", "url": "/.well-known/openid-configuration"},
61+
"response": {
62+
"status": 200,
63+
"headers": {"Content-Type": "application/json"},
64+
"body": (
65+
'{"issuer":"{{request.baseUrl}}",'
66+
'"token_endpoint":"{{request.baseUrl}}/oauth/v2/token",'
67+
'"authorization_endpoint":"{{request.baseUrl}}/oauth/v2/authorize",'
68+
'"userinfo_endpoint":"{{request.baseUrl}}/oidc/v1/userinfo",'
69+
'"jwks_uri":"{{request.baseUrl}}/oauth/v2/keys"}'
70+
),
71+
},
72+
}
73+
).encode()
74+
75+
req = urllib.request.Request(
76+
f"http://{cls.host}:{cls.http_port}/__admin/mappings",
77+
data=oidc_stub,
78+
headers={"Content-Type": "application/json"},
79+
method="POST",
80+
)
81+
with urllib.request.urlopen(req) as resp: # noqa: S310
82+
assert resp.status == 201
83+
84+
# Register stub for the token endpoint
85+
token_stub = json.dumps(
86+
{
87+
"request": {"method": "POST", "url": "/oauth/v2/token"},
88+
"response": {
89+
"status": 200,
90+
"headers": {"Content-Type": "application/json"},
91+
"jsonBody": {
92+
"access_token": "test-token-12345",
93+
"token_type": "Bearer",
94+
"expires_in": 3600,
95+
},
96+
},
97+
}
98+
).encode()
99+
100+
req = urllib.request.Request(
101+
f"http://{cls.host}:{cls.http_port}/__admin/mappings",
102+
data=token_stub,
103+
headers={"Content-Type": "application/json"},
104+
method="POST",
105+
)
106+
with urllib.request.urlopen(req) as resp: # noqa: S310
107+
assert resp.status == 201
108+
109+
# Extract the WireMock HTTPS certificate to a temp file
110+
pem_cert = ssl.get_server_certificate((cls.host, int(cls.https_port)))
111+
cert_file = tempfile.NamedTemporaryFile(suffix=".pem", delete=False)
112+
cert_file.write(pem_cert.encode())
113+
cert_file.close()
114+
cls.ca_cert_path = cert_file.name
115+
116+
@classmethod
117+
def teardown_class(cls) -> None:
118+
if cls.ca_cert_path is not None:
119+
os.unlink(cls.ca_cert_path)
120+
if cls.wiremock is not None:
121+
cls.wiremock.stop()
122+
123+
def test_custom_ca_cert(self) -> None:
124+
zitadel = Zitadel.with_client_credentials(
125+
f"https://{self.host}:{self.https_port}",
126+
"dummy-client",
127+
"dummy-secret",
128+
ca_cert_path=self.ca_cert_path,
129+
)
130+
self.assertIsNotNone(zitadel)
131+
132+
def test_insecure_mode(self) -> None:
133+
zitadel = Zitadel.with_client_credentials(
134+
f"https://{self.host}:{self.https_port}",
135+
"dummy-client",
136+
"dummy-secret",
137+
insecure=True,
138+
)
139+
self.assertIsNotNone(zitadel)
140+
141+
def test_default_headers(self) -> None:
142+
# Use HTTP to avoid TLS concerns
143+
zitadel = Zitadel.with_client_credentials(
144+
f"http://{self.host}:{self.http_port}",
145+
"dummy-client",
146+
"dummy-secret",
147+
default_headers={"X-Custom-Header": "test-value"},
148+
)
149+
self.assertIsNotNone(zitadel)
150+
151+
# Verify via WireMock request journal
152+
journal_url = f"http://{self.host}:{self.http_port}/__admin/requests"
153+
with urllib.request.urlopen(journal_url) as response: # noqa: S310
154+
journal = json.loads(response.read().decode())
155+
156+
found_header = False
157+
for req in journal.get("requests", []):
158+
headers = req.get("request", {}).get("headers", {})
159+
if "X-Custom-Header" in headers:
160+
found_header = True
161+
break
162+
self.assertTrue(found_header, "Custom header should be present in WireMock request journal")
163+
164+
def test_proxy_url(self) -> None:
165+
# Use HTTP (not HTTPS) to avoid TLS complications with the proxy
166+
zitadel = Zitadel.with_client_credentials(
167+
f"http://{self.host}:{self.http_port}",
168+
"dummy-client",
169+
"dummy-secret",
170+
proxy_url=f"http://{self.host}:{self.http_port}",
171+
)
172+
self.assertIsNotNone(zitadel)
173+
174+
def test_no_ca_cert_fails(self) -> None:
175+
with self.assertRaises(Exception): # noqa: B017
176+
Zitadel.with_client_credentials(
177+
f"https://{self.host}:{self.https_port}",
178+
"dummy-client",
179+
"dummy-secret",
180+
)
181+
182+
def test_transport_options_object(self) -> None:
183+
opts = TransportOptions(insecure=True)
184+
zitadel = Zitadel.with_client_credentials(
185+
f"https://{self.host}:{self.https_port}",
186+
"dummy-client",
187+
"dummy-secret",
188+
transport_options=opts,
189+
)
190+
self.assertIsNotNone(zitadel)

uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

zitadel_client/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@
88
ZitadelError, # noqa F401
99
)
1010
from .models import * # noqa: F403, F401
11+
from .transport_options import TransportOptions # noqa F401
1112
from .zitadel import Zitadel # noqa F401

zitadel_client/api_client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ def __init__(
6565

6666
self.rest_client = rest.RESTClientObject(configuration)
6767
self.default_headers = {"User-Agent": configuration.user_agent}
68+
if configuration.default_headers:
69+
self.default_headers.update(configuration.default_headers)
6870
if header_name is not None and header_value is not None:
6971
self.default_headers[header_name] = header_value
7072
self.client_side_validation = configuration.client_side_validation

zitadel_client/auth/client_credentials_authenticator.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import sys
2-
from typing import Dict, Set
2+
from typing import Dict, Optional, Set
33

44
if sys.version_info >= (3, 12):
55
from typing import override
@@ -13,6 +13,7 @@
1313
OAuthAuthenticatorBuilder,
1414
)
1515
from zitadel_client.auth.open_id import OpenId
16+
from zitadel_client.transport_options import TransportOptions
1617

1718

1819
class ClientCredentialsAuthenticator(OAuthAuthenticator):
@@ -50,16 +51,19 @@ def get_grant(self) -> Dict[str, str]:
5051
return {"grant_type": "client_credentials"}
5152

5253
@staticmethod
53-
def builder(host: str, client_id: str, client_secret: str) -> "ClientCredentialsAuthenticatorBuilder":
54+
def builder(
55+
host: str, client_id: str, client_secret: str, transport_options: Optional[TransportOptions] = None
56+
) -> "ClientCredentialsAuthenticatorBuilder":
5457
"""
5558
Returns a builder for constructing a ClientCredentialsAuthenticator.
5659
5760
:param host: The base URL for the OAuth provider.
5861
:param client_id: The OAuth client identifier.
5962
:param client_secret: The OAuth client secret.
63+
:param transport_options: Optional TransportOptions for configuring HTTP connections.
6064
:return: A ClientCredentialsAuthenticatorBuilder instance.
6165
"""
62-
return ClientCredentialsAuthenticatorBuilder(host, client_id, client_secret)
66+
return ClientCredentialsAuthenticatorBuilder(host, client_id, client_secret, transport_options=transport_options)
6367

6468

6569
class ClientCredentialsAuthenticatorBuilder(OAuthAuthenticatorBuilder["ClientCredentialsAuthenticatorBuilder"]):
@@ -70,15 +74,16 @@ class ClientCredentialsAuthenticatorBuilder(OAuthAuthenticatorBuilder["ClientCre
7074
required for the client credentials flow.
7175
"""
7276

73-
def __init__(self, host: str, client_id: str, client_secret: str):
77+
def __init__(self, host: str, client_id: str, client_secret: str, transport_options: Optional[TransportOptions] = None):
7478
"""
7579
Initializes the ClientCredentialsAuthenticatorBuilder with host, client ID, and client secret.
7680
7781
:param host: The base URL for the OAuth provider.
7882
:param client_id: The OAuth client identifier.
7983
:param client_secret: The OAuth client secret.
84+
:param transport_options: Optional TransportOptions for configuring HTTP connections.
8085
"""
81-
super().__init__(host)
86+
super().__init__(host, transport_options=transport_options)
8287
self.client_id = client_id
8388
self.client_secret = client_secret
8489

0 commit comments

Comments
 (0)