Skip to content

Commit 241f7d4

Browse files
authored
feat: add TransportOptions for configuring TLS, proxy, and default headers (#103)
1 parent 0d4d38e commit 241f7d4

21 files changed

+600
-63
lines changed

README.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,84 @@ 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 provides a `TransportOptions` object that allows you to customise
202+
the underlying HTTP transport used for both OpenID discovery and API calls.
203+
204+
### Disabling TLS Verification
205+
206+
In development or testing environments with self-signed certificates, you can
207+
disable TLS verification entirely:
208+
209+
```python
210+
from zitadel_client import Zitadel, TransportOptions
211+
212+
options = TransportOptions(insecure=True)
213+
214+
zitadel = Zitadel.with_client_credentials(
215+
"https://your-instance.zitadel.cloud",
216+
"client-id",
217+
"client-secret",
218+
transport_options=options,
219+
)
220+
```
221+
222+
### Using a Custom CA Certificate
223+
224+
If your Zitadel instance uses a certificate signed by a private CA, you can
225+
provide the path to the CA certificate in PEM format:
226+
227+
```python
228+
from zitadel_client import Zitadel, TransportOptions
229+
230+
options = TransportOptions(ca_cert_path="/path/to/ca.pem")
231+
232+
zitadel = Zitadel.with_client_credentials(
233+
"https://your-instance.zitadel.cloud",
234+
"client-id",
235+
"client-secret",
236+
transport_options=options,
237+
)
238+
```
239+
240+
### Custom Default Headers
241+
242+
You can attach default headers to every outgoing request. This is useful for
243+
custom routing or tracing headers:
244+
245+
```python
246+
from zitadel_client import Zitadel, TransportOptions
247+
248+
options = TransportOptions(default_headers={"X-Custom-Header": "my-value"})
249+
250+
zitadel = Zitadel.with_client_credentials(
251+
"https://your-instance.zitadel.cloud",
252+
"client-id",
253+
"client-secret",
254+
transport_options=options,
255+
)
256+
```
257+
258+
### Proxy Configuration
259+
260+
If your environment requires routing traffic through an HTTP proxy, you can
261+
specify the proxy URL. To authenticate with the proxy, embed the credentials
262+
directly in the URL:
263+
264+
```python
265+
from zitadel_client import Zitadel, TransportOptions
266+
267+
options = TransportOptions(proxy_url="http://user:pass@proxy:8080")
268+
269+
zitadel = Zitadel.with_client_credentials(
270+
"https://your-instance.zitadel.cloud",
271+
"client-id",
272+
"client-secret",
273+
transport_options=options,
274+
)
275+
```
276+
199277
## Design and Dependencies
200278

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

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ dev = [
3434
"pytest-cov>=2.8.1",
3535
"tox>=3.9.0",
3636
"types-python-dateutil>=2.8.19.14",
37-
"testcontainers==3.7.1",
37+
"testcontainers>=4.14.0,<5.0.0",
3838
"python-dotenv==1.1.1",
3939
"ruff>=0.12.4",
4040
"sphinx==7.4.7",

test/fixtures/ca.pem

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDOjCCAiKgAwIBAgIUYtCHt3J95fUpagYaFNw8M1/oV7kwDQYJKoZIhvcNAQEL
3+
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDMwNDA1MTcwNVoYDzIxMjYw
4+
MjA4MDUxNzA1WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB
5+
AQUAA4IBDwAwggEKAoIBAQCVY1jORnqyVB9tUgYYo9U3uYCVtCzWt3lGCoxDpxAb
6+
LlpNnqOxG33ugRbNTY/QBht37Q37PjBahMJxkRE7EPsqi2Bz2fsZMyB7pJgP5iTA
7+
0cILFyFzGpgUkXjmtsozKy0jAHpnzHGALjtzoKgp4SxCrWSp/MYtfMkBP9xbEpf1
8+
IYYQyyiISgic0/vO+nUEjyR/ULFP+nd48KjOHwWIHqwMY3nuzqScshAsyIZzSRT0
9+
ND2TLK1rxGoITqsOg2yTxRWwP0khvE08Y/59BGfWZq0svBCp2E3sIXg2Z3hlie7o
10+
n+3P0F00kQfrEvkTi/cHv2vuhJpnlHxmTgJBRwhWE2+xAgMBAAGjgYEwfzAdBgNV
11+
HQ4EFgQUPOzmGXHMRu3zIZqKLad8EkHkZvowHwYDVR0jBBgwFoAUPOzmGXHMRu3z
12+
IZqKLad8EkHkZvowDwYDVR0TAQH/BAUwAwEB/zAsBgNVHREEJTAjgglsb2NhbGhv
13+
c3SHBH8AAAGHEAAAAAAAAAAAAAAAAAAAAAEwDQYJKoZIhvcNAQELBQADggEBAD/z
14+
IRzYSBp6qPrvVgIX5/mEwN6ylp1J1pTC8nPQRozg0X2SEnRxz1DGBa1l046QVew2
15+
3+LuGYWtVkTzEtiX7BN2jSshX8d8Ss73+psZOye6t8VcAmEeVVdnqU+EzVAhM1DP
16+
mUiNxJPHgK2cZkpV2BHB0Ccu7qVfaIFvTk2OdbGOsQ7+r2l562kUDzCFvBo/mskO
17+
xiIt3YMZrpyLJJzvgi+fIo351oqLvTKOHw30FelAPIHo/A2OgngsM31HvwxROYlr
18+
C5mET6wnOtjTQbKORADTGQ8D3sJCjQJ/AI34Q4C2q/PBljVL8JKoAPzwviYAuqdd
19+
NIIKpaYUzng24gw7+50=
20+
-----END CERTIFICATE-----

test/fixtures/keystore.p12

2.58 KB
Binary file not shown.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"request": {
3+
"method": "GET",
4+
"url": "/.well-known/openid-configuration"
5+
},
6+
"response": {
7+
"status": 200,
8+
"headers": {
9+
"Content-Type": "application/json"
10+
},
11+
"jsonBody": {
12+
"issuer": "{{request.baseUrl}}",
13+
"token_endpoint": "{{request.baseUrl}}/oauth/v2/token",
14+
"authorization_endpoint": "{{request.baseUrl}}/oauth/v2/authorize",
15+
"userinfo_endpoint": "{{request.baseUrl}}/oidc/v1/userinfo",
16+
"jwks_uri": "{{request.baseUrl}}/oauth/v2/keys"
17+
}
18+
}
19+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"request": {
3+
"method": "POST",
4+
"url": "/zitadel.settings.v2.SettingsService/GetGeneralSettings"
5+
},
6+
"response": {
7+
"status": 200,
8+
"headers": {
9+
"Content-Type": "application/json"
10+
},
11+
"jsonBody": {
12+
"defaultLanguage": "{{request.scheme}}",
13+
"defaultOrgId": "{{request.headers.X-Custom-Header}}"
14+
}
15+
}
16+
}

test/fixtures/mappings/token.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"request": {
3+
"method": "POST",
4+
"url": "/oauth/v2/token"
5+
},
6+
"response": {
7+
"status": 200,
8+
"headers": {
9+
"Content-Type": "application/json"
10+
},
11+
"jsonBody": {
12+
"access_token": "test-token-12345",
13+
"token_type": "Bearer",
14+
"expires_in": 3600
15+
}
16+
}
17+
}

test/fixtures/squid.conf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
http_port 3128
2+
acl all src all
3+
http_access allow all

test/test_transport_options.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import unittest
2+
3+
from zitadel_client.transport_options import TransportOptions
4+
5+
6+
class TransportOptionsTest(unittest.TestCase):
7+
def test_defaults_returns_empty(self) -> None:
8+
self.assertEqual({}, TransportOptions.defaults().to_session_kwargs())
9+
10+
def test_insecure_sets_verify_false(self) -> None:
11+
opts = TransportOptions(insecure=True)
12+
self.assertEqual({"verify": False}, opts.to_session_kwargs())
13+
14+
def test_ca_cert_path_sets_verify(self) -> None:
15+
opts = TransportOptions(ca_cert_path="/path/to/ca.pem")
16+
self.assertEqual({"verify": "/path/to/ca.pem"}, opts.to_session_kwargs())
17+
18+
def test_proxy_url_sets_proxies(self) -> None:
19+
opts = TransportOptions(proxy_url="http://proxy:3128")
20+
self.assertEqual(
21+
{"proxies": {"http": "http://proxy:3128", "https": "http://proxy:3128"}},
22+
opts.to_session_kwargs(),
23+
)
24+
25+
def test_insecure_takes_precedence_over_ca_cert(self) -> None:
26+
opts = TransportOptions(insecure=True, ca_cert_path="/nonexistent/ca.pem")
27+
self.assertEqual({"verify": False}, opts.to_session_kwargs())
28+
29+
def test_immutability(self) -> None:
30+
opts = TransportOptions.defaults()
31+
with self.assertRaises(AttributeError):
32+
opts.insecure = True # type: ignore[misc]
33+
34+
def test_defaults_factory(self) -> None:
35+
opts = TransportOptions.defaults()
36+
self.assertEqual({}, dict(opts.default_headers))
37+
self.assertIsNone(opts.ca_cert_path)
38+
self.assertFalse(opts.insecure)
39+
self.assertIsNone(opts.proxy_url)

test/test_zitadel.py

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,32 @@
11
import importlib
22
import inspect
3+
import os
34
import pkgutil
45
import unittest
6+
import urllib.request
7+
from typing import Optional
8+
9+
from testcontainers.core.container import DockerContainer
10+
from testcontainers.core.network import Network
11+
from testcontainers.core.wait_strategies import PortWaitStrategy
12+
from testcontainers.core.waiting_utils import wait_container_is_ready
513

614
from zitadel_client.auth.no_auth_authenticator import NoAuthAuthenticator
15+
from zitadel_client.transport_options import TransportOptions
716
from zitadel_client.zitadel import Zitadel
817

18+
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
19+
20+
21+
@wait_container_is_ready()
22+
def _wait_for_wiremock(host: str, port: str) -> None:
23+
url = f"http://{host}:{port}/__admin/mappings"
24+
with urllib.request.urlopen(url, timeout=5) as resp: # noqa: S310
25+
if resp.status != 200:
26+
raise ConnectionError(f"WireMock not ready: {resp.status}")
27+
928

1029
class ZitadelServicesTest(unittest.TestCase):
11-
"""
12-
Test to verify that all API service classes defined in the "zitadel_client.api" namespace
13-
are registered as attributes in the Zitadel class.
14-
"""
1530

1631
def test_services_dynamic(self) -> None:
1732
expected = set()
@@ -30,3 +45,114 @@ def test_services_dynamic(self) -> None:
3045
and getattr(zitadel, attr).__class__.__module__.startswith("zitadel_client.api")
3146
}
3247
self.assertEqual(expected, actual)
48+
49+
50+
class ZitadelTransportTest(unittest.TestCase):
51+
host: Optional[str] = None
52+
http_port: Optional[str] = None
53+
https_port: Optional[str] = None
54+
proxy_port: Optional[str] = None
55+
ca_cert_path: Optional[str] = None
56+
wiremock: DockerContainer = None
57+
proxy: DockerContainer = None
58+
network: Network = None
59+
60+
@classmethod
61+
def setup_class(cls) -> None:
62+
cls.ca_cert_path = os.path.join(FIXTURES_DIR, "ca.pem")
63+
keystore_path = os.path.join(FIXTURES_DIR, "keystore.p12")
64+
squid_conf = os.path.join(FIXTURES_DIR, "squid.conf")
65+
66+
cls.network = Network().create()
67+
68+
cls.wiremock = (
69+
DockerContainer("wiremock/wiremock:3.12.1")
70+
.with_network(cls.network)
71+
.with_network_aliases("wiremock")
72+
.with_exposed_ports(8080, 8443)
73+
.with_volume_mapping(keystore_path, "/home/wiremock/keystore.p12", mode="ro")
74+
.with_volume_mapping(
75+
os.path.join(FIXTURES_DIR, "mappings"), "/home/wiremock/mappings", mode="ro"
76+
)
77+
.with_command(
78+
"--https-port 8443"
79+
" --https-keystore /home/wiremock/keystore.p12"
80+
" --keystore-password password"
81+
" --keystore-type PKCS12"
82+
" --global-response-templating"
83+
)
84+
)
85+
cls.wiremock.start()
86+
87+
cls.proxy = (
88+
DockerContainer("ubuntu/squid:6.10-24.10_beta")
89+
.with_network(cls.network)
90+
.with_exposed_ports(3128)
91+
.with_volume_mapping(squid_conf, "/etc/squid/squid.conf", mode="ro")
92+
.waiting_for(PortWaitStrategy(3128))
93+
)
94+
cls.proxy.start()
95+
96+
cls.host = cls.wiremock.get_container_host_ip()
97+
cls.http_port = cls.wiremock.get_exposed_port(8080)
98+
cls.https_port = cls.wiremock.get_exposed_port(8443)
99+
cls.proxy_port = cls.proxy.get_exposed_port(3128)
100+
101+
_wait_for_wiremock(cls.host, cls.http_port)
102+
103+
@classmethod
104+
def teardown_class(cls) -> None:
105+
if cls.proxy is not None:
106+
cls.proxy.stop()
107+
if cls.wiremock is not None:
108+
cls.wiremock.stop()
109+
if cls.network is not None:
110+
cls.network.remove()
111+
112+
def test_custom_ca_cert(self) -> None:
113+
zitadel = Zitadel.with_client_credentials(
114+
f"https://{self.host}:{self.https_port}",
115+
"dummy-client",
116+
"dummy-secret",
117+
transport_options=TransportOptions(ca_cert_path=self.ca_cert_path),
118+
)
119+
response = zitadel.settings.get_general_settings({})
120+
self.assertEqual("https", response.default_language)
121+
122+
def test_insecure_mode(self) -> None:
123+
zitadel = Zitadel.with_client_credentials(
124+
f"https://{self.host}:{self.https_port}",
125+
"dummy-client",
126+
"dummy-secret",
127+
transport_options=TransportOptions(insecure=True),
128+
)
129+
response = zitadel.settings.get_general_settings({})
130+
self.assertEqual("https", response.default_language)
131+
132+
def test_default_headers(self) -> None:
133+
zitadel = Zitadel.with_client_credentials(
134+
f"http://{self.host}:{self.http_port}",
135+
"dummy-client",
136+
"dummy-secret",
137+
transport_options=TransportOptions(default_headers={"X-Custom-Header": "test-value"}),
138+
)
139+
response = zitadel.settings.get_general_settings({})
140+
self.assertEqual("http", response.default_language)
141+
self.assertEqual("test-value", response.default_org_id)
142+
143+
def test_proxy_url(self) -> None:
144+
zitadel = Zitadel.with_access_token(
145+
"http://wiremock:8080",
146+
"test-token",
147+
transport_options=TransportOptions(proxy_url=f"http://{self.host}:{self.proxy_port}"),
148+
)
149+
response = zitadel.settings.get_general_settings({})
150+
self.assertEqual("http", response.default_language)
151+
152+
def test_no_ca_cert_fails(self) -> None:
153+
with self.assertRaises(Exception): # noqa: B017
154+
Zitadel.with_client_credentials(
155+
f"https://{self.host}:{self.https_port}",
156+
"dummy-client",
157+
"dummy-secret",
158+
)

0 commit comments

Comments
 (0)